Python-数据清理秘籍第二版-全-
Python 数据清理秘籍第二版(全)
原文:
annas-archive.org/md5/2774e54b23314a6bebe51d6caf9cd592译者:飞龙
前言
本书是一本关于数据清理的实用指南,广义上定义为所有为分析准备数据所需的任务。它按照数据清理过程中的常见任务进行组织:导入数据、诊断性查看数据、识别离群值和意外值、填补缺失值、整理数据等。每个实例都会引导读者从原始数据处理到完成特定的数据清理任务。
市面上已经有一些非常好的 pandas 书籍。不出所料,这些书籍和本书有一些内容重叠。然而,本书的重点不同。我在本书中不仅关注“如何做”,也同样重视“为什么这么做”。
由于 pandas 仍然相对较新,我在数据清理方面学到的经验,受到了我使用其他工具时的影响。在大约 10 年前我定居于现在的 Python 和 R 工作方式之前,我在 2000 年代初期主要依赖 C# 和 T-SQL,90 年代使用 SAS 和 Stata,80 年代使用 FORTRAN 和 Pascal。本书的大多数读者可能都对多种数据清理和分析工具有一定经验。从许多方面来看,特定工具的重要性不如数据准备任务和数据的属性。我如果被要求编写《SAS 数据清理手册》或《R 数据清理手册》,也会覆盖几乎相同的内容。我只是用 Python/pandas 特定的方法来处理几十年来分析师们一直面临的相同数据清理挑战。
我在每一章的开始部分都会介绍如何思考当前手头的数据清理任务,然后再讨论如何使用 Python 生态系统中的工具——pandas、NumPy、Matplotlib 等来处理。每个实例中都会通过讨论我们在数据中揭示的内容的含义来加深这一点。我尽量将工具与目标联系起来。例如,像偏度和峰度这样的概念对于处理离群值的重要性,和了解如何更新 pandas Series 的值一样。
第二版新增内容
第一版的读者会发现,本书比第一版长了很多。这部分是因为增加了两个新章节——一个专门讲解缺失值处理,另一个讲解数据预处理在预测分析中的应用。第一版对缺失值的覆盖不足,以及没有涉及机器学习应用中数据预处理的内容,是重要的遗漏。数据预处理的内容通过最后一章关于数据管道的新实例得到了进一步改进,帮助读者从原始数据到模型评估。
本书中的所有章节的食谱都进行了修订。这是为了确保它们能够与最新版本的 pandas 兼容。在本书编写期间,pandas 从 1.5.3 版本更新到了 2.2.1 版本。我已尽力确保所有代码在 2023 年 1 月到 2024 年 2 月期间发布的所有 pandas 版本上都能正常工作。由于 AI 工具在我们的工作中越来越普遍,我在四章中加入了 OpenAI 工具的讨论。总的来说,82 个食谱中有 22 个是新的。所有使用的数据集也进行了更新。
本书适合的读者
在写这本书时,我考虑了多个读者群体,但我最常想到的是我的一位亲密朋友。30 年前,她购买了一本 Transact-SQL 书籍,很快便在数据库工作中建立了极大的信心,并最终围绕这些技能建立了职业生涯。如果一位刚开始做数据科学家或分析师的新人,通过本书也能有我朋友那样的经历,我会非常高兴。最重要的是,我希望你在阅读完本书后能对自己所能做的事情感到高兴和兴奋。
我也希望本书能为那些从事此类工作一段时间的朋友提供有价值的参考。在这里,我设想有人翻开书,心里想:“有哪些处理缺失数据的方法能够保持我变量的方差?”
与本书实践性的特点保持一致,书中的每一段输出都可以通过代码重现。我在写作过程中也始终遵循一个规则,即使遇到挑战也没有改变:每个食谱都从基本未改变的原始数据开始。你将从数据文件开始,一步步得到更好处理的数据。如果你忘记了某个对象是如何创建的,只需翻回几页就能找到。
有一些 pandas 和 NumPy 基础的读者会更容易理解某些代码块,了解 Python 和基础统计的读者也会有一些帮助。不过这些都不是必须的。只是有些食谱你可能需要停下来多想一会儿。
本书内容
第一章,在使用 pandas 导入表格数据时预见数据清理问题,探讨了将 CSV 文件、Excel 文件、关系数据库表、SAS、SPSS、Stata 和 R 文件加载到 pandas DataFrame 中的工具。
第二章,处理 HTML、JSON 和 Spark 数据时预见数据清理问题,讨论了读取和标准化 JSON 数据、网页抓取以及使用 Spark 处理大数据的技术。它还探讨了数据持久化技术,包括版本控制。
第三章,衡量你的数据,介绍了在 DataFrame 中导航、选择列和行以及生成摘要统计信息的常见技术。还介绍了使用 OpenAI 工具检查数据集结构和生成统计信息。
第四章,在数据子集中过滤异常值,探索了识别整个 DataFrame 及选定组中的异常值的广泛策略。
第五章,使用可视化识别意外值,展示了如何使用 Matplotlib 和 Seaborn 工具可视化关键变量的分布,包括直方图、箱线图、散点图、折线图和小提琴图。
第六章,使用 Series 操作清理和探索数据,讨论了如何使用标量、算术运算和基于一个或多个 Series 的条件语句更新 pandas Series。
第七章,识别和修复缺失值,介绍了识别行、列和数据子集中的缺失值的策略。探讨了填充缺失值的策略,例如设置为总体均值或某一类别的均值以及前向填充。还研究了多变量填充缺失值的技术,并讨论了它们的适用情况。
第八章,编码、转换和缩放特征,涵盖了各种变量转换技术,用于为预测分析准备特征和目标。这包括最常见的编码类型——独热编码、序数编码和哈希编码;改善变量分布的转换;以及用于解决偏态、峰态和异常值问题的分箱和缩放方法,还可以调整特征范围差异较大的情况。
第九章,聚合数据时修复杂乱数据,展示了通过分组聚合数据的多种方法,包括使用itertuples或 NumPy 数组遍历数据、删除重复行,以及使用 pandas 的 groupby 和透视表。还讨论了何时选择一种方法而非其他方法。
第十章,合并 DataFrame 时处理数据问题,探讨了连接和合并数据的不同策略,以及如何预测在合并数据时可能遇到的常见数据挑战。
第十一章,整理和重塑数据,介绍了几种去重、堆叠、熔化和透视数据的策略。
第十二章,使用用户定义函数、类和管道自动化数据清理,探讨了如何将前 11 章中的许多技术转化为可复用的代码。
下载示例代码文件
本书的代码包托管在 GitHub 上,地址为:github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。我们还在github.com/PacktPublishing/提供了来自我们丰富书籍和视频目录的其他代码包。快来看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。你可以在这里下载:packt.link/gbp/9781803239873。
使用的约定
本书中使用了许多文本约定。
Code in text:指文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”
代码块如下所示:
import pandas as pd
import os
import sys
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True)
代码的任何输出将显示如下:
satverbal satmath
min 14 7
per15 390 390
qr1 430 430
med 500 500
qr3 570 580
per85 620 621
max 800 800
count 1,406 1,407
mean 500 501
iqr 140 150
粗体:表示新术语、重要词汇或屏幕上显示的文字。例如,菜单或对话框中的词汇将以这种方式出现在文本中。以下是一个示例:“从系统信息中选择管理面板。”
警告或重要说明如下所示。
提示和技巧如下所示。
与我们联系
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。
勘误:尽管我们已经尽力确保内容的准确性,但错误难免。如果您在本书中发现了错误,欢迎您向我们报告。请访问www.packtpub.com/submit-errata,点击提交勘误并填写表单。
盗版:如果你在互联网上发现任何我们作品的非法复制版本,请提供其位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上该材料的链接。
如果您有兴趣成为作者:如果您在某个主题领域有专业知识,并且有兴趣撰写或参与写作书籍,请访问authors.packtpub.com。
分享您的想法
阅读完Python 数据清理食谱(第二版)后,我们很乐意听听您的想法!请点击这里直接访问亚马逊评论页面并分享您的反馈。
您的评价对我们和技术社区非常重要,并将帮助我们确保提供卓越的优质内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在旅途中阅读,但又无法随身携带纸质书籍吗?
你的电子书购买是否与您选择的设备不兼容?
别担心,现在购买每本 Packt 书籍时,您都能免费获得该书的无 DRM 版 PDF。
在任何地方、任何设备上阅读。直接从您最喜爱的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
特权不仅限于此,您还可以获得独家折扣、新闻通讯和每日发送到您邮箱的精彩免费内容。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接:

https://packt.link/free-ebook/9781803239873
-
提交您的购买凭证。
-
就是这样!我们会将您的免费 PDF 和其他福利直接发送到您的邮箱。
第一章:预见导入表格数据时的清洗问题,使用 pandas
科学版的Python(如 Anaconda、WinPython、Canopy 等)为分析师提供了广泛的数据处理、探索和可视化工具。其中一个重要的工具是 pandas。pandas 由 Wes McKinney 于 2008 年开发,但真正获得广泛关注是在 2012 年之后,它如今已成为 Python 数据分析中的必备库。本书中的实例展示了许多常见的数据准备任务如何通过 pandas 比其他工具更加轻松地完成。虽然我们在本书中广泛使用 pandas,但也使用了其他流行的软件包,如 Numpy、matplotlib 和 scipy。
一个关键的 pandas 对象是DataFrame,它将数据表示为一个表格结构,具有行和列。这样,它与我们在本章讨论的其他数据存储方式类似。然而,pandas 的 DataFrame 还具有索引功能,使得选择、合并和转换数据相对简单,正如本书中的示例所展示的那样。
在我们可以利用这个强大的功能之前,我们需要将数据导入到 pandas 中。数据以多种格式呈现给我们:作为 CSV 或 Excel 文件,从 SQL 数据库的表格中,来自统计分析软件包如 SPSS、Stata、SAS 或 R,来自非表格来源如 JSON,以及网页数据。
在本示例中,我们将介绍导入表格数据的工具。具体而言,我们将涵盖以下主题:
-
导入 CSV 文件
-
导入 Excel 文件
-
从 SQL 数据库导入数据
-
导入 SPSS、Stata 和 SAS 数据
-
导入 R 数据
-
持久化表格数据
技术要求
本章的代码和笔记本可以在 GitHub 上找到:github.com/michaelbwalker/Python-Data-Cleaning-Cookbook-Second-Edition。你可以使用任何你选择的IDE(集成开发环境)——如 IDLE、Visual Studio、Sublime、Spyder 等——或 Jupyter Notebook 来操作本章的代码,或本书中的任何一章。关于如何开始使用 Jupyter Notebook 的好指南可以在这里找到:www.dataquest.io/blog/jupyter-notebook-tutorial/。我使用了 Spyder IDE 编写本章的代码。
我在本章及后续章节的所有代码中使用了 pandas 2.2.1 和 NumPy 版本 1.24.3。我也在 pandas 1.5.3 上测试了所有代码。
导入 CSV 文件
pandas库的read_csv方法可以用来读取一个逗号分隔值(CSV)文件,并将其加载到内存中作为 pandas 的 DataFrame。在本示例中,我们导入了一个 CSV 文件,并解决了一些常见问题:创建我们能理解的列名,解析日期,以及删除含有重要缺失数据的行。
原始数据通常以 CSV 文件格式存储。这些文件在每行数据的末尾有一个回车符,用于区分每一行数据,并且数据值之间有逗号分隔。除了逗号外,也可以使用其他分隔符,比如制表符。值周围可能会有引号,尤其是当分隔符本身出现在某些值中时(比如逗号出现在值中)。
CSV 文件中的所有数据都是字符类型,无论其逻辑数据类型是什么。这就是为什么当 CSV 文件不太大的时候,很容易在文本编辑器中查看它。pandas 的read_csv方法会对每一列的数据类型进行推测,但你需要帮助它,以确保这些推测是准确的。
准备工作
为本章节创建一个文件夹,然后在该文件夹中创建一个新的 Python 脚本或Jupyter Notebook文件。创建一个数据子文件夹,然后将landtempssample.csv文件放入该子文件夹。或者,你也可以从 GitHub 仓库中获取所有文件,包括数据文件。以下是 CSV 文件开头的截图:

图 1.1:陆地温度数据
数据说明
该数据集来自全球历史气候网络(Global Historical Climatology Network)集成数据库,由美国国家海洋和大气管理局(NOAA)在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly提供公开使用。我使用的是第 4 版的数据。本食谱中的数据使用了完整数据集中的 100,000 行样本,完整数据集也可以在仓库中找到。
如何操作…
我们将把 CSV 文件导入到 pandas 中,利用一些非常有用的read_csv选项:
-
导入
pandas库,并设置环境以便更方便地查看输出:import pandas as pd pd.options.display.float_format = '{:,.2f}'.format pd.set_option('display.width', 85) pd.set_option('display.max_columns', 8) -
读取数据文件,设置新的标题名称,并解析日期列。
将参数1传递给skiprows参数,以跳过第一行,将列的列表传递给parse_dates,以便从这些列创建一个 pandas 日期时间列,并将low_memory设置为False。这样,pandas 会一次性将所有数据加载到内存中,而不是分块加载。我们这样做是为了让 pandas 自动识别每一列的数据类型。在更多内容…部分,我们会看到如何手动设置每列的数据类型:
landtemps = pd.read_csv('data/landtempssample.csv',
... names=['stationid','year','month','avgtemp','latitude',
... 'longitude','elevation','station','countryid','country'],
... skiprows=1,
... parse_dates=[['month','year']],
... low_memory=False)
type(landtemps)
<class 'pandas.core.frame.DataFrame'>
备注
我们必须使用skiprows,因为我们正在将列名列表传递给read_csv。如果我们使用 CSV 文件中的列名,则不需要为names或skiprows指定值。
- 快速浏览一下数据。
查看前几行。显示所有列的数据类型,以及行和列的数量:
landtemps.head(7)
month_year stationid ... countryid country
0 2000-04-01 USS0010K01S ... US United States
1 1940-05-01 CI000085406 ... CI Chile
2 2013-12-01 USC00036376 ... US United States
3 1963-02-01 ASN00024002 ... AS Australia
4 2001-11-01 ASN00028007 ... AS Australia
5 1991-04-01 USW00024151 ... US United States
6 1993-12-01 RSM00022641 ... RS Russia
[7 rows x 9 columns]
landtemps.dtypes
month_year datetime64[ns]
stationed object
avgtemp float64
latitude float64
longitude float64
elevation float64
station object
countryid object
country object
dtype: object
landtemps.shape
(100000, 9)
-
给日期列起个更合适的名字,并查看平均月温的总结统计:
landtemps.rename(columns={'month_year':'measuredate'}, inplace=True) landtemps.dtypesmeasuredate datetime64[ns] stationid object avgtemp float64 latitude float64 longitude float64 elevation float64 station object countryid object country object dtype: objectlandtemps.avgtemp.describe()count 85,554.00 mean 10.92 std 11.52 min -70.70 25% 3.46 50% 12.22 75% 19.57 max 39.95 Name: avgtemp, dtype: float64 -
查找每列的缺失值。
使用isnull,它会对每一列的缺失值返回True,对非缺失值返回False。将其与sum链式调用来计算每列的缺失值数量。(在处理布尔值时,sum将True视为1,将False视为0。我将在后续内容...部分讨论方法链式调用):
landtemps.isnull().sum()
measuredate 0
stationed 0
avgtemp 14446
latitude 0
longitude 0
elevation 0
station 0
countryid 0
country 5
dtype: int64
- 删除缺失
avgtemp数据的行。
使用subset参数告诉dropna在avgtemp缺失时删除行。将inplace设置为True。如果将inplace保持在默认值False,则会显示 DataFrame,但我们所做的更改不会被保留。使用 DataFrame 的shape属性获取行数和列数:
landtemps.dropna(subset=['avgtemp'], inplace=True)
landtemps.shape
(85554, 9)
就是这样!将 CSV 文件导入 pandas 就是这么简单。
它是如何工作的……
本书中的几乎所有食谱都使用pandas库。为了方便后续引用,我们将其称为pd。这是惯例。我们还使用float_format以可读的方式显示浮动值,并使用set_option使终端输出足够宽,以容纳所需的变量数量。
大部分工作由第 2 步中的第一行完成。我们使用read_csv加载一个 pandas DataFrame 到内存中,并将其命名为landtemps。除了传递文件名外,我们将names参数设置为我们首选的列标题列表。我们还告诉read_csv跳过第一行,通过将skiprows设置为 1,因为 CSV 文件的第一行包含了原始列标题。如果不告诉它跳过第一行,read_csv会将文件中的标题行当作实际数据来处理。
read_csv还为我们解决了日期转换问题。我们使用parse_dates参数要求它将month和year列转换为日期值。
第 3 步进行了一些标准的数据检查。我们使用head(7)打印出前七行的所有列。我们使用 DataFrame 的dtypes属性显示所有列的数据类型。每列都具有预期的数据类型。在 pandas 中,字符数据具有对象数据类型,这是一个允许混合值的数据类型。shape返回一个元组,其第一个元素是 DataFrame 的行数(此例中为 100,000),第二个元素是列数(9)。
当我们使用read_csv解析month和year列时,它会将结果列命名为month_year。我们在第 4 步中使用rename方法为该列命名了更合适的名称。我们需要指定inplace=True,以便在内存中将旧列名替换为新列名。describe方法提供了avgtemp列的汇总统计信息。
注意,avgtemp的计数显示有 85,554 行具有有效的avgtemp值。这是在整个 DataFrame 的 100,000 行中,shape属性提供了这个信息。第 5 步中的缺失值列表(landtemps.isnull().sum())确认了这一点:100,000 – 85,554 = 14,446。
第 6 步删除所有avgtemp值为NaN的行。(NaN值,即非数字,是 pandas 表示缺失值的方式。)subset用于指定检查缺失值的列。此时,landtemps的shape属性显示共有 85,554 行,这与通过describe获取的前一次计数一致。
还有更多...
如果你读取的文件使用的是除逗号外的其他分隔符,例如制表符,可以在read_csv的sep参数中指定。当创建 pandas DataFrame 时,还会创建一个索引。在运行head时,输出最左侧的数字即为索引值。head可以指定任意数量的行,默认值是5。
与其将low_memory设置为False,为了让 pandas 更好地猜测数据类型,我们可以手动设置数据类型:
landtemps = pd.read_csv('data/landtempssample.csv',
names=['stationid','year','month','avgtemp','latitude',
'longitude','elevation','station','countryid','country'],
skiprows=1,
parse_dates=[['month','year']],
dtype={'stationid':'object', 'avgtemp':'float64',
'latitude':'float64','longitude':'float64',
'elevation':'float64','station':'object',
'countryid':'object','country':'object'},
)
landtemps.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 100000 entries, 0 to 99999
Data columns (total 9 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 month_year 100000 non-null datetime64[ns]
1 stationid 100000 non-null object
2 avgtemp 85554 non-null float64
3 latitude 100000 non-null float64
4 longitude 100000 non-null float64
5 elevation 100000 non-null float64
6 station 100000 non-null object
7 countryid 100000 non-null object
8 country 99995 non-null object
dtypes: datetime64ns, float64(4), object(4)
memory usage: 6.9+ MB
landtemps.isnull().sum()语句是方法链的一个例子。首先,isnull返回一个True和False值组成的 DataFrame,表示测试每一列值是否为null。sum函数对这个 DataFrame 进行求和,计算每列中True值的数量,True值按1计,False值按0计。如果我们使用以下两步操作,也能得到相同的结果:
checknull = landtemps.isnull()
checknull.sum()
什么时候链式调用方法,什么时候不调用,并没有硬性规定。我发现,当整体操作看起来像是一个单一步骤时,即使它实际上是两个或多个步骤,从机械角度来看,链式调用是有帮助的。链式调用还有一个附带的好处,就是不会创建我可能不需要的额外对象。
本食谱中使用的数据集仅为完整的土地温度数据库的一个样本,包含近 1700 万条记录。如果你的机器能够处理的话,可以运行更大的文件,使用以下代码:
landtemps = pd.read_csv('data/landtemps.zip',
... compression='zip', names=['stationid','year',
... 'month','avgtemp','latitude','longitude',
... 'elevation','station','countryid','country'],
... skiprows=1,
... parse_dates=[['month','year']],
... low_memory=False)
read_csv可以读取压缩的 ZIP 文件。我们通过传递 ZIP 文件的名称和压缩类型来实现这一功能。
另见
本章以及其他章节的后续食谱设置了索引,以提高行和合并操作的导航效率。
在使用全球历史气候网络原始数据之前,进行了大量的数据重塑。我们在第十一章,整理和重塑数据中展示了这一过程。
导入 Excel 文件
pandas库的read_excel方法可以用来从 Excel 文件中导入数据,并将其加载到内存中作为 pandas DataFrame。在本食谱中,我们导入一个 Excel 文件,并处理一些常见问题,如多余的页眉和页脚信息、选择特定的列、删除没有数据的行以及连接到特定的工作表。
尽管 Excel 的表格结构鼓励将数据组织成行和列,但电子表格并不是数据集,且不要求人们以这种方式存储数据。即使某些数据符合这些期望,通常在数据导入前后,行或列中还有其他信息。数据类型并不总是像创建电子表格的人所理解的那样清晰。这对于任何曾经与导入前导零作斗争的人来说,都是再熟悉不过的了。此外,Excel 并不要求某一列中的所有数据类型相同,也不要求列标题适合用于像 Python 这样的编程语言。
幸运的是,read_excel 提供了多个选项,用于处理 Excel 数据中的杂乱问题。这些选项使得跳过行、选择特定列以及从特定工作表或多个工作表中提取数据变得相对简单。
准备工作
您可以从本书的 GitHub 仓库下载 GDPpercapita22b.xlsx 文件,以及此配方的代码。代码假设 Excel 文件位于数据子文件夹中。以下是文件开头的视图(出于显示目的,某些列被隐藏):

图 1.2:数据集视图
这里是文件末尾的视图:

图 1.3:数据集视图
数据注释
该数据集来自经济合作与发展组织(OECD),可在 stats.oecd.org/ 上公开获取。
如何操作…
我们将一个 Excel 文件导入 pandas 并进行一些初步的数据清理:
-
导入
pandas库:import pandas as pd -
阅读 Excel 人均 GDP 数据。
选择包含我们所需数据的工作表,但跳过我们不需要的列和行。使用 sheet_name 参数指定工作表。将 skiprows 设置为 4,将 skipfooter 设置为 1,以跳过前四行(第一行被隐藏)和最后一行。我们为 usecols 提供值,从列 A 和列 C 到列 W 获取数据(列 B 是空白的)。使用 head 查看前几行,使用 shape 获取行数和列数:
percapitaGDP = pd.read_excel("data/GDPpercapita22b.xlsx",
... sheet_name="OECD.Stat export",
... skiprows=4,
... skipfooter=1,
... usecols="A,C:W")
percapitaGDP.head()
Year 2000 ... 2019 2020
0 Metropolitan areas ... NaN ... NaN NaN
1 AUS: Australia .. ... ... ... ... ...
2 AUS01: Greater Sydney ... ... ... 45576 45152
3 AUS02: Greater Melbourne ... ... ... 42299 40848
4 AUS03: Greater Brisbane ... ... ... 42145 40741
[5 rows x 22 columns]
percapitaGDP.shape
(731, 22)
注意
如果 Excel 文件没有使用 utf-8 编码,您可能会遇到 read_excel 的问题。一种解决方法是将 Excel 文件另存为 CSV 文件,重新打开并保存为 utf-8 编码格式。
-
使用 DataFrame 的
info方法查看数据类型和non-null数量。注意所有列的数据类型都是object:percapitaGDP.info()<class 'pandas.core.frame.DataFrame'> RangeIndex: 731 entries, 0 to 730 Data columns (total 22 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Year 731 non-null object 1 2000 730 non-null object 2 2001 730 non-null object 3 2002 730 non-null object 4 2003 730 non-null object 5 2004 730 non-null object 6 2005 730 non-null object 7 2006 730 non-null object 8 2007 730 non-null object 9 2008 730 non-null object 10 2009 730 non-null object 11 2010 730 non-null object 12 2011 730 non-null object 13 2012 730 non-null object 14 2013 730 non-null object 15 2014 730 non-null object 16 2015 730 non-null object 17 2016 730 non-null object 18 2017 730 non-null object 19 2018 730 non-null object 20 2019 730 non-null object 21 2020 730 non-null object dtypes: object(22) memory usage: 125.8+ KB -
将
Year列重命名为metro,并去除前导空格。
给大都市区域列起一个合适的名称。在某些情况下,大都市值前面有额外的空格。我们可以使用 startswith(' ') 测试是否有前导空格,然后使用 any 来检查是否有一个或多个前导空格的情况。我们可以使用 endswith(' ') 来检查尾随空格。我们使用 strip 去除前后空格。再次测试尾随空格时,我们看到没有:
percapitaGDP.rename(columns={'Year':'metro'}, inplace=True)
percapitaGDP.metro.str.startswith(' ').any()
True
percapitaGDP.metro.str.endswith(' ').any()
False
percapitaGDP.metro = percapitaGDP.metro.str.strip()
percapitaGDP.metro.str.startswith(' ').any()
False
- 将数据列转换为数值型。
遍历所有 GDP 年度列(2000–2020),并将数据类型从 object 转换为 float。即使存在字符数据(例如这个例子中的 ..),也要强制转换。我们希望这些列中的字符值变为 missing,这正是发生的情况。将年份列重命名为更能反映列中数据的名称:
for col in percapitaGDP.columns[1:]:
... percapitaGDP[col] = pd.to_numeric(percapitaGDP[col],
... errors='coerce')
... percapitaGDP.rename(columns={col:'pcGDP'+col},
... inplace=True)
...
percapitaGDP.head()
metro pcGDP2000 pcGDP2001 ... \
0 Metropolitan areas NaN NaN ...
1 AUS: Australia NaN NaN ...
2 AUS01: Greater Sydney NaN 41091 ...
3 AUS02: Greater Melbourne NaN 40488 ...
4 AUS03: Greater Brisbane NaN 35276 ...
pcGDP2018 pcGDP2019 pcGDP2020
0 NaN NaN NaN
1 NaN NaN NaN
2 47171 45576 45152
3 43237 42299 40848
4 44328 42145 40741
[5 rows x 22 columns]
percapitaGDP.dtypes
metro object
pcGDP2000 float64
pcGDP2001 float64
abbreviated to save space
pcGDP2019 float64
pcGDP2020 float64
dtype: object
-
使用
describe方法生成 DataFrame 中所有数值数据的摘要统计信息:percapitaGDP.describe()pcGDP2000 pcGDP2001 pcGDP2002 ... pcGDP2018 \ count 158 450 479 ... 692 mean 33961 38874 39621 ... 41667 std 15155 13194 13061 ... 17440 min 2686 7805 7065 ... 5530 25% 21523 30790 31064 ... 31322 50% 35836 38078 39246 ... 41428 75% 42804 46576 47874 ... 51130 max 95221 96941 98929 ... 147760 pcGDP2019 pcGDP2020 count 596 425 mean 42709 39792 std 18893 19230 min 5698 5508 25% 29760 24142 50% 43505 41047 75% 53647 51130 max 146094 131082 [8 rows x 21 columns] -
删除所有人均 GDP 值缺失的行。
使用 dropna 的 subset 参数检查所有列,从第二列(基于零索引)开始,到最后一列。使用 how 参数指定只有当 subset 中所有列的值都缺失时才删除行。使用 shape 来显示结果 DataFrame 中的行数和列数:
percapitaGDP.dropna(subset=percapitaGDP.columns[1:], how="all", inplace=True)
percapitaGDP.shape
(692, 22)
- 使用大都市区域列设置 DataFrame 的索引。
确认 metro 有 692 个有效值,并且这 692 个值是唯一的,然后再设置索引:
percapitaGDP.metro.count()
692
percapitaGDP.metro.nunique()
692
percapitaGDP.set_index('metro', inplace=True)
percapitaGDP.head()
pcGDP2000 pcGDP2001 ... \
metro ...
AUS01: Greater Sydney NaN 41091 ...
AUS02: Greater Melbourne NaN 40488 ...
AUS03: Greater Brisbane NaN 35276 ...
AUS04: Greater Perth NaN 43355 ...
AUS05: Greater Adelaide NaN 36081 ...
pcGDP2019 pcGDP2020
metro
AUS01: Greater Sydney 45576 45152
AUS02: Greater Melbourne 42299 40848
AUS03: Greater Brisbane 42145 40741
AUS04: Greater Perth 70970 78489
AUS05: Greater Adelaide 38314 39181
[5 rows x 21 columns]
percapitaGDP.loc['AUS02: Greater Melbourne']
pcGDP2000 NaN
pcGDP2001 40488
...
pcGDP2019 42299
pcGDP2020 40848
Name: AUS02: Greater Melbourne, dtype: float64
我们现在已经将 Excel 数据导入到 pandas DataFrame 中,并清理了一些电子表格中的杂乱数据。
它是如何工作的…
我们在 步骤 2 中通过跳过不需要的行和列,基本上获得了我们想要的数据,但仍然存在一些问题——read_excel 将所有的 GDP 数据解释为字符数据,许多行加载了无用的数据,而且列名未能很好地表示数据。此外,大都市区域列可能作为索引很有用,但存在前后空格,并且可能存在缺失或重复的值。
read_excel 将 Year 解释为大都市区域数据的列名,因为它查找该 Excel 列数据上方的标题,并发现了 Year。我们在 步骤 4 中将该列重命名为 metro。我们还使用 strip 修复了前后空格问题。我们本来也可以只使用 lstrip 去除前导空格,或者如果有尾随空格,使用 rstrip。假设任何字符数据可能存在前导或尾随空格,并在初次导入后立即清理数据是一个好习惯。
电子表格作者使用 .. 表示缺失数据。由于这实际上是有效的字符数据,这些列获得了对象数据类型(这是 pandas 处理具有字符或混合数据的列的方式)。我们在第 5 步强制转换为数值类型。这也导致将 .. 的原始值替换为 NaN(不是一个数字),这是 pandas 表示数字缺失值的方式。这正是我们想要的。
使用 pandas 能够仅通过几行代码修复所有人均 GDP 列,因为 pandas 提供了对 DataFrame 列进行迭代的简便方法。通过指定 [1:],我们从第二列迭代到最后一列。然后,我们可以将这些列更改为数值类型,并重命名为更合适的名称。
有几个原因值得考虑清理年度 GDP 列标题 – 这帮助我们记住数据的实际内容; 如果我们按都会区域与其他数据合并,我们将不必担心冲突的变量名称; 我们可以使用属性访问来处理基于这些列的 pandas Series,我将在本配方的还有更多…部分详细讨论。
第 6 步中的 describe 显示我们有少于 500 行有效数据的人均 GDP 的一些年份。当我们在第 7 步中删除所有人均 GDP 列中缺失值的所有行时,我们最终得到 DataFrame 中的 692 行。
还有更多…
一旦我们有了 pandas DataFrame,我们可以将列视为不仅仅是列。我们可以使用属性访问(例如 percapitaGPA.metro)或括号表示法(percapitaGPA['metro'])来获取 pandas Series 的功能。任何一种方法都可以使用 Series 的字符串检查方法,如 str.startswith,以及计数方法,如 nunique。请注意,20## 的原始列名不允许属性访问,因为它们以数字开头,所以 percapitaGDP.pcGDP2001.count() 可以工作,但 percapitaGDP.2001.count() 返回语法错误,因为 2001 不是有效的 Python 标识符(因为它以数字开头)。
pandas 提供了丰富的字符串操作和 Series 操作功能。我们将在后续的示例中尝试其中许多功能。本示例展示了在导入 Excel 数据时我认为最有用的功能。
另请参阅
有很多理由考虑重塑这些数据。与每个都会区域的 21 列人均 GDP 数据相比,我们应该为每个都会区域有 21 行数据,年份和人均 GDP 数据的列。有关重塑数据的配方可以在第十一章,整理和重塑数据中找到。
从 SQL 数据库导入数据
在本教程中,我们将使用pymssql和mysql apis从Microsoft SQL Server和MySQL(现在由Oracle拥有)数据库中读取数据。像这些来源的数据通常结构良好,因为它们旨在促进组织成员及与之互动的人员的同时交易。每一笔交易也可能与其他组织交易相关联。
这意味着,尽管来自企业系统的数据表比 CSV 文件和 Excel 文件中的数据结构更加可靠,但它们的逻辑较少是自包含的。你需要了解一个表中的数据与另一个表中的数据如何关联,才能理解其完整含义。在提取数据时,必须保留这些关系,包括主键和外键的完整性。此外,结构良好的数据表不一定是简单的数据表。往往存在复杂的编码方案来确定数据值,这些编码方案可能会随着时间的推移而发生变化。例如,零售商店连锁的商品代码可能在 1998 年与 2024 年有所不同。同样,经常会有缺失值的代码,如 99,999,pandas 会将其识别为有效值。
由于其中许多逻辑是业务逻辑,并且在存储过程或其他应用程序中实现,因此当数据从这一更大的系统中提取出来时,这些逻辑会丢失。在为分析准备数据时,丢失的部分最终需要被重建。这几乎总是涉及到将多个表中的数据合并,因此保留这种能力非常重要。然而,它也可能涉及在将 SQL 表加载到 pandas DataFrame 后,将一些编码逻辑重新加入。我们将在本教程中探讨如何做到这一点。
准备工作
本教程假设你已经安装了pymssql和mysql apis。如果没有,可以通过pip轻松安装。在终端或powershell(在 Windows 中)中输入pip install pymssql或pip install mysql-connector-python。我们将在本教程中使用有关教育水平的数据。
数据说明
本教程使用的数据集可以通过archive.ics.uci.edu/ml/machine-learning-databases/00320/student.zip公开下载。
如何操作...
我们将 SQL Server 和 MySQL 的数据表导入到 pandas DataFrame 中,如下所示:
- 导入
pandas、numpy、pymssql和mysql。
此步骤假设你已经安装了pymssql和mysql apis:
import pandas as pd
import numpy as np
import pymssql
import mysql.connector
- 使用
pymssql api和read_sql从 SQL Server 实例中检索并加载数据。
从 SQL Server 数据中选择我们需要的列,使用 SQL 别名改善列名(例如,fedu AS fathereducation)。通过将数据库凭证传递给 pymssql 的 connect 函数,建立与 SQL Server 数据的连接。通过将 SELECT 语句和连接对象传递给 read_sql 创建 pandas DataFrame。使用 close 将连接返回到服务器上的连接池:
sqlselect = "SELECT studentid, school, sex, age, famsize,\
... medu AS mothereducation, fedu AS fathereducation,\
... traveltime, studytime, failures, famrel, freetime,\
... goout, g1 AS gradeperiod1, g2 AS gradeperiod2,\
... g3 AS gradeperiod3 From studentmath"
server = "pdcc.c9sqqzd5fulv.us-west-2.rds.amazonaws.com"
user = "pdccuser"
password = "pdccpass"
database = "pdcctest"
conn = pymssql.connect(server=server,
... user=user, password=password, database=database)
studentmath = pd.read_sql(sqlselect,conn)
conn.close()
注意
尽管像 pymssql 这样的工具使得连接到 SQL Server 实例相对简单,但如果不熟悉,语法可能还是需要一些时间适应。前一步展示了你通常需要传递给连接对象的参数值——服务器名称、具有凭证的用户名称、该用户的密码,以及服务器上 SQL 数据库的名称。
-
检查数据类型和前几行:
studentmath.dtypesstudentid object school object sex object age int64 famsize object mothereducation int64 fathereducation int64 traveltime int64 studytime int64 failures int64 famrel int64 freetime int64 gout int64 gradeperiod1 int64 gradeperiod2 int64 gradeperiod3 int64 dtype: objectstudentmath.head()studentid school ... gradeperiod2 gradeperiod3 0 001 GP ... 6 6 1 002 GP ... 5 6 2 003 GP ... 8 10 3 004 GP ... 14 15 4 005 GP ... 10 10 [5 rows x 16 columns] -
连接到 MySQL 服务器与连接到 SQL Server 实例没有太大不同。我们可以使用
mysql连接器的connect方法来完成此操作,然后使用read_sql加载数据。
创建与 mysql 数据的连接,将该连接传递给 read_sql 以检索数据,并将其加载到 pandas DataFrame 中(相同的学生数学成绩数据文件已经上传到 SQL Server 和 MySQL,因此我们可以使用与上一步骤相同的 SQL SELECT 语句):
host = "pdccmysql.c9sqqzd5fulv.us-west-2.rds.amazonaws.com"
user = "pdccuser"
password = "pdccpass"
database = "pdccschema"
connmysql = mysql.connector.connect(host=host, \
... database=database,user=user,password=password)
studentmath = pd.read_sql(sqlselect,connmysql)
connmysql.close()
- 重新排列列,设置索引,并检查缺失值。
将成绩数据移到 DataFrame 的左侧,紧跟在 studentid 后面。同时,将 freetime 列移到 traveltime 和 studytime 后面。确认每行都有一个 ID 且 ID 是唯一的,并将 studentid 设置为索引:
newcolorder = ['studentid', 'gradeperiod1',
... 'gradeperiod2','gradeperiod3', 'school',
... 'sex', 'age', 'famsize','mothereducation',
... 'fathereducation', 'traveltime',
... 'studytime', 'freetime', 'failures',
... 'famrel','goout']
studentmath = studentmath[newcolorder]
studentmath.studentid.count()
395
studentmath.studentid.nunique()
395
studentmath.set_index('studentid', inplace=True)
-
使用 DataFrame 的
count函数检查缺失值:studentmath.count()gradeperiod1 395 gradeperiod2 395 gradeperiod3 395 school 395 sex 395 age 395 famsize 395 mothereducation 395 fathereducation 395 traveltime 395 studytime 395 freetime 395 failures 395 famrel 395 goout 395 dtype: int64 -
用更具信息量的值替换编码数据值。
创建一个字典,包含要替换的列的值,然后使用 replace 设置这些值:
setvalues= \
... {"famrel":{1:"1:very bad",2:"2:bad",
... 3:"3:neutral",4:"4:good",5:"5:excellent"},
... "freetime":{1:"1:very low",2:"2:low",
... 3:"3:neutral",4:"4:high",5:"5:very high"},
... "goout":{1:"1:very low",2:"2:low",3:"3:neutral",
... 4:"4:high",5:"5:very high"},
... "mothereducation":{0:np.nan,1:"1:k-4",2:"2:5-9",
... 3:"3:secondary ed",4:"4:higher ed"},
... "fathereducation":{0:np.nan,1:"1:k-4",2:"2:5-9",
... 3:"3:secondary ed",4:"4:higher ed"}}
studentmath.replace(setvalues, inplace=True)
- 将已更改数据的列类型改为
category。
检查内存使用情况的任何变化:
setvalueskeys = [k for k in setvalues]
studentmath[setvalueskeys].memory_usage(index=False)
famrel 3160
freetime 3160
goout 3160
mothereducation 3160
fathereducation 3160
dtype: int64
for col in studentmath[setvalueskeys].columns:
... studentmath[col] = studentmath[col]. \
... astype('category')
...
studentmath[setvalueskeys].memory_usage(index=False)
famrel 607
freetime 607
goout 607
mothereducation 599
fathereducation 599
dtype: int64
- 计算
famrel列中的值的百分比。
运行 value_counts,并将 normalize 设置为 True 以生成百分比:
studentmath['famrel'].value_counts(sort=False, normalize=True)
1:very bad 0.02
2:bad 0.05
3:neutral 0.17
4:good 0.49
5:excellent 0.27
Name: famrel, dtype: float64
-
使用
apply计算多个列的百分比:studentmath[['freetime','goout']].\ ... apply(pd.Series.value_counts, sort=False, ... normalize=True)freetime goout 1:very low 0.05 0.06 2:low 0.16 0.26 3:neutral 0.40 0.33 4:high 0.29 0.22 5:very high 0.10 0.13studentmath[['mothereducation','fathereducation']].\ ... apply(pd.Series.value_counts, sort=False, ... normalize=True)mothereducation fathereducation 1:k-4 0.15 0.21 2:5-9 0.26 0.29 3:secondary ed 0.25 0.25 4:higher ed 0.33 0.24
上述步骤从 SQL 数据库中检索了一个数据表,将数据加载到 pandas 中,并进行了初步的数据检查和清理。
它是如何工作的…
由于企业系统中的数据通常比 CSV 或 Excel 文件更有结构性,我们无需执行像跳过行或处理列中的不同逻辑数据类型等操作。然而,在开始探索性分析之前,通常仍然需要做一些数据处理。通常列的数量超过我们需要的,而且一些列名并不直观,或者没有以最适合分析的顺序排列。为了避免输入错误并节省存储空间,许多数据值的意义并未存储在数据表中。例如,3表示母亲的教育程度,而不是“中等教育”。最好在清理过程中尽早重构这种编码。
要从 SQL 数据库服务器中提取数据,我们需要一个连接对象来进行身份验证,以及一个 SQL 选择字符串。这些可以传递给read_sql以检索数据并加载到 pandas DataFrame 中。我通常在此时使用 SQL 的SELECT语句对列名进行一些清理。有时我也会重新排列列,但在这个步骤我是在稍后的操作中做的。
我们在步骤 5中设置了索引,首先确认每行都有studentid的值,并且它是唯一的。当处理企业数据时,这一点通常更为重要,因为我们几乎总是需要将获取的数据与系统中的其他数据文件合并。虽然合并时不要求必须有索引,但设置索引的习惯让我们为后续更复杂的合并操作做好了准备。这也可能提高合并的速度。
我们使用 DataFrame 的count函数检查缺失值,并确认没有缺失值——对于非缺失值,每一列的计数都是 395(行数)。这几乎好得令人难以置信。可能存在逻辑上缺失的值——也就是说,有效的数字却表示缺失值,比如-1、0、9或99。我们将在下一步处理中解决这个问题。
步骤 7展示了一个用于替换多个列数据值的有用技巧。我们创建一个字典,将每列的原始值映射到新值,然后使用replace运行它。为了减少新长格式值占用的存储空间,我们将这些列的数据类型转换为category。我们通过生成setvalues字典的键列表来实现这一点——setvalueskeys = [k for k in setvalues]生成[famrel、freetime、goout、mothereducation和fathereducation]。然后,我们遍历这五列,并使用astype方法将数据类型更改为category。注意,这些列的内存使用量显著减少。
最后,我们通过使用value_counts查看相对频率来检查新值的分配情况。我们使用apply,因为我们想对多个列运行value_counts。为了防止value_counts按频率排序,我们将sort设置为False。
replace方法也是处理逻辑缺失值的便利工具,当通过read_sql检索时,这些值将不被识别为缺失。对于mothereducation和fathereducation的0值似乎属于这种情况。我们在setvalues字典中通过指示mothereducation和fathereducation的0值应替换为NaN来解决这个问题。在初始导入后不久解决这类缺失值问题很重要,因为它们并不总是显而易见,而且可能会显著影响后续所有工作。
像SPPS、SAS和R等软件包的用户会注意到这种方法与 SPSS 和 R 中的值标签以及 SAS 中的proc格式的差异。在 pandas 中,我们需要更改实际数据以获得更详细的值。然而,通过为列指定category数据类型,我们减少了实际存储的数据量。这类似于 R 中的factors。
还有更多内容……
我将成绩数据移到了 DataFrame 的开头附近。我发现将潜在的目标或依赖变量放在最左边的列中是有帮助的,这样可以将它们保持在脑海中的最前沿。将类似的列放在一起也是有帮助的。在这个例子中,个人人口统计变量(性别和年龄)相邻,家庭变量(mothereducation和fathereducation)相邻,以及学生如何花时间(traveltime、studytime和freetime)。
在Step 7中,你本可以使用map而不是replace。在 pandas 的 19.2 版本之前,map 的效率显著更高。此后,效率差异已经小得多。如果你正在处理非常大的数据集,这种差异仍然足以考虑使用map。
另请参阅
在Chapter 10的配方中,解决数据合并时的数据问题会详细介绍数据合并的细节。我们将在Chapter 4中更仔细地研究变量之间的双变量和多变量关系,以及如何在本章后续的 SPSS、SAS 和 R 中使用一些相同的方法。
导入 SPSS、Stata 和 SAS 数据
我们将使用pyreadstat从三个流行的统计软件包中读取数据到 pandas 中。pyreadstat的主要优势在于允许数据分析师导入这些软件包的数据而不丢失元数据,例如变量和值标签。
我们收到的 SPSS、Stata 和 SAS 数据文件通常已经解决了 CSV 和 Excel 文件以及 SQL 数据库中的数据问题。通常我们不会遇到 CSV 或 Excel 文件中可能出现的无效列名、数据类型变化和不明确的缺失值,也不太会遇到 SQL 数据中常见的数据与业务逻辑脱节的问题(比如数据代码的含义)。当某人或某个组织与我们共享来自这些软件包的数据文件时,他们通常会为分类数据添加变量标签和数值标签。例如,一个假设的数据列presentsat可能有总体满意度的变量标签和1到5的数值标签,其中1表示完全不满意,5表示非常满意。
挑战在于从这些系统导入数据到 pandas 时如何保留元数据。pandas 没有与变量和数值标签完全对应的功能,并且用于导入 SAS、Stata 和 SAS 数据的内建工具会丢失元数据。在这个教程中,我们将使用pyreadstat加载变量和数值标签信息,并使用一些技巧将这些信息表示在 pandas 中。
准备工作
本教程假设你已经安装了pyreadstat包。如果没有安装,你可以通过pip来安装它。在终端或 PowerShell(Windows)中输入pip install pyreadstat。运行此代码需要 SPSS、Stata 和 SAS 数据文件。
我们将使用来自美国国家纵向调查(NLS)青少年数据的数据。
数据说明
青少年 NLS 调查由美国劳工统计局进行。该调查始于 1997 年,最初的受访者是在高中年龄时完成调查的,出生年份在 1980 至 1985 年之间。每年都有后续调查,一直到 2023 年。对于本教程,我从调查中数百个数据项中提取了 42 个变量,涵盖了成绩、就业、收入和对政府的态度。可以从数据仓库下载 SPSS、Stata 和 SAS 的独立文件。
原始的 NLS 数据可以从www.nlsinfo.org/investigator/pages/search下载,同时也有用于从下载中的 ASCII 数据文件创建 SPSS、Stata 或 SAS 文件的代码。
如何操作...
我们将从 SPSS、Stata 和 SAS 中导入数据,保留元数据,如数值标签:
- 导入
pandas、numpy和pyreadstat。
这一步假设你已经安装了pyreadstat:
import pandas as pd
import numpy as np
import pyreadstat
- 获取 SPSS 数据。
将路径和文件名传递给pyreadstat的read_sav方法。显示前几行和频率分布。注意,列名和数值标签是非描述性的,并且read_sav返回一个 pandas DataFrame 和一个meta对象:
nls97spss, metaspss = pyreadstat.read_sav('data/nls97.sav')
nls97spss.dtypes
R0000100 float64
R0536300 float64
R0536401 float64
...
U2962900 float64
U2963000 float64
Z9063900 float64
dtype: object
nls97spss.head()
R0000100 R0536300 ... U2963000 Z9063900
0 1 2 ... nan 52
1 2 1 ... 6 0
2 3 2 ... 6 0
3 4 2 ... 6 4
4 5 1 ... 5 12
[5 rows x 42 columns]
nls97spss['R0536300'].value_counts(normalize=True)
1.00 0.51
2.00 0.49
Name: R0536300, dtype: float64
- 获取元数据以改进列标签和数值标签。
在我们调用read_sav时创建的metaspss对象包含了 SPSS 文件中的列标签和值标签。使用variable_value_labels字典将值映射到某一列(R0536300)的值标签。(这不会改变数据,仅在运行value_counts时改进显示。)使用set_value_labels方法将值标签实际应用于 DataFrame:
metaspss.variable_value_labels['R0536300']
{0.0: 'No Information', 1.0: 'Male', 2.0: 'Female'}
nls97spss['R0536300'].\
... map(metaspss.variable_value_labels['R0536300']).\
... value_counts(normalize=True)
Male 0.51
Female 0.49
Name: R0536300, dtype: float64
nls97spss = pyreadstat.set_value_labels(nls97spss, metaspss, formats_as_category=True)
- 使用元数据中的列标签来重命名列。
要在我们的 DataFrame 中使用metaspss中的列标签,我们只需将metaspss中的列标签分配给 DataFrame 的列名。稍微清理一下列名,将其转换为小写字母,将空格替换为下划线,并删除所有剩余的非字母数字字符:
nls97spss.columns = metaspss.column_labels
nls97spss['KEY!SEX (SYMBOL) 1997'].value_counts(normalize=True)
Male 0.51
Female 0.49
Name: KEY!SEX (SYMBOL) 1997, dtype: float64
nls97spss.dtypes
PUBID - YTH ID CODE 1997 float64
KEY!SEX (SYMBOL) 1997 category
KEY!BDATE M/Y (SYMBOL) 1997 float64
KEY!BDATE M/Y (SYMBOL) 1997 float64
CV_SAMPLE_TYPE 1997 category
KEY!RACE_ETHNICITY (SYMBOL) 1997 category
"... abbreviated to save space"
HRS/WK R WATCHES TELEVISION 2017 category
HRS/NIGHT R SLEEPS 2017 float64
CVC_WKSWK_YR_ALL L99 float64
dtype: object
nls97spss.columns = nls97spss.columns.\
... str.lower().\
... str.replace(' ','_').\
... str.replace('[^a-z0-9_]', '', regex=True)
nls97spss.set_index('pubid__yth_id_code_1997', inplace=True)
- 通过从一开始就应用值标签来简化这个过程。
实际上,在初始调用read_sav时,就可以通过设置apply_value_formats为True来应用数据值。这消除了稍后调用set_value_labels函数的需求:
nls97spss, metaspss = pyreadstat.read_sav('data/nls97.sav', apply_value_formats=True, formats_as_category=True)
nls97spss.columns = metaspss.column_labels
nls97spss.columns = nls97spss.columns.\
... str.lower().\
... str.replace(' ','_').\
... str.replace('[^a-z0-9_]', '', regex=True)
-
显示列和几行数据:
nls97spss.dtypespubid__yth_id_code_1997 float64 keysex_symbol_1997 category keybdate_my_symbol_1997 float64 keybdate_my_symbol_1997 float64 hrsnight_r_sleeps_2017 float64 cvc_wkswk_yr_all_l99 float64 dtype: objectnls97spss.head()pubid__yth_id_code_1997 keysex_symbol_1997 ... \ 0 1 Female ... 1 2 Male ... 2 3 Female ... 3 4 Female ... 4 5 Male ... hrsnight_r_sleeps_2017 cvc_wkswk_yr_all_l99 0 nan 52 1 6 0 2 6 0 3 6 4 4 5 12 [5 rows x 42 columns] -
对某一列运行频率,并设置索引:
nls97spss.govt_responsibility__provide_jobs_2006.\ ... value_counts(sort=False)Definitely should be 454 Definitely should not be 300 Probably should be 617 Probably should not be 462 Name: govt_responsibility__provide_jobs_2006, dtype: int64nls97spss.set_index('pubid__yth_id_code_1997', inplace=True) -
这展示了如何将 SPSS 数据转换。现在我们尝试使用 Stata 数据。
-
导入 Stata 数据,应用值标签,并改进列标题。
对 Stata 数据使用与 SPSS 数据相同的方法:
nls97stata, metastata = pyreadstat.read_dta('data/nls97.dta', apply_value_formats=True, formats_as_category=True)
nls97stata.columns = metastata.column_labels
nls97stata.columns = nls97stata.columns.\
... str.lower().\
... str.replace(' ','_').\
... str.replace('[^a-z0-9_]', '', regex=True)
nls97stata.dtypes
pubid__yth_id_code_1997 float64
keysex_symbol_1997 category
keybdate_my_symbol_1997 float64
keybdate_my_symbol_1997 float64
hrsnight_r_sleeps_2017 float64
cvc_wkswk_yr_all_l99 float64
dtype: object
-
查看几行数据并运行频率:
nls97stata.head()pubid__yth_id_code_1997 keysex_symbol_1997 ... \ 0 1 Female ... 1 2 Male ... 2 3 Female ... 3 4 Female ... 4 5 Male ... hrsnight_r_sleeps_2017 cvc_wkswk_yr_all_l99 0 -5 52 1 6 0 2 6 0 3 6 4 4 5 12 [5 rows x 42 columns]nls97stata.govt_responsibility__provide_jobs_2006.\ ... value_counts(sort=False)-5.0 1425 -4.0 5665 -2.0 56 -1.0 5 Definitely should be 454 Definitely should not be 300 Probably should be 617 Probably should not be 462 Name: govt_responsibility__provide_jobs_2006, dtype: int64 -
修复 Stata 数据中出现的逻辑缺失值,并设置索引。我们可以使用
replace方法将任何列中介于–9和–1之间的值设置为缺失值:nls97stata.min(numeric_only=True)pubid__yth_id_code_1997 1 keybdate_my_symbol_1997 1 keybdate_my_symbol_1997 1,980 trans_sat_verbal_hstr -4 trans_sat_math_hstr -4 trans_crd_gpa_overall_hstr -9 trans_crd_gpa_eng_hstr -9 trans_crd_gpa_math_hstr -9 trans_crd_gpa_lp_sci_hstr -9 cv_ba_credits_l1_2011 -5 cv_bio_child_hh_2017 -5 cv_bio_child_nr_2017 -5 hrsnight_r_sleeps_2017 -5 cvc_wkswk_yr_all_l99 -4 dtype: float64nls97stata.replace(list(range(-9,0)), np.nan, inplace=True) nls97stata.min(numeric_only=True)pubid__yth_id_code_1997 1 keybdate_my_symbol_1997 1 keybdate_my_symbol_1997 1,980 trans_sat_verbal_hstr 14 trans_sat_math_hstr 7 trans_crd_gpa_overall_hstr 10 trans_crd_gpa_eng_hstr 0 trans_crd_gpa_math_hstr 0 trans_crd_gpa_lp_sci_hstr 0 cv_ba_credits_l1_2011 0 cv_bio_child_hh_2017 0 cv_bio_child_nr_2017 0 hrsnight_r_sleeps_2017 0 cvc_wkswk_yr_all_l99 0 dtype: float64nls97stata.set_index('pubid__yth_id_code_1997', inplace=True)
当处理 SAS 数据文件时,流程与接下来的几个步骤类似。
- 使用 SAS 目录文件来检索 SAS 数据的值标签:
SAS 的数据值存储在目录文件中。设置目录文件路径和文件名可以检索值标签并应用它们:
nls97sas, metasas = pyreadstat.read_sas7bdat('data/nls97.sas7bdat', catalog_file='data/nlsformats3.sas7bcat', formats_as_category=True)
nls97sas.columns = metasas.column_labels
nls97sas.columns = nls97sas.columns.\
... str.lower().\
... str.replace(' ','_').\
... str.replace('[^a-z0-9_]', '', regex=True)
nls97sas.head()
pubid__yth_id_code_1997 keysex_symbol_1997 ... \
0 1 Female ...
1 2 Male ...
2 3 Female ...
3 4 Female ...
4 5 Male ...
hrsnight_r_sleeps_2017 cvc_wkswk_yr_all_l99
0 nan 52
1 6 0
2 6 0
3 6 4
4 5 12
[5 rows x 42 columns]
nls97sas.keysex_symbol_1997.value_counts()
Male 4599
Female 4385
Name: keysex_symbol_1997, dtype: int64
nls97sas.set_index('pubid__yth_id_code_1997', inplace=True)
这展示了如何导入 SPSS、SAS 和 Stata 数据,而不丢失重要的元数据。
它是如何工作的...
Pyreadstat的read_sav、read_dta和read_sas7bdat方法,分别用于 SPSS、Stata 和 SAS 数据文件,工作方式相似。在读取数据时,可以通过设置apply_value_formats为True来应用值标签(适用于 SPSS 和 Stata 文件,见步骤 5 和 8),或者通过提供 SAS 目录文件路径和文件名来应用值标签(见步骤 12)。
我们可以将formats_as_category设置为True,以便将那些数据值会变化的列的数据类型更改为category。元对象包含了统计软件包中的列名和列标签,因此,元数据列标签可以随时分配给 pandas DataFrame 的列名(nls97spss.columns = metaspss.column_labels)。我们甚至可以在将元数据列标签分配给列之后,使用元数据列名将 pandas 列名恢复为原始列标题(nls97spss.columns = metaspss.column_names)。
在步骤 3中,我们在应用值标签之前查看了一些 SPSS 数据。我们查看了某个变量的字典(metaspss.variable_value_labels['R0536300']),但我们也可以查看所有变量的字典(metaspss.variable_value_labels)。当我们确认标签合理后,可以通过调用 set_value_labels 函数来设置它们。这是一种好的方法,尤其是在你对数据不太了解时,想在应用标签之前先检查它们。
来自元对象的列标签通常比原始列标题更为合适。列标题可能会非常晦涩,特别是当 SPSS、Stata 或 SAS 文件基于一个大型调查时,就像这个例子一样。然而,这些标签通常也不是理想的列标题。它们有时包含空格、不太有用的大写字母和非字母数字字符。我们通过一些字符串操作将其转换为小写,替换空格为下划线,并去除非字母数字字符。
处理缺失值在这些数据文件中并不总是那么简单,因为数据缺失往往有很多不同的原因。如果文件来自于一项调查,缺失值可能是因为调查跳过模式、受访者未作回应、回应无效等原因。NLS 有九种可能的缺失值,从 –1 到 –9。SPSS 导入会自动将这些值设置为 NaN,而 Stata 导入则保留了原始值。(我们本可以通过将 user_missing 设置为 True,让 SPSS 导入保留这些值。)对于 Stata 数据,我们需要告知它将所有从 –1 到 –9 的值替换为 NaN。我们通过使用 DataFrame 的 replace 函数,并传入从 –9 到 –1 的整数列表(list(range(-9,0)))来实现这一点。
还有更多…
你可能已经注意到,这个步骤和前面的步骤在设置值标签方面有相似之处。set_value_labels 函数类似于我们在上一步中用于设置值标签的 DataFrame replace 操作。我们将一个字典传递给 replace,该字典将列与值标签映射起来。在这个步骤中,set_value_labels 函数本质上做了同样的事情,它使用元对象的 variable_value_labels 属性作为字典。
来自统计软件包的数据通常没有 SQL 数据库那样结构化,尤其在一个重要的方面。因为这些软件包的设计目的是为了促进分析,它们常常违反数据库的规范化规则。通常会有一个隐含的关系结构,可能需要在某个时刻进行反扁平化。例如,数据可能会将个体和事件级别的数据结合在一起——一个人和医院就诊记录,一个棕熊和它从冬眠中苏醒的日期。通常情况下,这些数据需要在某些分析方面进行重塑。
另见
pyreadstat包有很好的文档,地址是github.com/Roche/pyreadstat。这个包有许多有用的选项,可以选择列并处理缺失数据,但由于篇幅限制,我无法在本食谱中展示。我们将在第十一章,整理和重塑数据中,讨论如何规范化可能已为分析目的而扁平化的数据。
导入 R 数据
我们将使用pyreadr将 R 数据文件读取到 pandas 中。由于pyreadr无法捕捉元数据,我们需要编写代码来重构值标签(类似于 R 中的因子)和列标题。这与我们在从 SQL 数据库导入数据食谱中做的类似。
R 统计包在许多方面类似于 Python 和 pandas 的组合,至少在其功能范围上是如此。两者都在数据准备和数据分析任务中拥有强大的工具。部分数据科学家同时使用 R 和 Python,可能在 Python 中进行数据处理,在 R 中进行统计分析,或者反过来,具体取决于他们偏好的包。然而,目前在将 R 中保存的数据(如rds或rdata文件)读取到 Python 中的工具仍然比较稀缺。分析师通常先将数据保存为 CSV 文件,然后再加载到 Python 中。我们将使用pyreadr,它与pyreadstat同一作者开发,因为它不需要安装 R。
当我们接收到 R 文件,或处理我们自己创建的 R 文件时,可以指望它的结构相对完善,至少比 CSV 或 Excel 文件要好。每一列只有一种数据类型,列标题会有适合 Python 变量的名称,所有行也会有相同的结构。然而,我们可能需要恢复一些编码逻辑,就像处理 SQL 数据时一样。
准备工作
本食谱假设你已经安装了pyreadr包。如果尚未安装,你可以使用pip安装它。在终端或 Windows 的 Powershell 中输入pip install pyreadr。
在这个食谱中,我们将再次使用 NLS 数据。你需要从 GitHub 仓库下载本食谱中使用的rds文件,以便运行代码。
如何实现…
我们将导入 R 数据,同时不会丢失重要的元数据:
-
加载
pandas,numpy,pprint和pyreadr包:import pandas as pd import numpy as np import pyreadr import pprint -
获取 R 数据。
将路径和文件名传递给read_r方法,以检索 R 数据,并将其加载为 pandas DataFrame。read_r可以返回一个或多个对象。当读取rds文件(与rdata文件相对)时,它会返回一个对象,键为None。我们通过指定None来获取 pandas DataFrame:
nls97r = pyreadr.read_r('data/nls97.rds')[None]
nls97r.dtypes
R0000100 int32
R0536300 int32
...
U2962800 int32
U2962900 int32
U2963000 int32
Z9063900 int32
dtype: object
nls97r.head(10)
R0000100 R0536300 ... U2963000 Z9063900
0 1 2 ... -5 52
1 2 1 ... 6 0
2 3 2 ... 6 0
3 4 2 ... 6 4
4 5 1 ... 5 12
5 6 2 ... 6 6
6 7 1 ... -5 0
7 8 2 ... -5 39
8 9 1 ... 4 0
9 10 1 ... 6 0
[10 rows x 42 columns]
- 设置值标签和列标题的字典。
加载一个将列映射到值标签的字典,并创建如下的首选列名列表:
with open('data/nlscodes.txt', 'r') as reader:
... setvalues = eval(reader.read())
...
pprint.pprint(setvalues)
{'R0536300': {0.0: 'No Information', 1.0: 'Male', 2.0: 'Female'},
'R1235800': {0.0: 'Oversample', 1.0: 'Cross-sectional'},
'S8646900': {1.0: '1\. Definitely',
2.0: '2\. Probably ',
3.0: '3\. Probably not',
4.0: '4\. Definitely not'}}
...abbreviated to save space
newcols = ['personid','gender','birthmonth',
... 'birthyear','sampletype','category',
... 'satverbal','satmath','gpaoverall',
... 'gpaeng','gpamath','gpascience','govjobs',
... 'govprices','govhealth','goveld','govind',
... 'govunemp','govinc','govcollege',
... 'govhousing','govenvironment','bacredits',
... 'coltype1','coltype2','coltype3','coltype4',
... 'coltype5','coltype6','highestgrade',
... 'maritalstatus','childnumhome','childnumaway',
... 'degreecol1','degreecol2','degreecol3',
... 'degreecol4','wageincome','weeklyhrscomputer',
... 'weeklyhrstv','nightlyhrssleep',
... 'weeksworkedlastyear']
- 设置值标签和缺失值,并将选定的列更改为
category数据类型。
使用 setvalues 字典将现有值替换为值标签。用 NaN 替换所有从 –9 到 –1 的所有值:
nls97r.replace(setvalues, inplace=True)
nls97r.head()
R0000100 R0536300 ... U2963000 Z9063900
0 1 Female ... -5 52
1 2 Male ... 6 0
2 3 Female ... 6 0
3 4 Female ... 6 4
4 5 Male ... 5 12
[5 rows x 42 columns]
nls97r.replace(list(range(-9,0)), np.nan, inplace=True)
for col in nls97r[[k for k in setvalues]].columns:
... nls97r[col] = nls97r[col].astype('category')
...
nls97r.dtypes
R0000100 int64
R0536300 category
R0536401 int64
R0536402 int64
R1235800 category
...
U2857300 category
U2962800 category
U2962900 category
U2963000 float64
Z9063900 float64
Length: 42, dtype: object
-
设置有意义的列标题:
nls97r.columns = newcols nls97r.dtypespersonid int64 gender category birthmonth int64 birthyear int64 sampletype category ... wageincome category weeklyhrscomputer category weeklyhrstv category nightlyhrssleep float64 weeksworkedlastyear float64 Length: 42, dtype: object
这展示了如何将 R 数据文件导入 pandas 并分配值标签。
它的工作原理是……
使用 pyreadr 将 R 数据读入 pandas 相当简单。将文件名传递给 read_r 函数即可。由于 read_r 可以一次返回多个对象,我们需要指定哪个对象。在读取 rds 文件时(而不是 rdata 文件),只返回一个对象。它的键为 None。
在 第 3 步 中,我们加载了一个将变量映射到值标签的字典,并为我们首选的列标题加载了一个列表。在 第 4 步 中,我们应用了值标签。我们还将应用值的数据类型更改为 category,用 [k for k in setvalues] 生成了 setvalues 字典中键的列表,然后迭代这些列。
我们将 第 5 步 中的列标题更改为更直观的标题。请注意这里顺序很重要。在更改列名之前,我们需要设置值标签,因为 setvalues 字典基于原始列标题。
使用 pyreadr 将 R 文件直接读入 pandas 的主要优势是我们无需首先将 R 数据转换为 CSV 文件。一旦编写了读取文件的 Python 代码,只需在 R 数据更改时重新运行它即可。当我们在没有安装 R 的机器上工作时,这尤其有帮助。
还有更多内容……
Pyreadr 能够返回多个数据帧。当我们将多个数据对象保存为 rdata 文件时,这将非常有用。我们可以一次性返回它们所有。
Pprint 是改善 Python 字典显示的一个方便工具。
我们本可以使用 rpy2 而不是 pyreadr 导入 R 数据。rpy2 要求也安装了 R,但它比 pyreadr 更强大。它将读取 R 因子并自动将它们设置为 pandas DataFrame 值。参见以下代码:
import rpy2.robjects as robjects
from rpy2.robjects import pandas2ri
pandas2ri.activate()
readRDS = robjects.r['readRDS']
nls97withvalues = readRDS('data/nls97withvalues.rds')
nls97withvalues
R0000100 R0536300 ... U2963000 Z9063900
1 1 Female ... -2147483648 52
2 2 Male ... 6 0
3 3 Female ... 6 0
4 4 Female ... 6 4
5 5 Male ... 5 12
... ... ... ... ... ...
8980 9018 Female ... 4 49
8981 9019 Male ... 6 0
8982 9020 Male ... -2147483648 15
8983 9021 Male ... 7 50
8984 9022 Female ... 7 20
[8984 rows x 42 columns]
这会生成异常的 –2147483648 值。这是当 readRDS 解释数值列中的缺失数据时发生的情况。确认该数值不是有效值后,将该数字全局替换为 NaN,是一个不错的下一步。
另请参阅
pyreadr 的清晰说明和示例可在 github.com/ofajardo/pyreadr 查看。
Feather 文件是一种相对较新的格式,可以被 R 和 Python 都读取。我将在下一个示例中讨论这些文件。
持久化表格数据
我们持久化数据,将其从内存复制到本地或远程存储,有几个原因:为了能够在不重复生成数据的步骤的情况下访问数据,便于与他人共享数据,或者为了使数据能被不同的软件使用。在本食谱中,我们将加载到 pandas DataFrame 中的数据保存为不同的文件类型(CSV、Excel、Pickle 和 Feather)。
另一个重要的,但有时被忽视的,持久化数据的原因是保存我们需要更仔细检查的某个数据片段;也许它需要在我们完成分析之前被其他人审查。对于在中型到大型组织中处理操作数据的分析师来说,这一过程是日常数据清理工作流程的一部分。
除了这些持久化数据的原因外,我们关于何时和如何序列化数据的决策还受到其他几个因素的影响:我们处于数据分析项目的哪个阶段,保存和重新加载数据的机器的硬件和软件资源,以及我们的数据集大小。分析师在保存数据时往往比在字处理应用程序中按 Ctrl + S 时更需要谨慎。
一旦我们持久化数据,它就会与我们用于创建它的逻辑分开存储。我认为这是对我们分析完整性构成的最重要威胁之一。我们常常会加载那些我们在过去保存的数据(一个星期前?一个月前?一年前?),却忘记了一个变量是如何定义的,它与其他变量的关系是什么。如果我们正处于数据清理的过程中,最好不要持久化数据,只要我们的工作站和网络能够轻松地处理数据的重新生成。只有在我们完成了工作的里程碑时,才应该持久化数据。
除了何时持久化数据的问题,还有如何持久化数据的问题。如果我们是为了自己在相同软件中重新使用数据,最好将其保存为该软件本地的二进制格式。对于 SPSS、SAS、Stata 和 R 等工具来说,这很简单,但对于 pandas 来说就没有那么容易了。不过从某种意义上来说,这也是好消息。我们有很多选择,从 CSV 和 Excel 到 Pickle 和 Feather。在本食谱中,我们会将数据保存为所有这些文件类型。
注意
Pickle 和 Feather 是可以用来存储 pandas DataFrame 的二进制文件格式。
准备工作
如果系统中没有安装 Feather,你需要安装它。可以通过在终端窗口或 Windows 的 powershell 中输入 pip install pyarrow 来安装。如果在你的 chapter 1 文件夹中没有名为 Views 的子文件夹,你需要创建一个,以便运行本食谱中的代码。
数据说明
这个数据集来自全球历史气候网络集成数据库,由美国国家海洋和大气管理局提供公开使用,网址为www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly。我使用的是版本 4 的数据。本例中的数据使用的是完整数据集中的 100,000 行样本,完整数据集也可在仓库中获得。
如何做到这一点...
我们将 CSV 文件加载到 pandas 中,然后将其保存为 Pickle 和 Feather 格式。我们还将数据的子集保存为 CSV 和 Excel 格式:
- 导入
pandas和pyarrow。
需要导入pyarrow才能将 pandas 数据保存为 Feather 格式:
import pandas as pd
import pyarrow
-
将土地温度的 CSV 文件加载到 pandas 中,删除包含缺失数据的行,并设置索引:
landtemps = \ ... pd.read_csv('data/landtempssample.csv', ... names=['stationid','year','month','avgtemp', ... 'latitude','longitude','elevation', ... 'station','countryid','country'], ... skiprows=1, ... parse_dates=[['month','year']], ... low_memory=False) landtemps.rename(columns={'month_year':'measuredate'}, inplace=True) landtemps.dropna(subset=['avgtemp'], inplace=True) landtemps.dtypesmeasuredate datetime64[ns] stationid object avgtemp float64 latitude float64 longitude float64 elevation float64 station object countryid object country object dtype: objectlandtemps.set_index(['measuredate','stationid'], inplace=True) -
将
temperature的极端值写入 CSV 和 Excel 文件。
使用quantile方法选择异常值行,这些行位于分布两端的千分之一水平:
extremevals = landtemps[(landtemps.avgtemp < landtemps.avgtemp.quantile(.001)) | (landtemps.avgtemp > landtemps.avgtemp.quantile(.999))]
extremevals.shape
(171, 7)
extremevals.sample(7)
avgtemp ... country
measuredate stationid ...
2013-08-01 QAM00041170 35.30 ... Qatar
2005-01-01 RSM00024966 -40.09 ... Russia
1973-03-01 CA002401200 -40.26 ... Canada
2007-06-01 KU000405820 37.35 ... Kuwait
1987-07-01 SUM00062700 35.50 ... Sudan
1998-02-01 RSM00025325 -35.71 ... Russia
1968-12-01 RSM00024329 -43.20 ... Russia
[7 rows x 7 columns]
extremevals.to_excel('views/tempext.xlsx')
extremevals.to_csv('views/tempext.csv')
- 保存为 Pickle 和 Feather 文件。
为了保存 Feather 文件,需要重置索引:
landtemps.to_pickle('data/landtemps.pkl')
landtemps.reset_index(inplace=True)
landtemps.to_feather("data/landtemps.ftr")
- 加载我们刚保存的 Pickle 和 Feather 文件。
注意,当保存和加载 Pickle 文件时,我们的索引得到了保留:
landtemps = pd.read_pickle('data/landtemps.pkl')
landtemps.head(2).T
measuredate 2000-04-01 1940-05-01
stationid USS0010K01S CI000085406
avgtemp 5.27 18.04
latitude 39.90 -18.35
longitude -110.75 -70.33
elevation 2,773.70 58.00
station INDIAN_CANYON ARICA
countryid US CI
country United States Chile
landtemps = pd.read_feather("data/landtemps.ftr")
landtemps.head(2).T
0 1
measuredate 2000-04-01 00:00:00 1940-05-01 00:00:00
stationid USS0010K01S CI000085406
avgtemp 5.27 18.04
latitude 39.90 -18.35
longitude -110.75 -70.33
elevation 2,773.70 58.00
station INDIAN_CANYON ARICA
countryid US CI
country United States Chile
前面的步骤展示了如何使用两种不同的格式,Pickle 和 Feather,序列化 pandas 的 DataFrame。
它是如何工作的...
持久化 pandas 数据非常简单。DataFrame 有to_csv、to_excel、to_pickle和to_feather方法。Pickle 格式能保留我们的索引。
还有更多内容...
存储数据到 CSV 文件的优点是保存时几乎不占用额外的内存。缺点是写入 CSV 文件的速度比较慢,而且我们会失去重要的元数据,例如数据类型。(read_csv在重新加载文件时通常能识别数据类型,但并不总是如此。) Pickle 文件能保留这些数据,但在序列化时可能会给资源较少的系统带来负担。Feather 格式对资源的要求较低,且可以在 R 和 Python 中轻松加载,但为了序列化我们必须牺牲索引。此外,Feather 的作者未对长期支持做出承诺。
你可能已经注意到,我没有就数据序列化提出全球性的建议——除非是在项目里程碑时限制持久化完整数据集。这绝对是一个“对的工具用于对的工作”的情形。当我想与同事分享文件的一个片段进行讨论时,我使用 CSV 或 Excel 文件。当我进行持续的 Python 项目时,尤其是当我使用内存较小、处理器较旧并且还要使用 R 的机器时,我使用 Feather 格式。当我完成一个项目时,我会使用 Pickle 格式保存 DataFrames。
总结
我们的 Python 数据项目通常从存储在各种格式中的原始数据开始,这些数据由多种软件工具导出。最常见的表格格式和工具包括 CSV 和 Excel 文件、SQL 表格,以及 SPSS、Stata、SAS 和 R 数据集。在本章中,我们将所有这些来源的数据转换为 pandas DataFrame,并解决了最常见的挑战。我们还探索了持久化表格数据的方法。在下一章中,我们将处理其他格式的数据。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第二章:预测处理 HTML、JSON 和 Spark 数据时的数据清理问题
本章继续讨论从各种来源导入数据的工作,以及导入数据后需要进行的初步检查。在过去的 25 年里,数据分析师发现他们越来越需要处理非表格型的半结构化数据。有时,他们甚至会在这些形式中创建和持久化数据。本章将以 JSON 这一传统表格数据集的常见替代方案为例,但这些一般概念也可以扩展到 XML 和 NoSQL 数据存储,如 MongoDB。此外,我们还将讨论从网站抓取数据时常遇到的问题。
数据分析师还发现,分析数据的量的增加速度超过了机器处理能力的提升,至少是在本地可用的计算资源方面。处理大数据有时需要依赖像 Apache Spark 这样的技术,它可以利用分布式资源。
本章中,我们将依次解决以下几种方法:
-
导入简单的 JSON 数据
-
从 API 导入更复杂的 JSON 数据
-
从网页导入数据
-
使用 Spark 数据
-
持久化 JSON 数据
-
数据版本管理
技术要求
完成本章中的方法,您需要使用 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但该代码也适用于 pandas 1.5.3 及以后的版本。
本章中的代码可以从本书的 GitHub 仓库下载,链接:github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
导入简单的 JSON 数据
JavaScript 对象表示法(JSON)已成为一种极其有用的标准,用于在不同的机器、进程或节点之间传输数据。通常,客户端向服务器发送数据请求,服务器随后查询本地存储中的数据,并将其从 SQL Server、MySQL 或 PostgreSQL 表等格式转换为 JSON,客户端可以使用该数据。这有时会被进一步复杂化,例如,第一个服务器(比如一个 Web 服务器)将请求转发给数据库服务器。JSON 和 XML 都能方便地完成这一过程,具体方式如下:
-
可被人类阅读
-
适用于大多数客户端设备
-
不受结构限制
JSON 具有相当高的灵活性,这意味着它几乎可以容纳任何内容,无论这些内容多么不明智。结构甚至可以在 JSON 文件内发生变化,因此在不同的地方可能会出现不同的键。例如,文件可能以一些解释性键开始,而这些键的结构与其余的数据键截然不同,或者某些键在某些情况下可能会出现,而在其他情况下则不会出现。我们将讨论处理这种混乱(呃,灵活性)的一些方法。
准备工作
在这个示例中,我们将处理有关政治候选人的新闻故事数据。这些数据可以在 dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/0ZLHOK 上公开使用。我已将该处的 JSON 文件合并为一个文件,并从合并的数据中随机选择了 60,000 条新闻故事。这些样本(allcandidatenewssample.json)可以在本书的 GitHub 仓库中找到。
在这个示例中,我们将进行一些列表和字典推导式的操作。如果你对列表推导式或字典推导式感到生疏,或者没有经验,可以参考 DataCamp 提供的很好的教程:Python 列表推导式 和 Python 字典推导式。
操作步骤…
我们将在做一些数据检查和清理后,将 JSON 文件导入到 pandas 中:
- 导入
json和pprint库。
pprint 改善了加载 JSON 数据时返回的列表和字典的显示方式:
import pandas as pd
import numpy as np
import json
import pprint
from collections import Counter
- 加载 JSON 数据并查找潜在的问题。
使用 json load 方法返回关于政治候选人的新闻故事数据。load 返回一个字典列表。使用 len 获取列表的大小,即新闻故事的总数。在这个例子中,每个列表项都是一个字典,包含标题、来源等键值对。使用 pprint 显示前两个字典。获取第一个列表项中 source 键的值:
with open('data/allcandidatenewssample.json') as f:
... candidatenews = json.load(f)
...
len(candidatenews)
60000
pprint.pprint(candidatenews[0:2])
[{'date': '2019-12-25 10:00:00',
'domain': 'www.nbcnews.com',
'panel_position': 1,
'query': 'Michael Bloomberg',
'source': 'NBC News',
'story_position': 6,
'time': '18 hours ago',
'title': 'Bloomberg cuts ties with company using prison inmates to make campaign calls',
'url': 'https://www.nbcnews.com/politics/2020-election/bloomberg-cuts-ties-company-using-prison-inmates-make-campaign-calls-n1106971'},
{'date': '2019-11-09 08:00:00',
'domain': 'www.townandcountrymag.com',
'panel_position': 1,
'query': 'Amy Klobuchar',
'source': 'Town & Country Magazine',
'story_position': 3,
'time': '18 hours ago',
'title': "Democratic Candidates React to Michael Bloomberg's Potential Run",
'url': 'https://www.townandcountrymag.com/society/politics/a29739854/michael-bloomberg-democratic-candidates-campaign-reactions/'}]
pprint.pprint(candidatenews[0]['source'])
'NBC News'
- 检查字典结构的差异。
使用 Counter 检查列表中是否有字典的键数少于或多于正常的 9 个。查看一些几乎没有数据的字典(只有两个键)然后再将其删除。确认剩余字典列表的长度符合预期——60000-2382=57618:
Counter([len(item) for item in candidatenews])
Counter({9: 57202, 2: 2382, 10: 416})
pprint.pprint(next(item for item in candidatenews if len(item)<9))
{'date': '2019-09-11 18:00:00', 'reason': 'Not collected'}
pprint.pprint(next(item for item in candidatenews if len(item)>9))
{'category': 'Satire',
'date': '2019-08-21 04:00:00',
'domain': 'politics.theonion.com',
'panel_position': 1,
'query': 'John Hickenlooper',
'source': 'Politics | The Onion',
'story_position': 8,
'time': '4 days ago',
'title': ''And Then There Were 23,' Says Wayne Messam Crossing Out '
'Hickenlooper Photo \n'
'In Elaborate Grid Of Rivals',
'url': 'https://politics.theonion.com/and-then-there-were-23-says-wayne-messam-crossing-ou-1837311060'}
pprint.pprint([item for item in candidatenews if len(item)==2][0:2])
[{'date': '2019-09-11 18:00:00', 'reason': 'Not collected'},
{'date': '2019-07-24 00:00:00', 'reason': 'No Top stories'},
3candidatenews = [item for item in candidatenews if len(item)>2]
len(candidatenews)
57618
- 从 JSON 数据中生成统计数据。
获取仅针对 Politico(一个报道政治新闻的网站)的字典,并显示其中的几个字典:
politico = [item for item in candidatenews if item["source"] == "Politico"]
len(politico)
2732
pprint.pprint(politico[0:2])
[{'date': '2019-05-18 18:00:00',
'domain': 'www.politico.com',
'panel_position': 1,
'query': 'Marianne Williamson',
'source': 'Politico',
'story_position': 7,
'time': '1 week ago',
'title': 'Marianne Williamson reaches donor threshold for Dem debates',
'url': 'https://www.politico.com/story/2019/05/09/marianne-williamson-2020-election-1315133'},
{'date': '2018-12-27 06:00:00',
'domain': 'www.politico.com',
'panel_position': 1,
'query': 'Julian Castro',
'source': 'Politico',
'story_position': 1,
'time': '1 hour ago',
'title': "O'Rourke and Castro on collision course in Texas",
'url': 'https://www.politico.com/story/2018/12/27/orourke-julian-castro-collision-texas-election-1073720'}]
- 获取
source数据,并确认其长度符合预期。
显示新 sources 列表中的前几个项目。生成按来源统计的新闻故事数量,并展示 10 个最受欢迎的来源。注意,来自 The Hill 的故事可能会在 source 中显示为 TheHill(没有空格)或 The Hill:
sources = [item.get('source') for item in candidatenews]
type(sources)
<class 'list'>
len(sources)
57618
sources[0:5]
['NBC News', 'Town & Country Magazine', 'TheHill', 'CNBC.com', 'Fox News']
pprint.pprint(Counter(sources).most_common(10))
[('Fox News', 3530),
('CNN.com', 2750),
('Politico', 2732),
('TheHill', 2383),
('The New York Times', 1804),
('Washington Post', 1770),
('Washington Examiner', 1655),
('The Hill', 1342),
('New York Post', 1275),
('Vox', 941)]
- 修正字典中值的错误。
修正 The Hill 的 source 值。注意,现在 The Hill 是新闻故事中最常见的来源:
for newsdict in candidatenews:
... newsdict.update((k, "The Hill") for k, v in newsdict.items()
... if k == "source" and v == "TheHill")
...
sources = [item.get('source') for item in candidatenews]
pprint.pprint(Counter(sources).most_common(10))
[('The Hill', 3725),
('Fox News', 3530),
('CNN.com', 2750),
('Politico', 2732),
('The New York Times', 1804),
('Washington Post', 1770),
('Washington Examiner', 1655),
('New York Post', 1275),
('Vox', 941),
('Breitbart', 799)]
- 创建一个 pandas DataFrame。
将 JSON 数据传递给 pandas 的 DataFrame 方法。将 date 列转换为 datetime 数据类型:
candidatenewsdf = pd.DataFrame(candidatenews)
candidatenewsdf.dtypes
title object
url object
source object
time object
date object
query object
story_position int64
panel_position object
domain object
category object
dtype: object
- 确认我们得到了预期的
source值。
同时,重命名 date 列:
candidatenewsdf.rename(columns={'date':'storydate'}, inplace=True)
candidatenewsdf.storydate = candidatenewsdf.storydate.astype('datetime64[ns]')
candidatenewsdf.shape
(57618, 10)
candidatenewsdf.source.value_counts(sort=True).head(10)
The Hill 3725
Fox News 3530
CNN.com 2750
Politico 2732
The New York Times 1804
Washington Post 1770
Washington Examiner 1655
New York Post 1275
Vox 941
Breitbart 799
Name: source, dtype: int64
现在我们有一个 pandas DataFrame,只包含那些具有有意义数据且 source 值已固定的新闻故事。
工作原理……
json.load 方法返回一个字典列表。这使得在处理这些数据时可以使用许多熟悉的工具:列表方法、切片、列表推导、字典更新等等。有时候(也许当你只需要填充一个列表或统计给定类别中的个体数量时)根本不需要使用 pandas。
在 步骤 2 到 6 中,我们使用列表方法执行许多与前面的 pandas 配方相同的检查。在 步骤 3 中,我们使用 Counter 与列表推导式(Counter([len(item) for item in candidatenews]))来获取每个字典中的键的数量。这告诉我们有 2,382 个字典只有 2 个键,416 个字典有 10 个键。我们使用 next 查找一个字典示例,看看是否有少于或多于 9 个键的字典,以了解这些项的结构。我们使用切片展示 2 个只有 2 个键的字典,看看这些字典中是否有数据。然后,我们仅选择那些有超过 2 个键的字典。
在 步骤 4 中,我们创建了字典列表的一个子集,仅包含 source 等于 Politico 的项,并查看了其中的几项。然后,我们创建了一个仅包含源数据的列表,并在 步骤 5 中使用 Counter 来列出 10 个最常见的来源。
步骤 6 演示了如何在字典列表中有条件地替换关键值。在这种情况下,当 key (k) 为 source 且 value (v) 为 TheHill 时,我们将键值更新为 The Hill。for k, v in newsdict.items() 这一部分是此行代码的无名英雄。它遍历了 candidatenews 中所有字典的所有键/值对。
通过将字典列表传递给 pandas 的 DataFrame 方法,很容易创建一个 pandas DataFrame。我们在 步骤 7 中执行了这一操作。主要的复杂性在于我们需要将日期列从字符串转换为日期格式,因为在 JSON 中日期实际上只是字符串。
还有更多……
在 步骤 5 和 6 中,我们使用 item.get('source') 而不是 item['source']。当字典中可能缺少键时,这非常方便。get 方法在键缺失时返回 None,但我们可以使用可选的第二个参数指定返回的值。
我在 步骤 8 中将 date 列重命名为 storydate。这不是必须的,但这是一个好主意。date 列不仅不能告诉你日期实际代表什么,而且它作为列名太过通用,最终会在某些情况下引起问题。
新闻故事数据非常适合表格结构。将每个列表项表示为一行,键值对作为该行的列和值,逻辑上是合理的。没有复杂的情况,例如键值本身是字典列表。假设每个故事的 authors 键包含一个列表,列表中的每个项代表一位作者,而该项是关于作者的字典信息。在使用 Python 处理 JSON 数据时,这种情况并不少见。下一个食谱将展示如何处理这种结构的数据。
从 API 导入更复杂的 JSON 数据
在前面的食谱中,我们讨论了处理 JSON 数据的一个重要优势(也是挑战)——它的灵活性。一个 JSON 文件可以拥有几乎任何其作者能想象的结构。这通常意味着这些数据并不像我们到目前为止讨论过的数据源那样具有表格结构,也不像 pandas DataFrame 那样有明确的行列结构。分析师和应用程序开发者之所以使用 JSON,正是因为它不强制要求表格结构。我知道我就是这么做的!
从多个表中检索数据通常需要我们进行一对多合并。将这些数据保存到一个表或文件中意味着在“一”方重复数据。例如,学生的人口统计数据与所学课程的数据合并,人口统计数据会为每个课程重复。在 JSON 中,不需要重复数据即可在一个文件中捕获这些数据项。我们可以将所学课程的数据嵌套在每个学生的数据中。
但是,以这种方式结构化的 JSON 数据进行分析,最终会要求我们以不同于习惯的方式操作数据,或者将 JSON 转换为表格形式。我们将在 第十二章:使用用户定义函数和类自动化数据清理 的 处理非表格数据结构的类 食谱中探讨第一种方法。本食谱采用第二种方法,使用一个非常方便的工具 json_normalize 将 JSON 的指定节点转换为表格结构。
我们首先使用 API 获取 JSON 数据,因为这正是 JSON 数据常见的使用方式。通过 API 获取数据的一个优势是,相比于从本地保存的文件中读取数据,当源数据更新时,我们的代码可以更容易地重新运行。
准备工作
本食谱假设你已经安装了 requests 和 pprint 库。如果没有安装,你可以通过 pip 安装它们。在终端(或 Windows 中的 PowerShell)中,输入 pip install requests 和 pip install pprint。
以下是使用克利夫兰艺术博物馆 Collections API 时生成的 JSON 文件结构。文件开头有一个有用的info部分,但我们关注的是data部分。由于这些数据不适合直接放入表格结构中,所以我们需要展平数据。每个收藏项可能有多个citations对象和多个creators对象。我已简化了 JSON 文件以节省空间:
{"info": { "total": 778, "parameters": {"african_american_artists": "" }},
"data": [
{
"id": 165157,
"accession_number": "2007.158",
"title": "Fulton and Nostrand",
"creation_date": "1958",
"citations": [
{
"citation": "Annual Exhibition: Sculpture, Paintings...",
"page_number": "Unpaginated, [8],[12]",
"url": null
},
{
"citation": "\"Moscow to See Modern U.S. Art,\"<em> New York...",
"page_number": "P. 60",
"url": null
}]
"creators": [
{
"description": "Jacob Lawrence (American, 1917-2000)",
"extent": null,
"qualifier": null,
"role": "artist",
"birth_year": "1917",
"death_year": "2000"
}
]
}
数据说明
本配方使用的 API 由克利夫兰艺术博物馆提供,可以在openaccess-api.clevelandart.org/上公开访问。
由于调用 API 会检索实时数据,运行此代码时可能会得到不同的输出。
如何实现……
使用博物馆的收藏数据创建一个 DataFrame,每个citation对应一行,title和creation_date会被重复:
- 导入
json、requests和pprint库。
我们需要requests库来使用 API 获取 JSON 数据。pprint可以改善列表和字典的显示效果:
import pandas as pd
import numpy as np
import json
import pprint
import requests
- 使用 API 加载 JSON 数据。
向克利夫兰艺术博物馆的 Collections API 发送get请求。使用查询字符串来表示只获取非裔美国艺术家的收藏。显示第一个收藏项。为了节省空间,我已截断了第一个项目的输出:
response = requests.get("https://openaccess-api.clevelandart.org/api/artworks/?african_american_artists")
camcollections = json.loads(response.text)
len(camcollections['data'])
778
pprint.pprint(camcollections['data'][0])
{'accession_number': '2007.158',
'catalogue_raisonne': None,
'citations': [
{'citation': 'Annual Exhibition: Sculpture...',
'page_number': 'Unpaginated, [8],[12]',
'url': None},
{'citation': '"Moscow to See Modern U.S....',
'page_number': 'P. 60',
'url': None}]
'collection': 'American - Painting',
'creation_date': '1958',
'creators': [
{'biography': 'Jacob Lawrence (born 1917)...',
'birth_year': '1917',
'description': 'Jacob Lawrence (American...)',
'role': 'artist'}],
'type': 'Painting'}
- 展平 JSON 数据。
使用json_normalize方法从 JSON 数据创建 DataFrame。需要说明的是,引用的数量将决定行数,而accession_number、title、creation_date、collection、creators和type将被重复显示。可以通过显示前两条记录并使用.T选项转置它们,来观察数据已经被展平:
camcollectionsdf = \
... pd.json_normalize(camcollections['data'],
... 'citations',
... ['accession_number','title','creation_date',
... 'collection','creators','type'])
camcollectionsdf.head(2).T
0 1
citation Annual Exhibiti... "Moscow to See Modern...
page_number Unpaginated, P. 60
url None None
accession_number 2007.158 2007.158
title Fulton and No... Fulton and No...
creation_date 1958 1958
collection American - Pa... American - Pa...
creators [{'description':'J... [{'description':'J...
type Painting Painting
-
从
creators中提取birth_year值:creator = camcollectionsdf[:1].creators[0] type(creator[0])dictpprint.pprint(creator)[{'biography': 'Jacob Lawrence (born 1917) has been a prominent art...', 'birth_year': '1917', 'death_year': '2000', 'description': 'Jacob Lawrence (American, 1917-2000)', 'extent': None, 'name_in_original_language': None, 'qualifier': None, 'role': 'artist'}]camcollectionsdf['birthyear'] = camcollectionsdf.\ ... creators.apply(lambda x: x[0]['birth_year']) camcollectionsdf.birthyear.value_counts().\ ... sort_index().head()1821 18 1886 2 1888 1 1892 13 1899 17 Name: birthyear, dtype: int64
这将给我们一个 pandas DataFrame,其中每个citation都有一行,并且每个收藏项的信息(title、creation_date等)会被重复。
它是如何工作的……
在这道配方中,我们使用的 JSON 文件比前一个更为有趣。JSON 文件中的每个对象都是克利夫兰艺术博物馆收藏中的一项物品。在每个收藏项内,包含一个或多个引用。唯一能够将这些信息以表格形式存储的方法是将其展平。每个收藏项还有一个或多个关于创作者(艺术家)的字典。这些字典包含我们所需的birth_year值。
我们希望每个集合项的每个引用都对应一行。为了理解这一点,可以想象我们正在处理关系数据,拥有一个集合表和一个引用表,我们正在进行集合到引用的一个对多合并。我们通过将citations作为第二个参数,使用json_normalize做类似的事情。这告诉json_normalize为每个引用创建一行,并使用每个引用字典中的键值——citation、page_number和url——作为数据值。
调用json_normalize时的第三个参数包含了列名列表,这些列将在每个引用中重复。注意到access_number、title、creation_date、collection、creators和type在前两个观测值中是重复的。Citation和page_number发生变化。(url在第一个和第二个引用中是相同的值。否则,它也会发生变化。)
这仍然给我们留下了creators字典的问题(可能有多个创作者)。当我们运行json_normalize时,它会抓取我们指示的每个键的值(在第三个参数中),并将其存储在该列和行的数据中,无论该值是简单文本还是字典列表,如creators的情况。我们查看步骤 4中第一个集合行的第一个(也是唯一的)creators项,将其命名为creator。(注意,creators列表在所有集合项的citations中是重复的,正如title、creation_date等值也是一样。)
我们想要每个集合项的第一个创作者的出生年份,可以在creator[0]['birth_year']中找到。为了使用这个值创建birthyear系列,我们使用apply和lambda函数:
camcollectionsdf['birthyear'] = camcollectionsdf.\
... creators.apply(lambda x: x[0]['birth_year'])
我们仔细研究了第六章,使用系列操作清理和探索数据中的 lambda 函数。在这里,将x视为表示creators系列是有帮助的,因此x[0]给我们带来我们想要的列表项creators[0]。我们从birth_year键中提取值。
还有更多…
你可能注意到,我们在调用json_normalize时省略了一些 API 返回的 JSON。我们传递给json_normalize的第一个参数是camcollections['data']。实际上,我们忽略了 JSON 数据开头的info对象。我们需要的信息直到data对象才开始。这在概念上与上一章第二个食谱中的skiprows参数没有太大区别。JSON 文件开头有时会包含类似的元数据。
另见
上述食谱展示了一些有用的技术,用于在没有 pandas 的情况下进行数据完整性检查,包括列表操作和推导式。这些对于本食谱中的数据也是相关的。
从网页导入数据
我们在本教程中使用Beautiful Soup从网页抓取数据,并将这些数据加载到 pandas 中。网页抓取在网站上有定期更新的数据而没有 API 时非常有用。每当页面更新时,我们可以重新运行代码生成新的数据。
不幸的是,当目标页面的结构发生变化时,我们构建的网络爬虫可能会被破坏。而 API 则不太可能发生这种情况,因为它们是为数据交换设计的,并且在设计时就考虑到了这一点。大多数网页设计师的优先考虑事项是信息展示的质量,而非数据交换的可靠性和便捷性。这就导致了独特的网页抓取数据清理挑战,包括 HTML 元素将数据存放在不同且不断变化的位置、格式化标签使得底层数据变得模糊不清,以及解释性文本帮助数据解读但难以提取。除了这些挑战,抓取还会遇到一些常见的数据清理问题,比如列中数据类型的变化、不理想的标题以及缺失值。在本教程中,我们将处理这些最常见的数据问题。
准备工作
要运行本教程中的代码,你需要安装 Beautiful Soup。你可以通过在终端窗口或 Windows PowerShell 中输入pip install beautifulsoup4来安装它。
我们将从一个网页中抓取数据,找到该页面上的以下表格,并将其加载到 pandas DataFrame 中:

图 2.1:COVID-19 数据来自病例数每百万人最少的国家
数据 备注
我创建了这个网页,www.alrb.org/datacleaning/highlowcases.html,基于来自Our World in Data的 COVID-19 数据,供公众使用,数据可在ourworldindata.org/covid-cases找到。
如何操作…
我们从网站抓取 COVID-19 数据并进行一些常规的数据检查:
-
导入
pprint、requests和BeautifulSoup库:import pandas as pd import numpy as np import json import pprint import requests from bs4 import BeautifulSoup -
解析网页并获取表格的表头行。
使用 Beautiful Soup 的find方法获取我们需要的表格,然后使用find_all方法提取该表格中th元素内的内容。根据th行的文本创建列标签列表:
webpage = requests.get("http://www.alrb.org/datacleaning/highlowcases.html")
bs = BeautifulSoup(webpage.text, 'html.parser')
theadrows = bs.find('table', {'id':'tblLowCases'}).thead.find_all('th')
type(theadrows)
<class 'bs4.element.ResultSet'>
labelcols = [j.get_text() for j in theadrows]
labelcols[0] = "rowheadings"
labelcols
['rowheadings',
'Last Date',
'Total Cases',
'Total Deaths',
'Total Cases PM',
'Total Deaths PM',
'Population',
'GDP Per Capita',
'Median Age']
- 从表格单元格中获取数据。
找到我们需要的表格的所有表格行。对于每一行表格,找到th元素并提取文本。我们将使用这些文本作为行标签。同时,对于每一行,找到所有的td元素(表格单元格中的数据),并将它们的文本存入一个列表中。
这给我们提供了datarows,其中包含表格中的所有数字数据。(你可以确认它与网页中的表格匹配。)然后,我们将labelrows列表(包含行标题)插入到datarows中的每个列表的开头:
rows = bs.find('table', {'id':'tblLowCases'}).tbody.find_all('tr')
datarows = []
labelrows = []
for row in rows:
... rowlabels = row.find('th').get_text()
... cells = row.find_all('td', {'class':'data'})
... if (len(rowlabels)>3):
... labelrows.append(rowlabels)
... if (len(cells)>0):
... cellvalues = [j.get_text() for j in cells]
... datarows.append(cellvalues)
...
pprint.pprint(datarows[0:2])
[['11/6/2022', '11,945', '2,159', '354', '64', '33,696,612', '1,479', '20.3'],
['7/2/2023', '9,515', '315', '363', '12', '26,207,982', '926', '15.1']]
pprint.pprint(labelrows[0:2])
['Yemen', 'Niger']
for i in range(len(datarows)):
... datarows[i].insert(0, labelrows[i])
...
pprint.pprint(datarows[0:2])
[['Yemen',
'11/6/2022',
'11,945',
'2,159',
'354',
'64',
'33,696,612',
'1,479',
'20.3'],
['Niger',
'7/2/2023',
'9,515',
'315',
'363',
'12',
'26,207,982',
'926',
'15.1']]
- 将数据加载到 pandas 中。
将 datarows 列表传递给 pandas 的 DataFrame 方法。请注意,所有数据都以对象数据类型读取到 pandas 中,并且一些数据的当前格式无法转换为数值(由于逗号的存在):
lowcases = pd.DataFrame(datarows, columns=labelcols)
lowcases.iloc[:,1:5].head()
Last Date Total Cases Total Deaths Total Cases PM
0 11/6/2022 11,945 2,159 354
1 7/2/2023 9,515 315 363
2 4/30/2023 7,698 194 434
3 12/10/2023 43,223 846 660
4 12/24/2023 7,779 125 904
lowcases.dtypes
rowheadings object
Last Date object
Total Cases object
Total Deaths object
Total Cases PM object
Total Deaths PM object
Population object
GDP Per Capita object
Median Age object
dtype: object
- 修复列名并将数据转换为数值。
删除列名中的空格。删除第一列数据中的所有非数字数据,包括逗号(str.replace("[⁰-9]",""))。将数据转换为数值,处理大多数列为整数,将 last_date 列转换为日期时间格式,将 median_age 列转换为浮动数值,并将 rowheadings 保留为对象类型:
lowcases.columns = lowcases.columns.str.replace(" ", "_").str.lower()
for col in lowcases.columns[2:-1]:
lowcases[col] = lowcases[col].\
str.replace("[⁰-9]","",regex=True).astype('int64')
lowcases['last_date'] = pd.to_datetime(lowcases.last_date)
lowcases['median_age'] = lowcases['median_age'].astype('float')
lowcases.dtypes
rowheadings object
last_date datetime64[ns]
total_cases int64
total_deaths int64
total_cases_pm int64
total_deaths_pm int64
population int64
gdp_per_capita int64
median_age float64
dtype: object
我们现在已经从 html 表格创建了一个 pandas DataFrame。
它是如何工作的…
Beautiful Soup 是一个非常有用的工具,用于在网页中查找特定的 HTML 元素并从中提取文本。你可以使用 find 获取一个 HTML 元素,使用 find_all 获取一个或多个元素。find 和 find_all 的第一个参数是要获取的 HTML 元素,第二个参数是一个 Python 字典,包含属性。你可以使用 get_text 从所有找到的 HTML 元素中提取文本。
处理元素和文本通常需要一定的循环操作,就像在 步骤 2 和 步骤 3 中一样。步骤 2 中的这两条语句是比较典型的:
theadrows = bs.find('table', {'id':'tblLowCases'}).thead.find_all('th')
labelcols = [j.get_text() for j in theadrows]
第一个语句查找我们想要的所有 th 元素,并从找到的元素创建一个名为 theadrows 的 Beautiful Soup 结果集。第二个语句使用 get_text 方法遍历 theadrows 结果集,从每个元素中提取文本,并将其存储在 labelcols 列表中。
步骤 3 稍微复杂一些,但它使用了相同的 Beautiful Soup 方法。我们在目标表格中查找所有的表格行 (tr)(rows = bs.find('table', {'id':'tblLowCases'}).tbody.find_all('tr'))。然后我们遍历每一行,查找 th 元素并获取该元素中的文本(rowlabels = row.find('th').get_text())。我们还查找每行的所有表格单元格 (td)(cells = row.find_all('td', {'class':'data'}))并提取所有表格单元格的文本(cellvalues = [j.get_text() for j in cells])。请注意,这段代码依赖于 td 元素的类名为 data。
最后,我们将从 th 元素中获取的行标签插入到 datarows 中每个列表的开头:
for i in range(len(datarows)):
... datarows[i].insert(0, labelrows[i])
在 步骤 4 中,我们使用 DataFrame 方法将我们在 步骤 2 和 步骤 3 中创建的列表加载到 pandas 中。然后,我们进行一些清理工作,类似于本章之前的配方。我们使用 string replace 移除列名中的空格,并删除所有非数字数据(包括逗号),以便这些原本有效的数字值能正确转换。我们将所有列转换为数值,除了 rowheadings 列保留为对象类型。
还有更多内容…
我们的爬取代码依赖于网页结构的几个方面不发生变化:感兴趣的表格的 ID、包含列和行标签的th标签的存在,以及td元素继续拥有其类名等于 data。好消息是,如果网页结构确实发生变化,通常只会影响find和find_all调用。其余的代码无需更改。
使用 Spark 数据
在处理大型数据集时,我们有时需要依赖分布式资源来清理和操作数据。使用 Apache Spark,分析师可以利用多台计算机的处理能力。本教程中我们将使用 PySpark,它是一个用于处理 Spark 数据的 Python API。我们还将介绍如何使用 PySpark 工具来初步查看数据、选择数据的某些部分,并生成一些简单的汇总统计信息。
准备工作
要运行本节中的代码,你需要在计算机上运行 Spark。如果你已经安装了 Anaconda,可以按照以下步骤操作来使用 Spark:
-
使用
conda install openjdk安装Java。 -
使用
conda install pyspark或conda install -c conda-forge pyspark安装PySpark。 -
使用
conda install -c conda-forge findspark安装findspark。注意
安装 PySpark 可能会有些棘手,特别是在设置必要的环境变量时。虽然
findspark有助于此,但一个常见问题是,当运行 PySpark 命令时,Java 安装可能无法被识别。如果在尝试运行本教程中的代码时出现JAVA_HOME is not set错误,那么很可能是这个问题。以下链接中的步骤 3展示了如何为 Linux、macOS 和 Windows 机器设置环境变量:
www.dei.unipd.it/~capri/BDC/PythonInstructions.html。
我们将使用来自第一章的陆地温度数据、使用 pandas 导入表格数据时预期的数据清理问题以及本章的候选新闻数据。所有数据和我们将在本教程中运行的代码都可以在本书的 GitHub 仓库中找到。
数据 注意
该数据集来自全球历史气候网络集成数据库,由美国国家海洋和大气管理局提供,供公众使用,网址:www.ncei.noaa.gov/data/global-historical-climatology-network-monthly/v4/。
本教程中我们将使用 PySpark 来读取存储在本地的数据。
如何操作...
要读取和探索数据,请按照以下步骤操作:
-
让我们开始一个 Spark 会话并加载陆地温度数据。我们可以使用会话对象的 read 方法来创建一个 Spark DataFrame。我们需要指出,导入的 CSV 文件的第一行是标题:
from pyspark.sql import SparkSession spark = SparkSession.builder \ .getOrCreate() landtemps = spark.read.option("header",True) \ .csv("data/landtemps.tar.gz") type(landtemps)pyspark.sql.dataframe.DataFrame
请注意,read方法返回的是 Spark DataFrame,而不是 pandas DataFrame。我们将需要使用不同的方法来查看数据,而不是我们目前为止使用的方法。
我们加载了完整的数据集,而不仅仅是第一章中我们使用的 10 万行样本。如果你的系统资源有限,可以改为导入landtempssample.csv文件。
-
我们应该查看导入的行数、列名以及数据类型。
temp列被读取为字符串类型,但它应该是浮动类型。我们将在后续步骤中修正这一点:landtemps.count()16904868landtemps.printSchema()root |-- locationid: string (nullable = true) |-- year: string (nullable = true) |-- month: string (nullable = true) |-- temp: string (nullable = true) |-- latitude: string (nullable = true) |-- longitude: string (nullable = true) |-- stnelev: string (nullable = true) |-- station: string (nullable = true) |-- countryid: string (nullable = true) |-- country: string (nullable = true) -
让我们查看几行数据。我们可以通过使用
select方法选择部分列:landtemps.select("station",'country','month','year','temp') \ .show(5, False)+-------+-------------------+-----+----+-----+ |station|country |month|year|temp | +-------+-------------------+-----+----+-----+ |SAVE |Antigua and Barbuda|1 |1961|-0.85| |SAVE |Antigua and Barbuda|1 |1962|1.17 | |SAVE |Antigua and Barbuda|1 |1963|-7.09| |SAVE |Antigua and Barbuda|1 |1964|0.66 | |SAVE |Antigua and Barbuda|1 |1965|0.48 | +-------+-------------------+-----+----+-----+ only showing top 5 rows -
我们应该修正
temp列的数据类型。我们可以使用withColumn函数在 Spark 中进行一系列列操作。在这里,我们使用它将temp列转换为float类型:landtemps = landtemps \ .withColumn("temp",landtemps.temp.cast('float')) landtemps.select("temp").dtypes[('temp', 'float')] -
现在我们可以对
temp变量运行摘要统计信息。我们可以使用describe方法来实现:landtemps.describe('temp').show()+-------+------------------+ |summary| temp| +-------+------------------+ | count| 14461547| | mean|10.880725773138536| | stddev|11.509636369381685| | min| -75.0| | max| 42.29| +-------+------------------+ -
Spark 会话的 read 方法可以导入各种不同的数据文件,而不仅仅是 CSV 文件。让我们尝试使用之前在本章中处理过的
allcandidatenewsJSON 文件:allcandidatenews = spark.read \ .json("data/allcandidatenewssample.json") allcandidatenews \ .select("source","title","story_position") \ .show(5)+--------------------+--------------------+--------------+ | source| title|story_position| +--------------------+--------------------+--------------+ | NBC News|Bloomberg cuts ti...| 6| |Town & Country Ma...|Democratic Candid...| 3| | null| null| null| | TheHill|Sanders responds ...| 7| | CNBC.com|From Andrew Yang'...| 2| +--------------------+--------------------+--------------+ only showing top 5 rows -
我们可以再次使用
count和printSchema方法来查看数据:allcandidatenews.count()60000allcandidatenews.printSchema()root |-- category: string (nullable = true) |-- date: string (nullable = true) |-- domain: string (nullable = true) |-- panel_position: string (nullable = true) |-- query: string (nullable = true) |-- reason: string (nullable = true) |-- source: string (nullable = true) |-- story_position: long (nullable = true) |-- time: string (nullable = true) |-- title: string (nullable = true) |-- url: string (nullable = true) -
我们还可以对
story_position变量生成一些摘要统计信息:allcandidatenews \ .describe('story_position') \ .show()+-------+-----------------+ |summary| story_position| +-------+-----------------+ | count| 57618| | mean|5.249626852719636| | stddev|2.889001922195635| | min| 1| | max| 10| +-------+-----------------+
这些步骤展示了如何将数据文件导入到 Spark DataFrame,查看数据结构,并生成摘要统计信息。
它是如何工作的…
PySpark API 显著减少了 Python 程序员使用 Apache Spark 处理大数据文件时需要做的工作。我们可以使用的操作方法与我们在 pandas DataFrame 中使用的并没有太大区别。我们可以查看行列数、检查并更改数据类型,生成摘要统计信息。
还有更多…
在我们分析的某个阶段,我们可能需要将 Spark DataFrame 转换为 pandas DataFrame。这是一个相对昂贵的过程,并且我们会失去使用 Spark 的优势,所以通常只有在分析阶段需要 pandas 库或依赖 pandas 的库时,我们才会这样做。但当我们需要转换到 pandas 时,操作非常简单——不过如果你处理的是大量数据,而且你的计算机处理器和内存并不算顶级,可能最好先开始转换,然后去喝个茶或咖啡。
以下代码将我们创建的allcandidatenews Spark DataFrame 转换为 pandas DataFrame,并显示结果 DataFrame 的结构:
allcandidatenewsdf = allcandidatenews.toPandas()
allcandidatenewsdf.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 60000 entries, 0 to 59999
Data columns (total 11 columns):
# Column Non-Null Count Dtype
--- ------ -------------- ------
0 category 416 non-null object
1 date 60000 non-null object
2 domain 57618 non-null object
3 panel_position 57618 non-null object
4 query 57618 non-null object
5 reason 2382 non-null object
6 source 57618 non-null object
7 story_position 57618 non-null float64
8 time 57618 non-null object
9 title 57618 non-null object
10 url 57618 non-null object
dtypes: float64(1), object(10)
memory usage: 5.0+ MB
在本章中,我们主要使用了非传统数据存储:JSON 文件、HTML 页面中的数据和 Spark 文件。在我们的数据清理工作中,通常会遇到一个阶段,在这个阶段保存数据清理结果是有意义的。我们在 第一章 的结尾——通过 pandas 导入表格数据时预见数据清理问题 中,已经探讨过如何持久化表格数据。在数据能很好地通过列和行来捕捉的情况下,这种方法效果很好。但当数据无法用表格结构表示时(例如,当我们处理的是包含复杂子文档的 JSON 文件时),我们可能希望在持久化数据时保留其原始结构。在接下来的食谱中,我们将讲解如何持久化 JSON 数据。
持久化 JSON 数据
我们可能有几个原因希望序列化 JSON 文件:
-
我们可能通过 API 获取了数据,但需要保存数据的快照。
-
JSON 文件中的数据相对静态,并且在项目的多个阶段中为我们的数据清理和分析提供信息。
-
我们可能会决定,像 JSON 这样的无模式格式的灵活性有助于解决许多数据清理和分析问题。
值得特别提到的是使用 JSON 的最后一个原因——它可以解决许多数据问题。尽管表格数据结构显然有很多优点,尤其对于操作性数据,它们往往不是存储分析数据的最佳方式。在准备数据进行分析时,往往会花费大量时间将不同表格的数据合并,或者在处理平面文件时应对数据冗余问题。这些过程不仅耗时,而且每次合并或重塑都会让我们面临广泛的数据错误风险。这也意味着我们可能会过于关注数据操作的技巧,而忽视了工作核心的概念问题。
在本篇食谱中,我们回到克利夫兰艺术博物馆的藏品数据。该数据文件至少有三个可能的分析单位——藏品项目级别、创作者级别和引用级别。JSON 允许我们在藏品中嵌套引用和创作者。(你可以在本食谱的准备工作部分查看 JSON 文件的结构。)如果不扁平化文件,这些数据无法以表格结构持久化,这一点我们在本章前面 从 API 导入更复杂的 JSON 数据 食谱中已经做过了。在本食谱中,我们将使用两种不同的方法来持久化 JSON 数据,每种方法都有其优缺点。
准备工作
我们将使用来自克利夫兰艺术博物馆的关于非裔美国艺术家作品的收藏数据。以下是 API 返回的 JSON 数据的结构。为了节省空间,数据已被简化:
{"info": { "total": 778, "parameters": {"african_american_artists": "" }},
"data": [
{
"id": 165157,
"accession_number": "2007.158",
"title": "Fulton and Nostrand",
"creation_date": "1958",
"citations": [
{
"citation": "Annual Exhibition: Sculpture, Paintings...",
"page_number": "Unpaginated, [8],[12]",
"url": null
},
{
"citation": "\"Moscow to See Modern U.S. Art,\"<em> New York...",
"page_number": "P. 60",
"url": null
}]
"creators": [
{
"description": "Jacob Lawrence (American, 1917-2000)",
"extent": null,
"qualifier": null,
"role": "artist",
"birth_year": "1917",
"death_year": "2000"
}
]
}
如何操作……
我们将使用两种不同的方法来序列化 JSON 数据:
-
加载
pandas、json、pprint、requests和msgpack库:import pandas as pd import json import pprint import requests import msgpack -
从 API 加载 JSON 数据。我已经将 JSON 输出进行了简化:
response = requests.get("https://openaccess-api.clevelandart.org/api/artworks/?african_american_artists") camcollections = json.loads(response.text) len(camcollections['data'])778pprint.pprint(camcollections['data'][0]){'accession_number': '2007.158', 'catalogue_raisonne': None, 'citations': [ {'citation': 'Annual Exhibition: Sculpture...', 'page_number': 'Unpaginated, [8],[12]', 'url': None}, {'citation': '"Moscow to See Modern U.S....', 'page_number': 'P. 60', 'url': None}] 'collection': 'American - Painting', 'creation_date': '1958', 'creators': [ {'biography': 'Jacob Lawrence (born 1917)...', 'birth_year': '1917', 'description': 'Jacob Lawrence (American...)', 'role': 'artist'}], 'type': 'Painting'} -
使用 Python 的
json库保存并重新加载 JSON 文件。
以人类可读的形式持久化 JSON 数据。从保存的文件重新加载数据,并通过从第一个collections项中检索creators数据来确认它是否有效:
with open("data/camcollections.json","w") as f:
... json.dump(camcollections, f)
...
with open("data/camcollections.json","r") as f:
... camcollections = json.load(f)
...
pprint.pprint(camcollections['data'][0]['creators'])
[{'biography': 'Jacob Lawrence (born 1917) has been a prominent artist since...'
'birth_year': '1917',
'description': 'Jacob Lawrence (American, 1917-2000)',
'role': 'artist'}]
-
使用
msgpack保存并重新加载 JSON 文件:with open("data/camcollections.msgpack", "wb") as outfile: ... packed = msgpack.packb(camcollections) ... outfile.write(packed) ...1586507with open("data/camcollections.msgpack", "rb") as data_file: ... msgbytes = data_file.read() ... camcollections = msgpack.unpackb(msgbytes) pprint.pprint(camcollections['data'][0]['creators'])[{'biography': 'Jacob Lawrence (born 1917) has been a prominent...', 'birth_year': '1917', 'death_year': '2000', 'description': 'Jacob Lawrence (American, 1917-2000)', 'role': 'artist'}]
它是如何工作的…
我们使用克利夫兰艺术博物馆的收藏 API 来获取收藏品。查询字符串中的african_american_artists标志表示我们只想要这些创作者的收藏。json.loads返回一个名为info的字典和一个名为data的字典列表。我们检查data列表的长度。这告诉我们收藏中有 778 个项目。然后,我们展示第一个收藏品以更好地了解数据的结构。(我已经简化了 JSON 输出。)
我们在步骤 3中使用 Python 的 JSON 库保存并重新加载数据。以这种方式持久化数据的优点是它保持数据以人类可读的形式。不幸的是,它有两个缺点:保存的时间比其他序列化方法要长,而且它使用更多的存储空间。
在步骤 4中,我们使用msgpack来持久化数据。这比 Python 的json库更快,而且保存的文件占用更少的空间。当然,缺点是生成的 JSON 是二进制格式,而不是基于文本的。
还有更多…
在我的工作中,我同时使用这两种方法来持久化 JSON 数据。当我处理较少的数据,并且这些数据相对静态时,我更倾向于使用人类可读的 JSON。这在前一章中的食谱案例中非常适用,我们需要为其创建值标签。
当我处理大量数据且这些数据经常变化时,我使用msgpack。msgpack文件在你想要定期获取企业数据库中关键表的快照时也非常适用。
克利夫兰艺术博物馆的收藏数据在至少一个重要方面与我们每天使用的数据相似。分析单元经常变化。在这里,我们查看的是收藏品、引文和创作者。在我们的工作中,我们可能需要同时查看学生和课程,或家庭和存款。博物馆数据的企业数据库系统可能会有单独的收藏品、引文和创作者表,最终我们需要将它们合并。合并后的文件将存在数据冗余问题,我们需要在每次改变分析单元时考虑到这些问题。
当我们修改数据清理过程,使其直接处理 JSON 或其部分内容时,我们最终消除了一个主要的错误源。在第十二章的处理非表格数据结构的类食谱中,我们用 JSON 进行了更多的数据清理,自动化数据清理与用户定义的函数、类和管道。
版本控制数据
有时候我们希望在不覆盖数据文件的先前版本的情况下持久化数据。这可以通过将时间戳或唯一标识符附加到文件名来实现。然而,还有更优雅的解决方案。一种这样的解决方案就是 Delta Lake 库,我们将在本节中进行探索。
在本节中,我们将再次使用陆地温度数据。我们将加载数据,保存到数据湖中,然后将更改过的版本保存到同一个数据湖。
准备中
本节将使用 Delta Lake 库,可以通过pip install deltalake进行安装。我们还需要os库,以便为数据湖创建目录。
如何操作...
你可以按以下方式开始使用数据并对其进行版本管理:
-
我们首先导入 Delta Lake 库。然后,我们创建一个名为
temps_lake的文件夹,用于存储我们的数据版本:import pandas as pd from deltalake.writer import write_deltalake from deltalake import DeltaTable import os os.makedirs("data/temps_lake", exist_ok=True) -
现在,让我们加载陆地温度数据:
landtemps = pd.read_csv('data/landtempssample.csv', names=['stationid','year','month','avgtemp','latitude', 'longitude','elevation','station','countryid','country'], skiprows=1, parse_dates=[['month','year']]) landtemps.shape(100000, 9) -
我们将 landtemps DataFrame 保存到数据湖中:
write_deltalake("data/temps_lake", landtemps) -
现在,让我们获取刚刚保存的数据。我们指定要获取第一个版本,尽管这并不是必要的,因为如果没有指定版本,将会获取最新版本。返回的是一个
DeltaTable类型,我们可以将其转换为 pandas DataFrame:tempsdelta = DeltaTable("data/temps_lake", version=0) type(tempsdelta)deltalake.table.DeltaTabletempsdfv1 = tempsdelta.to_pandas() tempsdfv1.shape(100000, 9) -
让我们将前 1,000 行的陆地温度数据持久化到数据湖中,而不替换现有数据。我们将
overwrite作为mode参数的值传入。这将保存一个新的数据集到数据湖,而不是替换之前的版本。overwrite参数的值在这里有点令人困惑。当我们稍后使用append参数值时,这一点会更清楚:write_deltalake("data/temps_lake", landtemps.head(1000), mode="overwrite") -
现在,让我们提取数据的最新版本。请注意,这个版本只有 1,000 行:
tempsdfv2 = DeltaTable("data/temps_lake", version=1).to_pandas() tempsdfv2.shape(1000, 9) -
如果我们改为指定
append,则会将write_deltalake第二个参数中的 DataFrame 行添加到先前版本的行中:write_deltalake("data/temps_lake", landtemps.head(1000), mode="append") tempsdfv3 = DeltaTable("data/temps_lake", version=2).to_pandas() tempsdfv3.shape(2000, 9) -
让我们确认数据湖中第一个版本的数据集仍然可以访问,并且包含我们预期的行数:
DeltaTable("data/temps_lake", version=0).to_pandas().shape(100000, 9)
工作原理...
关于覆盖(overwrite)和追加(append)的术语有点令人困惑,但如果你把覆盖看作是对先前数据集的逻辑删除,而不是物理删除,可能会更容易理解。最新版本包含所有新数据,但之前的版本仍然存储着。
总结
本章的食谱探讨了导入和准备各种形式的非表格数据,包括 JSON 和 HTML。我们介绍了用于处理大数据的 Spark,并讨论了如何持久化表格和非表格数据。我们还研究了如何为版本管理创建数据湖。下一章,我们将学习如何评估我们的数据。
加入我们在 Discord 上的社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第三章:测量你的数据
在收到新数据集的一周内,至少会有人问我们一个熟悉的问题——“那么,看起来怎么样?”这个问题并不总是以轻松的语气提出,别人通常也不太兴奋地听我们已经发现的所有警告信号。可能会有一种紧迫感,希望宣告数据已经准备好进行分析。当然,如果我们太快地签字确认,这可能会带来更大的问题;无效结果的展示、变量关系的误解,以及不得不重新做大部分分析。关键是要在探索数据的其他部分之前,理清我们需要了解的数据内容。本章中的技巧提供了判断数据是否足够清晰以开始分析的方法,即使我们不能说“看起来很好”,至少我们可以说“我很确定我已经识别出主要问题,问题在这里。”
我们的领域知识通常非常有限,或者至少不如那些创建数据的人那么熟练。即使我们对数据中的个体或事件了解不多,我们也必须迅速了解我们所面对的数据内容。很多时候(对我们中的一些人来说,几乎是大多数时候)并没有类似数据字典或代码书这样的东西来帮助我们理解数据。
快速地问问自己,在这种情况下你首先想弄清楚的事情是什么;也就是说,当你获得一些你知道很少的数据时,首先要弄清楚的可能是这样的事情:
-
数据集的行是如何被唯一标识的?(分析单元是什么?)
-
数据集中有多少行和列?
-
关键的分类变量是什么?每个值的频率是多少?
-
重要的连续变量是如何分布的?
-
变量之间可能如何相关——例如,连续变量的分布如何根据数据中的类别而变化?
-
哪些变量的值超出了预期范围,缺失值是如何分布的?
本章介绍了回答前四个问题的基本工具和策略。接下来的章节我们将讨论最后两个问题。
我必须指出,尽管数据结构已经很熟悉,但对数据的第一次处理仍然很重要。例如,当我们收到同一列名和数据类型的新月度或年度数据时,很容易产生一种错误的感觉,认为我们可以直接重新运行之前的程序;我们很难像第一次处理数据时那样保持警觉。大多数人可能都有过这种经历:我们收到结构相同的新数据,但之前问题的答案却有了实质性变化:关键类别变量的新有效值;一直允许但在几期内未曾出现的稀有值;以及客户/学生/顾客状态的意外变化。建立理解数据的例程并始终遵循它们是非常重要的,不论我们是否对数据熟悉。
本章将重点介绍以下主题:
-
初步了解你的数据
-
选择和组织列
-
选择行
-
为分类变量生成频率
-
生成连续变量的汇总统计
-
使用生成性 AI 显示描述性统计数据
技术要求
本章中的食谱需要 pandas、Numpy 和 Matplotlib 库。我使用的是 pandas 2.1.4,但代码也可以在 pandas 1.5.3 或更高版本上运行。
本章中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
初步了解你的数据
本章我们将处理两个数据集:1997 年度国家纵向青少年调查,这是美国政府进行的一项调查,跟踪了同一群体从 1997 年到 2023 年的情况;以及来自 Our World in Data 的各国 COVID-19 案例和死亡人数数据。
准备工作…
本食谱主要使用 pandas 库。我们将使用 pandas 工具更深入地了解国家纵向调查(NLS)和 COVID-19 案例数据。
数据说明
青少年 NLS 调查由美国劳工统计局进行。该调查从 1997 年开始,针对 1980 年至 1985 年之间出生的一群人,进行每年跟踪,直到 2023 年。本食谱中,我从调查中的数百个数据项中提取了关于成绩、就业、收入和对政府态度的 89 个变量。SPSS、Stata 和 SAS 的独立文件可以从仓库下载。NLS 数据可以从 www.nlsinfo.org 下载。你需要创建一个研究者账户来下载数据,但无需收费。
我们的《全球数据》提供了 COVID-19 的公共使用数据,网址是 ourworldindata.org/covid-cases。该数据集包括总病例数、死亡病例数、已做的检测、医院床位数以及人口统计数据,如中位年龄、国内生产总值和人类发展指数,后者是衡量生活水平、教育水平和寿命的综合指标。本文所用的数据集于 2024 年 3 月 3 日下载。
如何操作…
我们将初步查看 NLS 和 COVID-19 数据,包括行数、列数和数据类型:
-
导入所需的库并加载数据框:
import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97.csv") covidtotals = pd.read_csv("data/covidtotals.csv", ... parse_dates=['lastdate']) -
设置并显示
nls97数据的索引和大小。
另外,检查索引值是否唯一:
nls97.set_index("personid", inplace=True)
nls97.index
Index([100061, 100139, 100284, 100292, 100583, 100833, ...
999543, 999698, 999963],
dtype='int64', name='personid', length=8984)
nls97.shape
(8984, 88)
nls97.index.nunique()
8984
-
显示数据类型和
non-null值的计数:nls97.info()<class 'pandas.core.frame.DataFrame'> Int64Index: 8984 entries, 100061 to 999963 Data columns (total 88 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 gender 8984 non-null object 1 birthmonth 8984 non-null int64 2 birthyear 8984 non-null int64 3 highestgradecompleted 6663 non-null float64 4 maritalstatus 6672 non-null object 5 childathome 4791 non-null float64 6 childnotathome 4791 non-null float64 7 wageincome 5091 non-null float64 8 weeklyhrscomputer 6710 non-null object 9 weeklyhrstv 6711 non-null object 10 nightlyhrssleep 6706 non-null float64 11 satverbal 1406 non-null float64 12 satmath 1407 non-null float64 ... 83 colenroct15 7469 non-null object 84 colenrfeb16 7036 non-null object 85 colenroct16 6733 non-null object 86 colenrfeb17 6733 non-null object 87 colenroct17 6734 non-null object dtypes: float64(29), int64(2), object(57) memory usage: 6.1+ MB -
显示
nls97数据的前两行。
使用转置来显示更多输出:
nls97.head(2).T
personid 100061 100139
gender Female Male
birthmonth 5 9
birthyear 1980 1983
highestgradecompleted 13 12
maritalstatus Married Married
... ... ...
colenroct15 1\. Not enrolled 1\. Not enrolled
colenrfeb16 1\. Not enrolled 1\. Not enrolled
colenroct16 1\. Not enrolled 1\. Not enrolled
colenrfeb17 1\. Not enrolled 1\. Not enrolled
colenroct17 1\. Not enrolled 1\. Not enrolled
- 设置并显示 COVID-19 数据的索引和大小。
另外,检查索引值是否唯一:
covidtotals.set_index("iso_code", inplace=True)
covidtotals.index
Index(['AFG', 'ALB', 'DZA', 'ASM', 'AND', 'AGO', 'AIA', 'ATG', 'ARG',
'ARM',
...
'URY', 'UZB', 'VUT', 'VAT', 'VEN', 'VNM', 'WLF', 'YEM', 'ZMB',
'ZWE'],
dtype='object', name='iso_code', length=231)
covidtotals.shape
(231, 16)
covidtotals.index.nunique()
231
-
显示数据类型和
non-null值的计数:covidtotals.info()<class 'pandas.core.frame.DataFrame'> Index: 231 entries, AFG to ZWE Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 lastdate 231 non-null datetime64[ns] 1 location 231 non-null object 2 total_cases 231 non-null float64 3 total_deaths 231 non-null float64 4 total_cases_pm 231 non-null float64 5 total_deaths_pm 231 non-null float64 6 population 231 non-null int64 7 pop_density 209 non-null float64 8 median_age 194 non-null float64 9 gdp_per_capita 191 non-null float64 10 hosp_beds 170 non-null float64 11 vac_per_hund 13 non-null float64 12 aged_65_older 188 non-null float64 13 life_expectancy 227 non-null float64 14 hum_dev_ind 187 non-null float64 15 region 231 non-null object dtypes: datetime64ns, float64(12), int64(1), object(2) memory usage: 38.8+ KB -
显示 COVID-19 数据的两行样本:
covidtotals.sample(2, random_state=1).Tiso_code GHA NIU lastdate 2023-12-03 2023-12-31 location Ghana Niue total_cases 171,834 993 total_deaths 1,462 0 total_cases_pm 5,133 508,709 total_deaths_pm 44 0 population 33475870 1952 pop_density 127 NaN median_age 21 NaN gdp_per_capita 4,228 NaN hosp_beds 1 NaN vac_per_hund NaN NaN aged_65_older 3 NaN life_expectancy 64 74 hum_dev_ind 1 NaN region West Africa Oceania / Aus
这为我们理解数据框提供了良好的基础,包括它们的大小和列数据类型。
它是如何工作的…
在步骤 2中,我们为 nls97 数据框设置并显示了索引 personid。它是一个比默认的 pandas RangeIndex 更有意义的索引,后者本质上是从零开始的行号。通常在处理个体作为分析单元时,会有一个唯一标识符,这是一个很好的索引候选。它使得通过该标识符选择一行变得更加简单。我们不需要使用 nls97.loc[personid==1000061] 语句来获取该人的数据行,而是可以使用 nls97.loc[1000061]。我们将在下一个示例中尝试这一方法。
pandas 使得查看每列的行数和列数、数据类型、非缺失值的数量,以及数据中前几行的列值变得容易。这可以通过使用 shape 属性和调用 info 方法实现,接着使用 head 或 sample 方法。使用 head(2) 方法显示前两行,但有时从数据框中任意一行获取数据会更有帮助,这时我们可以使用 sample。 (当我们调用 sample 时,我们设置了种子(random_state=1),这样每次运行代码时都会得到相同的结果。)我们可以将对 head 或 sample 的调用链式调用 T 来转置数据框。这将反转行和列的显示顺序。当列的数量比水平方向上能显示的更多时,这个操作很有用,你可以通过转置查看所有列。通过转置行和列,我们能够看到所有的列。
nls97 DataFrame 的shape属性告诉我们该数据集有 8,984 行和 88 列非索引列。由于personid是索引,因此不包含在列数中。info方法显示,许多列的数据类型是对象型,且部分列有大量缺失值。satverbal和satmath只有大约 1,400 个有效值。
covidtotals DataFrame 的shape属性告诉我们该数据集有 231 行和 16 列,其中不包括作为索引使用的国家iso_code列(iso_code是每个国家的唯一三位数标识符)。对于我们进行的大部分分析,关键变量是total_cases、total_deaths、total_cases_pm和total_deaths_pm。total_cases和total_deaths对每个国家都有数据,但total_cases_pm和total_deaths_pm在一个国家的数据缺失。
还有更多...
我发现,在处理数据文件时,考虑索引能提醒我分析的单位。这在 NLS 数据中并不明显,因为它实际上是伪装成个人级数据的面板数据。面板数据,或称纵向数据,包含同一组个体在一段时间内的数据。在这种情况下,数据收集的时间跨度为 26 年,从 1997 年到 2023 年。调查管理员为了分析方便,通过创建某些年份响应的列(例如,大学入学情况(colenroct15至colenroct17))将数据进行了平展。这是一个相对标准的做法,但我们可能需要对某些分析进行重新整形。
我在接收任何面板数据时特别注意的是关键变量在时间上的响应下降。注意从colenroct15到colenroct17有效值的下降。到 2017 年 10 月,只有 75%的受访者提供了有效回应(6,734/8,984)。在后续分析中,必须牢记这一点,因为剩余的 6,734 名受访者可能在重要方面与整体样本 8,984 人有所不同。
另见
第一章中的一个食谱,在导入表格数据时预测数据清理问题(使用 pandas),展示了如何将 pandas DataFrame 保存为 feather 或 pickle 文件。在本章后续的食谱中,我们将查看这两个 DataFrame 的描述性统计和频率分析。
我们在第十一章中对 NLS 数据进行了整形,数据整理与重塑,恢复了其作为面板数据的实际结构。这对于生存分析等统计方法是必要的,也更接近整洁数据的理想状态。
选择和组织列
在本食谱中,我们探索了几种从 DataFrame 中选择一个或多个列的方法。我们可以通过将列名列表传递给[]括号操作符,或者使用 pandas 特定的loc和iloc数据访问器来选择列。
在清理数据或进行探索性分析或统计分析时,专注于与当前问题或分析相关的变量是非常有帮助的。这使得根据列之间的实质性或统计关系对列进行分组,或在任何时候限制我们正在研究的列变得非常重要。我们有多少次对自己说过类似 “为什么变量 A 在变量 B 为 y 时的值是 x?” 的话呢?只有当我们查看的数据量在某一时刻不超过我们当时的感知能力时,我们才能做到这一点。
准备工作……
在本食谱中,我们将继续使用国家纵向调查(NLS)数据。
如何做到……
我们将探索几种选择列的方法:
- 导入
pandas库并将 NLS 数据加载到 pandas 中。
同时,将 NLS 数据中所有对象数据类型的列转换为类别数据类型。通过使用 select_dtypes 选择对象数据类型的列,并利用 transform 及 lambda 函数将数据类型转换为 category 来实现:
import pandas as pd
import numpy as np
nls97 = pd.read_csv("data/nls97.csv")
nls97.set_index("personid", inplace=True)
nls97[nls97.select_dtypes(['object']).columns] = \
nls97.select_dtypes(['object']). \
transform(lambda x: x.astype('category'))
- 使用 pandas 的
[]括号操作符和loc以及iloc访问器选择列。
我们将一个与列名匹配的字符串传递给括号操作符,从而返回一个 pandas Series。如果我们传入一个包含该列名的单一元素的列表(nls97[['gender']]),则返回一个 DataFrame。我们还可以使用 loc 和 iloc 访问器来选择列:
analysisdemo = nls97['gender']
type(analysisdemo)
<class 'pandas.core.series.Series'>
analysisdemo = nls97[['gender']]
type(analysisdemo)
<class 'pandas.core.frame.DataFrame'>
analysisdemo = nls97.loc[:,['gender']]
type(analysisdemo)
<class 'pandas.core.frame.DataFrame'>
analysisdemo = nls97.iloc[:,[0]]
type(analysisdemo)
<class 'pandas.core.frame.DataFrame'>
- 从 pandas DataFrame 中选择多个列。
使用括号操作符和 loc 选择几个列:
analysisdemo = nls97[['gender','maritalstatus',
... 'highestgradecompleted']]
analysisdemo.shape
(8984, 3)
analysisdemo.head()
gender maritalstatus highestgradecompleted
personid
100061 Female Married 13
100139 Male Married 12
100284 Male Never-married 7
100292 Male NaN nan
100583 Male Married 13
analysisdemo = nls97.loc[:,['gender','maritalstatus',
... 'highestgradecompleted']]
analysisdemo.shape
(8984, 3)
analysisdemo.head()
gender maritalstatus highestgradecompleted
personid
100061 Female Married 13
100139 Male Married 12
100284 Male Never-married 7
100292 Male NaN nan
100583 Male Married 13
- 基于列名列表选择多个列。
如果你选择的列超过几个,最好单独创建一个列名列表。在这里,我们创建了一个用于分析的关键变量 keyvars 列表:
keyvars = ['gender','maritalstatus',
... 'highestgradecompleted','wageincome',
... 'gpaoverall','weeksworked17','colenroct17']
analysiskeys = nls97[keyvars]
analysiskeys.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 8984 entries, 100061 to 999963
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 gender 8984 non-null category
1 maritalstatus 6672 non-null category
2 highestgradecompleted 6663 non-null float64
3 wageincome 5091 non-null float64
4 gpaoverall 6004 non-null float64
5 weeksworked17 6670 non-null float64
6 colenroct17 6734 non-null category
dtypes: category(3), float64(4)
memory usage: 377.7 KB
- 通过列名过滤选择一个或多个列。
使用 filter 操作符选择所有的 weeksworked## 列:
analysiswork = nls97.filter(like="weeksworked")
analysiswork.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 8984 entries, 100061 to 999963
Data columns (total 18 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 weeksworked00 8603 non-null float64
1 weeksworked01 8564 non-null float64
2 weeksworked02 8556 non-null float64
3 weeksworked03 8490 non-null float64
4 weeksworked04 8458 non-null float64
5 weeksworked05 8403 non-null float64
6 weeksworked06 8340 non-null float64
7 weeksworked07 8272 non-null float64
8 weeksworked08 8186 non-null float64
9 weeksworked09 8146 non-null float64
10 weeksworked10 8054 non-null float64
11 weeksworked11 7968 non-null float64
12 weeksworked12 7747 non-null float64
13 weeksworked13 7680 non-null float64
14 weeksworked14 7612 non-null float64
15 weeksworked15 7389 non-null float64
16 weeksworked16 7068 non-null float64
17 weeksworked17 6670 non-null float64
dtypes: float64(18)
memory usage: 1.3 MB
- 选择所有类别数据类型的列。
使用 select_dtypes 方法按数据类型选择列:
analysiscats = nls97.select_dtypes(include=["category"])
analysiscats.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 8984 entries, 100061 to 999963
Data columns (total 57 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 gender 8984 non-null category
1 maritalstatus 6672 non-null category
2 weeklyhrscomputer 6710 non-null category
3 weeklyhrstv 6711 non-null category
4 highestdegree 8953 non-null category
...
49 colenrfeb14 7624 non-null category
50 colenroct14 7469 non-null category
51 colenrfeb15 7469 non-null category
52 colenroct15 7469 non-null category
53 colenrfeb16 7036 non-null category
54 colenroct16 6733 non-null category
55 colenrfeb17 6733 non-null category
56 colenroct17 6734 non-null category
dtypes: category(57)
memory usage: 580.0 KB
- 使用列名列表组织列。
使用列表组织 DataFrame 中的列。通过这种方式,你可以轻松地更改列的顺序或排除一些列。在这里,我们将 demoadult 列表中的列移到前面:
demo = ['gender','birthmonth','birthyear']
highschoolrecord = ['satverbal','satmath','gpaoverall',
... 'gpaenglish','gpamath','gpascience']
govresp = ['govprovidejobs','govpricecontrols',
... 'govhealthcare','govelderliving','govindhelp',
... 'govunemp','govincomediff','govcollegefinance',
... 'govdecenthousing','govprotectenvironment']
demoadult = ['highestgradecompleted','maritalstatus',
... 'childathome','childnotathome','wageincome',
... 'weeklyhrscomputer','weeklyhrstv','nightlyhrssleep',
... 'highestdegree']
weeksworked = ['weeksworked00','weeksworked01',
... 'weeksworked02','weeksworked03','weeksworked04',
...
'weeksworked14','weeksworked15','weeksworked16',
... 'weeksworked17']
colenr = ['colenrfeb97','colenroct97','colenrfeb98',
... 'colenroct98','colenrfeb99','colenroct99',
.
... 'colenrfeb15','colenroct15','colenrfeb16',... 'colenroct16','colenrfeb17','colenroct17']
-
创建新的重新组织后的 DataFrame:
nls97 = nls97[demoadult + demo + highschoolrecord + \ ... govresp + weeksworked + colenr] nls97.dtypeshighestgradecompleted float64 maritalstatus category childathome float64 childnotathome float64 wageincome float64 ... colenroct15 category colenrfeb16 category colenroct16 category colenrfeb17 category colenroct17 category Length: 88, dtype: object
上述步骤展示了如何在 pandas DataFrame 中选择列并更改列的顺序。
它是如何工作的……
[] 括号操作符和 loc 数据访问器在选择和组织列时非常方便。当传入一个列名列表时,它们都会返回一个 DataFrame,列的顺序将按照传入的列名列表进行排列。
在步骤 1中,我们使用nls97.select_dtypes(['object'])来选择数据类型为对象的列,并将其与transform和lambda函数(transform(lambda x: x.astype('category')))链式调用,将这些列转换为类别类型。我们使用loc访问器只更新数据类型为对象的列(nls97.loc[:, nls97.dtypes == 'object'])。我们将在第六章中详细讲解transform、apply(与transform类似)和lambda函数的使用,清理和探索数据操作。
我们在步骤 6中通过数据类型选择列。select_dtypes在将列传递给如describe或value_counts等方法时非常有用,尤其是当你想将分析限制为连续变量或类别变量时。
在步骤 8中,当使用括号操作符时,我们将六个不同的列表连接起来。这将demoadult中的列名移到前面,并根据这六个组重新组织所有列。现在,我们的 DataFrame 列中有了清晰的高中记录和工作周数部分。
还有更多…
我们还可以使用select_dtypes来排除数据类型。如果我们只对info结果感兴趣,我们可以将select_dtypes与info方法链式调用:
nls97.select_dtypes(exclude=["category"]).info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 8984 entries, 100061 to 999963
Data columns (total 31 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 highestgradecompleted 6663 non-null float64
1 childathome 4791 non-null float64
2 childnotathome 4 791 non-null float64
3 wageincome 5091 non-null float64
4 nightlyhrssleep 6706 non-null float64
5 birthmonth 8984 non-null int64
6 birthyear 8984 non-null int64
...
25 weeksworked12 7747 non-null float64
26 weeksworked13 7680 non-null float64
27 weeksworked14 7612 non-null float64
28 weeksworked15 7389 non-null float64
29 weeksworked16 7068 non-null float64
30 weeksworked17 6670 non-null float64
dtypes: float64(29), int64(2)
memory usage: 2.2 MB
filter操作符也可以接受正则表达式。例如,你可以返回列名中包含income的列:
nls97.filter(regex='income')
wageincome govincomediff
personid
100061 12,500 NaN
100139 120,000 NaN
100284 58,000 NaN
100292 nan NaN
100583 30,000 NaN
... ... ...
999291 35,000 NaN
999406 116,000 NaN
999543 nan NaN
999698 nan NaN
999963 50,000 NaN
另见
许多这些技巧也可以用于创建pandas的 Series 以及 DataFrame。我们在第六章中演示了这一点,清理和探索数据操作。
选择行
当我们在衡量数据并回答问题“它看起来怎么样?”时,我们不断地放大和缩小,查看汇总数据和特定行。但也有一些只有在中等缩放级别下才能明显看到的数据问题,只有当我们查看某些行的子集时,这些问题才会浮现。本篇食谱展示了如何使用pandas工具在数据的子集上检测数据问题。
准备就绪...
在本篇食谱中,我们将继续使用 NLS 数据。
如何操作...
我们将讨论几种选择pandas DataFrame 中行的技巧:
-
导入
pandas和numpy,并加载nls97数据:import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97.csv") nls97.set_index("personid", inplace=True) -
使用切片从第 1001 行开始,到第 1004 行结束。
nls97[1000:1004]选择从左侧冒号指示的整数所在的行(此例中是1000)开始,到右侧冒号指示的整数所在的行(此例中是1004)之前的行。由于基于零索引,1000行实际上是第 1001 行。每一行都作为列出现在输出中,因为我们对结果 DataFrame 进行了转置:
nls97[1000:1004].T
personid 195884 195891 195970 195996
gender Male Male Female Female
birthmonth 12 9 3 9
birthyear 1981 1980 1982 1980
highestgradecompleted NaN 12 17 NaN
maritalstatus NaN Never-married Never-married NaN
... ... ... ... ...colenroct15 NaN 1\. Not enrolled 1\. Not enrolled NaN
colenrfeb16 NaN 1\. Not enrolled 1\. Not enrolled NaN
colenroct16 NaN 1\. Not enrolled 1\. Not enrolled NaN
colenrfeb17 NaN 1\. Not enrolled 1\. Not enrolled NaN
colenroct17 NaN 1\. Not enrolled 1\. Not enrolled NaN
- 使用切片从第 1001 行开始,到第 1004 行结束,跳过每隔一行。
第二个冒号后的整数(在这里是2)表示步长。当步长被省略时,它默认是 1。注意,设置步长为2时,我们跳过了每隔一行的行:
nls97[1000:1004:2].T
personid 195884 195970
gender Male Female
birthmonth 12 3
birthyear 1981 1982
highestgradecompleted NaN 17
maritalstatus NaN Never-married
... ... ...
colenroct15 NaN 1\. Not enrolled
colenrfeb16 NaN 1\. Not enrolled
colenroct16 NaN 1\. Not enrolled
colenrfeb17 NaN 1\. Not enrolled
colenroct17 NaN 1\. Not enrolled
- 使用
[]操作符切片选择前三行。
在[:3]中不提供冒号左侧的值,意味着我们告诉操作符从 DataFrame 的起始位置获取行:
nls97[:3].T
personid 100061 100139 100284
gender Female Male Male
birthmonth 5 9 11
birthyear 1980 1983 1984
... ... ... ...
colenroct15 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
请注意,nls97[:3]返回的 DataFrame 与nls97.head(3)返回的是相同的。
-
使用
[]操作符切片选择最后三行:nls97[-3:].Tpersonid 999543 999698 999963 gender Female Female Female birthmonth 8 5 9 birthyear 1984 1983 1982 ... ... ... ... colenroct15 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled colenrfeb16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled colenroct16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled colenrfeb17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled colenroct17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
请注意,nls97[-3:]返回的 DataFrame 与nls97.tail(3)返回的是相同的。
- 使用
loc数据访问器选择几行。
使用loc访问器按index标签选择。我们可以传递一个索引标签的列表,或者指定一个标签范围。(回顾一下,我们已经将personid设置为索引。)请注意,nls97.loc[[195884,195891,195970]]和nls97.loc[195884:195970]返回的是相同的 DataFrame,因为这些行是连续的。
nls97.loc[[195884,195891,195970]].T
personid 195884 195891 195970
gender Male Male Female
birthmonth 12 9 3
birthyear 1981 1980 1982
highestgradecompleted NaN 12 17
maritalstatus NaN Never-married Never-married
... ... ... ...
colenroct15 NaN 1\. Not enrolled 1\. Not enrolled
colenrfeb16 NaN 1\. Not enrolled 1\. Not enrolled
colenroct16 NaN 1\. Not enrolled 1\. Not enrolled
colenrfeb17 NaN 1\. Not enrolled 1\. Not enrolled
colenroct17 NaN 1\. Not enrolled 1\. Not enrolled
nls97.loc[195884:195970].T
personid 195884 195891 195970
gender Male Male Female
birthmonth 12 9 3
birthyear 1981 1980 1982
highestgradecompleted NaN 12 17
maritalstatus NaN Never-married Never-married
... ... ... ...
colenroct15 NaN 1\. Not enrolled 1\. Not enrolled
colenrfeb16 NaN 1\. Not enrolled 1\. Not enrolled
colenroct16 NaN 1\. Not enrolled 1\. Not enrolled
colenrfeb17 NaN 1\. Not enrolled 1\. Not enrolled
colenroct17 NaN 1\. Not enrolled 1\. Not enrolled
- 使用
iloc数据访问器从 DataFrame 的开始位置选择一行。
iloc与loc的不同之处在于,它接受一组行位置的整数,而不是索引标签。因此,它的工作方式类似于括号操作符切片。在这一步中,我们首先传递一个包含值0的单元素列表。这将返回一个包含第一行的 DataFrame:
nls97.iloc[[0]].T
personid 100061
gender Female
birthmonth 5
birthyear 1980
highestgradecompleted 13
maritalstatus Married
... ...
colenroct15 1\. Not enrolled
colenrfeb16 1\. Not enrolled
colenroct16 1\. Not enrolled
colenrfeb17 1\. Not enrolled
colenroct17 1\. Not enrolled
- 使用
iloc数据访问器选择数据框的几行。
我们传递一个包含三个元素的列表[0,1,2],以返回nls97的前三行的 DataFrame:
nls97.iloc[[0,1,2]].T
personid 100061 100139 100284
gender Female Male Male
birthmonth 5 9 11
birthyear 1980 1983 1984
... ... ... ...
colenroct15 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
如果我们将[0:3]传递给访问器,结果是一样的。
- 使用
iloc数据访问器从 DataFrame 的末尾选择几行。
使用nls97.iloc[[-3,-2,-1]]来获取 DataFrame 的最后三行:
nls97.iloc[[-3,-2,-1]].T
personid 999543 999698 999963
gender Female Female Female
birthmonth 8 5 9
birthyear 1984 1983 1982
... ... ... ...
colenroct15 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct16 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenrfeb17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
colenroct17 1\. Not enrolled 1\. Not enrolled 1\. Not enrolled
使用nls97.iloc[-3:]也能得到相同的结果。通过不在[-3:]的冒号右侧提供值,我们告诉访问器获取从倒数第三行到 DataFrame 结尾的所有行。
- 使用布尔索引按条件选择多行。
创建一个只包含睡眠时间极少的个体的 DataFrame。约有 5%的调查对象每晚睡眠时间为 4 小时或更少,调查共有 6,706 人回答了该问题。通过nls97.nightlyhrssleep<=4来测试哪些人每晚睡眠 4 小时或更少,这将生成一个True和False值的 pandas 系列,我们将其赋值给sleepcheckbool。将该系列传递给loc访问器以创建一个lowsleep DataFrame。lowsleep大约有我们预期的行数。我们不需要额外的步骤来将布尔系列赋值给变量。这里这样做仅仅是为了说明:
nls97.nightlyhrssleep.quantile(0.05)
4.0
nls97.nightlyhrssleep.count()
6706
sleepcheckbool = nls97.nightlyhrssleep<=4
sleepcheckbool
personid
100061 False
100139 False
100284 False
100292 False
100583 False
...
999291 False
999406 False
999543 False
999698 False
999963 False
Name: nightlyhrssleep, Length: 8984, dtype: bool
lowsleep = nls97.loc[sleepcheckbool]
lowsleep.shape
(364, 88)
- 基于多个条件选择行。
可能有些没有得到充足睡眠的人,也有不少孩子和他们一起生活。使用describe来了解那些有lowsleep的人群中,孩子数量的分布情况。大约四分之一的人有三个或更多孩子。创建一个新的 DataFrame,包含那些nightlyhrssleep为 4 小时或更少,且家中有 3 个或更多孩子的个体。&是 pandas 中的逻辑与运算符,表示只有当两个条件都满足时,行才会被选中:
lowsleep.childathome.describe()
count 293.00
mean 1.79
std 1.40
min 0.00
25% 1.00
50% 2.00
75% 3.00
max 9.00
lowsleep3pluschildren = nls97.loc[(nls97.nightlyhrssleep<=4) & (nls97.childathome>=3)]
lowsleep3pluschildren.shape
(82, 88)
如果我们从lowsleep DataFrame 开始,结果是一样的 – lowsleep3pluschildren = lowsleep.loc[lowsleep.childathome>=3] – 但那样我们就无法展示多个条件的测试。
- 根据多个条件选择行和列。
将条件传递给loc访问器以选择行。还可以传递一个列名的列表来选择列:
lowsleep3pluschildren = nls97.loc[(nls97.nightlyhrssleep<=4) & (nls97.childathome>=3), ['nightlyhrssleep','childathome']]
lowsleep3pluschildren
nightlyhrssleep childathome
personid
119754 4 4
141531 4 5
152706 4 4
156823 1 3
158355 4 4
... ... ...
905774 4 3
907315 4 3
955166 3 3
956100 4 6
991756 4 3
上述步骤展示了在 pandas 中选择行的关键技巧。
它是如何工作的…
在步骤 2到步骤 5中,我们使用了[]方括号运算符来做标准的类似 Python 的切片操作,选择行。这个运算符使得我们可以根据列出或范围的值,轻松选择行。切片表示法的形式为[start:end:step],其中如果没有提供step值,则默认假定为1。当start使用负数时,它表示从 DataFrame 的末尾开始算起的行数。
loc访问器,在步骤 6中使用,根据行索引标签选择行。由于personid是 DataFrame 的索引,我们可以将一个或多个personid值的列表传递给loc访问器,以获得具有这些索引标签的行的 DataFrame。我们也可以将一系列索引标签传递给访问器,这将返回一个包含所有行的 DataFrame,这些行的索引标签介于冒号左边和右边的标签之间(包含这两个标签);因此,nls97.loc[195884:195970]将返回personid在195884和195970之间的行的 DataFrame,包括这两个值。
iloc访问器的工作方式与方括号运算符非常相似。这一点在步骤 7到步骤 9中有所体现。我们可以传递整数列表或使用切片表示法传递一个范围。
pandas 最有价值的功能之一是布尔索引。它使得条件选择行变得非常简单。我们在步骤 10中看到了这一点。一个测试返回一个布尔序列。loc访问器选择所有测试为True的行。实际上,我们并不需要将布尔数据序列赋值给一个变量,再将该变量传递给loc运算符。我们本可以直接将测试条件传递给loc访问器,如nls97.loc[nls97.nightlyhrssleep<=4]。
我们应该仔细查看如何在 步骤 11 中使用 loc 访问器选择行。在 nls97.loc[(nls97.nightlyhrssleep<=4) & (nls97.childathome>=3)] 中,每个条件都被括号括起来。如果省略括号,将会产生错误。& 操作符在标准 Python 中等同于 and,意味着 两个 条件都必须为 True,对应的行才会被选择。如果我们想选择 任一 条件为 True 的行,则会使用 | 来代替 &。
最后,步骤 12 演示了如何在一次调用 loc 访问器时同时选择行和列。选择行的条件出现在逗号前,选择的列出现在逗号后,如以下语句所示:
nls97.loc[(nls97.nightlyhrssleep<=4) & (nls97.childathome>=3), ['nightlyhrssleep','childathome']]
这将返回 nightlyhrssleep 和 childathome 列的所有行,其中每行的 nightlyhrssleep 小于或等于 4,且 childathome 大于或等于 3。
还有更多…
在本食谱中,我们使用了三种不同的工具从 pandas DataFrame 中选择行:[] 方括号操作符、以及两个 pandas 特有的访问器,loc 和 iloc。如果你是 pandas 新手,这可能有点混淆,但只需几个月的使用,你就会明白在不同情况下该使用哪个工具。如果你是带着一定的 Python 和 NumPy 经验来学习 pandas 的,你可能会发现 [] 操作符最为熟悉。然而,pandas 文档建议在生产代码中不要使用 [] 操作符。我已经习惯了仅在从 DataFrame 中选择列时使用该操作符。选择行时,我使用 loc 访问器进行布尔索引或按索引标签选择,使用 iloc 访问器按行号选择行。由于我的工作流程中使用了大量布尔索引,我比其他方法使用 loc 要多得多。
另见
紧接着前面的食谱,选择和组织列,对列选择有更详细的讨论。
为分类变量生成频率
多年前,一位经验丰富的研究人员对我说,“我们要找到的 90%的内容,都会在频率分布中看到。” 这句话一直深深印在我心中。通过对 DataFrame 做更多的一维和二维频率分布(交叉表),我对其理解也越来越深刻。在本食谱中,我们将进行一维分布,之后的食谱将介绍交叉表。
准备好…
我们将继续处理 NLS 数据集。我们还会使用过滤方法进行大量的列选择。虽然不必重新审视本章关于列选择的食谱,但它可能会有所帮助。
如何操作…
我们使用 pandas 工具生成频率,尤其是非常方便的 value_counts:
- 加载
pandas库和nls97文件。
此外,将对象数据类型的列转换为类别数据类型:
import pandas as pd
nls97 = pd.read_csv("data/nls97.csv")
nls97.set_index("personid", inplace=True)
nls97[nls97.select_dtypes(['object']).columns] = \
nls97.select_dtypes(['object']). \
transform(lambda x: x.astype('category'))
- 显示类别数据类型列的名称,并检查缺失值的数量。
请注意,gender 列没有缺失值,highestdegree 只有少数缺失值,但 maritalstatus 和其他列有许多缺失值:
catcols = nls97.select_dtypes(include=["category"]).columns
nls97[catcols].isnull().sum()
gender 0
maritalstatus 2312
weeklyhrscomputer 2274
weeklyhrstv 2273
highestdegree 31
...
colenroct15 1515
colenrfeb16 1948
colenroct16 2251
colenrfeb17 2251
colenroct17 2250
Length: 57, dtype: int64
-
显示婚姻状况的频率:
nls97.maritalstatus.value_counts()Married 3066 Never-married 2766 Divorced 663 Separated 154 Widowed 23 Name: maritalstatus, dtype: int64 -
关闭按频率排序:
nls97.maritalstatus.value_counts(sort=False)Divorced 663 Married 3066 Never-married 2766 Separated 154 Widowed 23 Name: maritalstatus, dtype: int64 -
显示百分比而非计数:
nls97.maritalstatus.value_counts(sort=False, normalize=True)Divorced 0.10 Married 0.46 Never-married 0.41 Separated 0.02 Widowed 0.00 Name: maritalstatus, dtype: float64 -
显示所有政府责任列的百分比。
仅筛选出政府责任列的 DataFrame,然后使用 apply 在该 DataFrame 的所有列上运行 value_counts:
nls97.filter(like="gov").apply(pd.Series.value_counts, normalize=True)
govprovidejobs govpricecontrols ... \
1\. Definitely 0.25 0.54 ...
2\. Probably 0.34 0.33 ...
3\. Probably not 0.25 0.09 ...
4\. Definitely not 0.16 0.04 ...
govdecenthousing govprotectenvironment
1\. Definitely 0.44 0.67
2\. Probably 0.43 0.29
3\. Probably not 0.10 0.03
4\. Definitely not 0.02 0.02
- 找出所有政府责任列中已婚人数的百分比。
做我们在 第 6 步 中所做的,但首先选择仅 maritalstatus 为 Married 的行:
nls97[nls97.maritalstatus=="Married"].\
... filter(like="gov").\
... apply(pd.Series.value_counts, normalize=True)
govprovidejobs govpricecontrols ... \
1\. Definitely 0.17 0.46 ...
2\. Probably 0.33 0.38 ...
3\. Probably not 0.31 0.11 ...
4\. Definitely not 0.18 0.05 ...
govdecenthousing govprotectenvironment
1\. Definitely 0.36 0.64
2\. Probably 0.49 0.31
3\. Probably not 0.12 0.03
4\. Definitely not 0.03 0.01
- 找出所有类别列的频率和百分比。
首先,打开一个文件以写出频率:
for col in nls97.\
select_dtypes(include=["category"]):
print(col, "----------------------",
"frequencies",
nls97[col].value_counts(sort=False),
"percentages",
nls97[col].value_counts(normalize=True,
sort=False),
sep="\n\n", end="\n\n\n", file=freqout)
freqout.close()
这会生成一个文件,其开头如下所示:
gender
----------------------
frequencies
Female 4385
Male 4599
Name: gender, dtype: int64
percentages
Female 0.49
Male 0.51
Name: gender, dtype: float64
正如这些步骤所展示的,value_counts 在我们需要为一个或多个 DataFrame 列生成频率时非常有用。
它是如何工作的……
nls97 DataFrame 中大部分列(88 列中的 57 列)具有对象数据类型。如果我们处理的数据在逻辑上是类别型的,但在 pandas 中没有类别数据类型,将其转换为类别类型是有充分理由的。这不仅节省内存,还使得数据清理变得更简单,就像我们在本教程中看到的那样。
本教程的重点是 value_counts 方法。它可以为 Series 生成频率,就像我们使用 nls97.maritalstatus.value_counts 所做的那样。它也可以在整个 DataFrame 上运行,就像我们使用 nls97.filter(like="gov").apply(pd.Series.value_counts, normalize=True) 所做的那样。我们首先创建一个只包含政府责任列的 DataFrame,然后将生成的 DataFrame 传递给 value_counts 与 apply 一起使用。
你可能注意到在 第 7 步 中,我将链式操作拆分成了多行,以便更易于阅读。关于何时拆分并没有严格的规则。我通常会在链式操作涉及三次或更多操作时尝试这样做。
在 第 8 步 中,我们遍历了所有类别数据类型的列:for col in nls97.select_dtypes(include=["category"])。对于每一列,我们运行了 value_counts 获取频率,再次运行 value_counts 获取百分比。我们使用 print 函数生成换行符,使输出更易读。所有这些都保存在 views 子文件夹中的 frequencies.txt 文件中。我发现保留一组单向频率数据是非常方便的,便于在对类别变量进行任何工作之前进行检查。第 8 步 就是实现这一目标的。
还有更多……
频率分布可能是发现类别数据潜在问题最重要的统计工具。我们在本教程中生成的单向频率是进一步洞察的良好基础。
然而,我们通常只有在检查分类变量与其他变量(无论是分类的还是连续的)之间的关系时,才能发现问题。尽管在这个例子中我们没有进行双向频率分析,但我们确实在第 7 步中开始了拆分数据进行调查的过程。在那一步中,我们查看了已婚个体的政府责任回应,并发现这些回应与整体样本的回应有所不同。
这引发了我们需要探索的几个关于数据的问题。婚姻状况是否会影响回应率,这是否会对政府责任变量的分布产生影响?在得出结论之前,我们还需要谨慎考虑潜在的混杂变量。已婚的受访者是否更可能年纪较大或拥有更多孩子,这些因素是否对他们的政府责任回答更为重要?
我使用婚姻状况变量作为示例,说明生成单向频率(如本例中的频率)可能会引发的查询问题。在遇到类似问题时,准备一些双变量分析(如相关矩阵、交叉表或一些散点图)总是明智的。我们将在接下来的两章中生成这些分析。
为连续变量生成总结统计数据
pandas 提供了大量工具,我们可以利用它们了解连续变量的分布。我们将在本例中重点展示describe的强大功能,并演示直方图在可视化变量分布中的作用。
在对连续变量进行任何分析之前,理解它的分布非常重要——它的集中趋势、分布范围以及偏态性。这些理解大大帮助我们识别离群值和异常值。然而,这本身也是至关重要的信息。我认为并不夸张地说,如果我们能够很好地理解某个变量的分布,我们就能很好地理解它,而没有这种理解的任何解释都会不完整或有缺陷。
准备就绪……
在这个例子中,我们将使用 COVID-19 总数数据。你需要Matplotlib来运行此代码。如果你的机器上尚未安装它,可以通过在终端输入pip install matplotlib来安装。
如何做……
让我们看一下几个关键连续变量的分布:
-
导入
pandas、numpy和matplotlib,并加载 COVID-19 病例总数数据:import pandas as pd import numpy as np import matplotlib.pyplot as plt covidtotals = pd.read_csv("data/covidtotals.csv", ... parse_dates=['lastdate']) covidtotals.set_index("iso_code", inplace=True) -
让我们回顾一下数据的结构:
covidtotals.shape(231, 16)covidtotals.sample(1, random_state=1).Tiso_code GHA \ lastdate 2023-12-03 00:00:00 location Ghana total_cases 171,834.00 total_deaths 1,462.00 total_cases_pm 5,133.07 total_deaths_pm 43.67 population 33475870 pop_density 126.72 median_age 21.10 gdp_per_capita 4,227.63 hosp_beds 0.90 vac_per_hund NaN aged_65_older 3.38 life_expectancy 64.07 hum_dev_ind 0.61 region West Africacovidtotals.dtypeslastdate datetime64[ns] location object total_cases float64 total_deaths float64 total_cases_pm float64 total_deaths_pm float64 population int64 pop_density float64 median_age float64 gdp_per_capita float64 hosp_beds float64 vac_per_hund float64 aged_65_older float64 life_expectancy float64 hum_dev_ind float64 region object dtype: object -
获取 COVID-19 总数列的描述性统计:
totvars = ['total_cases', 'total_deaths','total_cases_pm', 'total_deaths_pm'] covidtotals[totvars].describe()total_cases total_deaths total_cases_pm total_deaths_pm count 231.0 231.0 231.0 231.0 mean 3,351,598.6 30,214.2 206,177.8 1,261.8 std 11,483,211.8 104,778.9 203,858.1 1,315.0 min 4.0 0.0 354.5 0.0 25% 25,671.5 177.5 21,821.9 141.2 50% 191,496.0 1,937.0 133,946.3 827.0 75% 1,294,286.0 14,150.0 345,689.8 1,997.5 max 103,436,829.0 1,127,152.0 763,475.4 6,507.7 -
更仔细地查看病例和死亡列的值分布。
使用 NumPy 的arange方法将从 0 到 1.0 的浮动数列表传递给 DataFrame 的quantile方法:
covidtotals[totvars].\
quantile(np.arange(0.0, 1.1, 0.1))
total_cases total_deaths total_cases_pm \
0.0 4.0 0.0 354.5
0.1 8,359.0 31.0 3,138.6
0.2 17,181.0 126.0 10,885.7
0.3 38,008.0 294.0 35,834.6
0.4 74,129.0 844.0 86,126.2
0.5 191,496.0 1,937.0 133,946.3
0.6 472,755.0 4,384.0 220,429.4
0.7 1,041,111.0 9,646.0 293,737.4
0.8 1,877,065.0 21,218.0 416,608.1
0.9 5,641,992.0 62,288.0 512,388.4
1.0 103,436,829.0 1,127,152.0 763,475.4
total_deaths_pm
0.0 0.0
0.1 32.9
0.2 105.3
0.3 210.5
0.4 498.8
0.5 827.0
0.6 1,251.3
0.7 1,697.6
0.8 2,271.7
0.9 3,155.9
1.0 6,507.7
-
查看总病例的分布:
plt.hist(covidtotals['total_cases']/1000, bins=12) plt.title("Total COVID-19 Cases (in thousands)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()

图 3.1:COVID-19 总病例数的图表
前面的步骤展示了describe方法和 Matplotlib 的hist方法的使用,这些是处理连续变量时的基本工具。
它是如何工作的…
我们在步骤 3中使用了describe方法来检查一些汇总统计数据和关键变量的分布。当均值和中位数(50^(th)百分位的值)差异巨大时,这通常是一个警示信号。病例和死亡数严重偏向右侧(这一点通过均值远高于中位数得以体现)。这提示我们在上端存在离群值。即使调整了人口规模,total_cases_pm和total_deaths_pm依然表现出相同的偏态。我们将在下一章进行更多关于离群值的分析。
步骤 4中的更详细的百分位数据进一步支持了这种偏态感。例如,病例和死亡数在 90^(th)-百分位和 100^(th)-百分位之间的差距相当大。这些都是表明我们处理的数据不是正态分布的良好初步指标。即使这不是由于错误导致的,这对于我们未来进行的统计检验也是至关重要的。当被问到“数据看起来怎么样?”时,我们首先想说的就是这些内容。
总病例数的直方图确认,大部分数据分布在 0 到 100,000 之间,且有一些离群值和 1 个极端离群值。从视觉效果来看,分布更接近对数正态分布,而非正态分布。对数正态分布具有更胖的尾部,并且没有负值。
另见
我们将在下一章深入探讨离群值和意外值。在第五章中,我们将做更多关于可视化的工作,使用可视化识别意外值。
使用生成式 AI 显示描述性统计数据
生成式 AI 工具为数据科学家提供了一个极好的机会,以简化数据清洗和探索工作流程。尤其是大型语言模型,具有使这项工作变得更容易、更直观的潜力。通过使用这些工具,我们可以按条件选择行和列,生成汇总统计,并绘制变量图。
将生成式 AI 工具引入数据探索的一个简单方法是使用 PandasAI。PandasAI 利用 OpenAI 的 API 将自然语言查询转换为 pandas 能够理解的数据选择和操作。截至 2023 年 7 月,OpenAI 是唯一可以与 PandasAI 配合使用的大型语言模型 API,尽管该库的开发者预计将来会添加其他 API。
我们可以使用 PandasAI 大幅减少编写代码的行数,以便生成我们在本章中创建的某些表格和可视化。此处的步骤展示了如何实现这一点。
准备工作…
要运行本食谱中的代码,你需要安装 PandasAI。你可以使用pip install pandasai来安装。我们将再次使用 COVID-19 数据,该数据可在 GitHub 仓库中找到,代码也是如此。
你还需要一个来自 OpenAI 的 API 密钥。你可以在platform.openai.com获取一个。你需要设置一个帐户,然后点击右上角的个人资料,再点击查看 API 密钥。
如何操作…
我们在以下步骤中创建一个 PandasAI 实例,并使用它查看 COVID-19 数据:
-
我们首先导入
pandas和PandasAI库:import pandas as pd from pandasai.llm.openai import OpenAI from pandasai import SmartDataframe -
接下来,我们加载 COVID-19 数据并实例化一个
PandasAI SmartDataframe对象。SmartDataframe对象将允许我们使用自然语言处理数据:covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=['lastdate']) covidtotals.set_index("iso_code", inplace=True) llm = OpenAI(api_token="Your API Key") covidtotalssdf = SmartDataframe(covidtotals, config={"llm": llm}) -
让我们看看 COVID-19 数据的结构。我们可以通过向 SmartDataframe 的
chat方法传递自然语言指令来做到这一点:covidtotalssdf.chat("Show me some information about the data")<class 'pandas.core.frame.DataFrame'> RangeIndex: 231 entries, 0 to 230 Data columns (total 17 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 iso_code 231 non-null object 1 lastdate 231 non-null datetime64[ns] 2 location 231 non-null object 3 total_cases 231 non-null float64 4 total_deaths 231 non-null float64 5 total_cases_pm 231 non-null float64 6 total_deaths_pm 231 non-null float64 7 population 231 non-null int64 8 pop_density 209 non-null float64 9 median_age 194 non-null float64 10 gdp_per_capita 191 non-null float64 11 hosp_beds 170 non-null float64 12 vac_per_hund 13 non-null float64 13 aged_65_older 188 non-null float64 14 life_expectancy 227 non-null float64 15 hum_dev_ind 187 non-null float64 16 region 231 non-null object dtypes: datetime64ns, float64(12), int64(1), object(3) memory usage: 30.8+ KB -
也可以轻松查看前几行数据:
covidtotalssdf.chat("Show first five rows.")lastdate location total_cases ... \ iso_code ... AFG 2024-02-04 Afghanistan 231,539 ... ALB 2024-01-28 Albania 334,863 ... DZA 2023-12-03 Algeria 272,010 ... ASM 2023-09-17 American Samoa 8,359 ... AND 2023-05-07 Andorra 48,015 ... life_expectancy hum_dev_ind region iso_code AFG 65 1 South Asia ALB 79 1 Eastern Europe DZA 77 1 North Africa ASM 74 NaN Oceania / Aus AND 84 1 Western Europe [5 rows x 16 columns] -
我们可以查看哪些地点(国家)有最高的总病例数:
covidtotalssdf.chat("Show total cases for locations with the five most total cases.")location total_cases iso_code USA United States 103,436,829 CHN China 99,329,249 IND India 45,026,139 FRA France 38,997,490 DEU Germany 38,437,756 -
我们还可以显示每百万最高总病例数,并且展示其他列数据。
注意,我们不需要在total_cases_pm或total_deaths_pm中添加下划线。chat方法会自动识别我们指的就是这些内容:
covidtotalssdf.chat("Show total cases pm, total deaths pm, and location for locations with the 10 highest total cases pm.")
total_cases_pm total_deaths_pm location
iso_code
BRN 763,475 396 Brunei
CYP 760,161 1,523 Cyprus
SMR 750,727 3,740 San Marino
AUT 680,263 2,521 Austria
KOR 667,207 693 South Korea
FRO 652,484 527 Faeroe Islands
SVN 639,408 4,697 Slovenia
GIB 628,883 3,458 Gibraltar
MTQ 626,793 3,004 Martinique
LUX 603,439 1,544 Luxembourg
-
我们还可以创建一个包含选定列的
SmartDataframe。当我们在这一步使用chat方法时,它会自动识别应该返回一个SmartDataframe:covidtotalsabb = covidtotalssdf.chat("Select total cases pm, total deaths pm, and location.") type(covidtotalsabb)pandasai.smart_dataframe.SmartDataframecovidtotalsabbtotal_cases_pm total_deaths_pm location iso_code AFG 5,630 194 Afghanistan ALB 117,813 1,268 Albania DZA 6,058 153 Algeria ASM 188,712 768 American Samoa AND 601,368 1,991 Andorra ... ... ... VNM 118,387 440 Vietnam WLF 306,140 690 Wallis and Futuna YEM 354 64 Yemen ZMB 17,450 203 Zambia ZWE 16,315 352 Zimbabwe [231 rows x 3 columns] -
我们不需要在传递给 PandasAI 的语言中非常精确。我们本可以写
Get或Grab,而不是Select:covidtotalsabb = covidtotalssdf.chat("Grab total cases pm, total deaths pm, and location.") covidtotalsabbtotal_cases_pm total_deaths_pm location iso_code AFG 5,630 194 Afghanistan ALB 117,813 1,268 Albania DZA 6,058 153 Algeria ASM 188,712 768 American Samoa AND 601,368 1,991 Andorra ... ... ... VNM 118,387 440 Vietnam WLF 306,140 690 Wallis and Futuna YEM 354 64 Yemen ZMB 17,450 203 Zambia ZWE 16,315 352 Zimbabwe [231 rows x 3 columns] -
我们可以通过汇总统计选择行。例如,我们可以选择那些每百万总病例数高于第 95 百分位的行。请注意,这可能需要一些时间在你的机器上运行:
covidtotalssdf.chat("Show total cases pm and location where total cases pm greater than 95th percentile.")location total_cases_pm iso_code AND Andorra 601,368 AUT Austria 680,263 BRN Brunei 763,475 CYP Cyprus 760,161 FRO Faeroe Islands 652,484 FRA France 603,428 GIB Gibraltar 628,883 LUX Luxembourg 603,439 MTQ Martinique 626,793 SMR San Marino 750,727 SVN Slovenia 639,408 KOR South Korea 667,207 -
我们可以通过请求它们的分布来查看连续变量是如何分布的:
covidtotalssdf.chat("Summarize values for total cases pm and total deaths pm.").Ttotal_cases_pm total_deaths_pm count 231 231 mean 206,178 1,262 std 203,858 1,315 min 354 0 25% 21,822 141 50% 133,946 827 75% 345,690 1,998 max 763,475 6,508 -
我们还可以获取各组的总数。让我们按地区获取总病例数和死亡数:
covidtotalssdf.chat("Show sum of total cases and total deaths by region.")region total_cases total_deaths 0 Caribbean 4,258,031 32,584 1 Central Africa 640,579 8,128 2 Central America 4,285,644 54,500 3 Central Asia 3,070,921 40,365 4 East Africa 2,186,107 28,519 5 East Asia 205,704,775 604,355 6 Eastern Europe 62,360,832 969,011 7 North Africa 3,727,507 83,872 8 North America 115,917,286 1,516,239 9 Oceania / Aus 14,741,706 31,730 10 South America 68,751,186 1,354,440 11 South Asia 51,507,806 632,374 12 Southern Africa 5,627,277 126,376 13 West Africa 953,756 12,184 14 West Asia 41,080,675 360,258 15 Western Europe 189,405,185 1,124,545 -
我们可以轻松生成关于 COVID-19 数据的图表:
covidtotalssdf.chat("Plot the total_cases_pm column data distribution")
这段代码生成以下图表:

图 3.2:每百万总病例数的分布
-
我们还可以生成散点图。让我们看看每百万总病例数与每百万总死亡数的关系:
covidtotalssdf.chat( "Plot total cases pm values against total deaths pm values")
这段代码生成以下图表:

图 3.3:每百万总病例数与每百万总死亡数的散点图
-
我们可以指定要使用哪个绘图工具。这里使用
regplot可能有助于更好地理解病例数与死亡数之间的关系:covidtotalssdf.chat("Use regplot to show total deaths pm against total cases pm")
这段代码生成以下图表:

图 3.4:回归图
-
对于病例或死亡的极端值,会使得在大部分范围内很难看到两者之间的关系。让我们也请求 PandasAI 去除这些极端值:
covidtotalssdf.chat("Use regplot to show total deaths pm against total cases pm without extreme values")
这将生成以下图表:

图 3.5:去除极端值后的回归图
这次移除了每百万死亡超过 350 和每百万病例超过 20,000 的数据。这样可以更容易地看到数据大部分部分的关系趋势。我们将在第五章,使用可视化方法识别意外值中与regplot和许多其他绘图工具进行更多的操作。
它是如何工作的……
这些示例展示了使用 PandasAI 的直观性。像 PandasAI 这样的生成式 AI 工具有潜力通过使我们几乎能像想出新的分析一样快速与数据互动,从而提升我们的探索性工作。我们只需将自然语言查询传递给 PandasAI 对象,就能获得我们想要的结果。
我们传递的查询并不是命令。我们可以使用任何能表达我们意图的语言。例如,回想一下,我们能够使用select、get,甚至grab来选择列。OpenAI 的大型语言模型通常非常擅长理解我们的意思。
查看 PandasAI 日志文件,以查看你传递给 SmartDataframe chat方法时生成的代码是个好主意。pandasai.log 文件将位于与你的 Python 代码同一文件夹中。
一个帮助我们更快速从问题到答案过渡的工具可以提高我们的思维和分析能力。如果你还没有尝试过这个方法,值得进行尝试,即使你已经有了查看数据的成熟流程。
另见
PandasAI 的 GitHub 仓库是获取更多信息并了解库更新的好地方。你可以通过以下链接访问:github.com/gventuri/pandas-ai。我们将在本书的各个配方中继续使用 PandasAI 库。
摘要
本章涵盖了将原始数据转换为 pandas DataFrame 后的关键步骤。我们探讨了检查数据结构的技巧,包括行数、列数和数据类型。我们还学习了如何生成类别变量的频率,并开始研究一个变量的值如何随着另一个变量的值而变化。最后,我们了解了如何检查连续变量的分布,包括使用均值、最小值和最大值等样本统计量,并通过绘图展示。这为下一章的主题做了准备,在那一章中我们将使用技术来识别数据中的异常值。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第四章:在数据子集中识别异常值
异常值和意外值不一定是错误。它们通常不是。个体和事件是复杂的,常常出乎分析师的意料。有些人确实有 7 英尺 4 英寸的身高,也有些人年收入 5000 万美元。有时候,数据混乱是因为人们和情况本身就很混乱;然而,极端值可能会对我们的分析产生过大的影响,尤其是当我们使用假设正态分布的参数化技术时。
当处理数据子集时,这些问题可能会变得更加明显。这不仅仅是因为在样本较小时,极端或意外的值权重更大。还因为当考虑双变量和多变量关系时,它们可能显得不合逻辑。当一个身高 7 英尺 4 英寸的人,或一个年收入 5000 万美元的人,只有 10 岁时,警示信号会更加显眼。这可能表明某些测量或数据收集的错误。
但关键问题是异常值对我们从数据中得出的推断可能产生的不当影响。事实上,将异常值视为具有非常规变量值,或变量值之间关系的观察,可能是有帮助的,这些观察值的异常之处使得它们无法帮助解释数据中其余部分的关系。这对于统计推断非常重要,因为我们不能假设异常值对我们的总结统计量或参数估计有中立的影响。有时,我们的模型会花费大量精力去构建能够解释异常值观察模式的参数估计,这样我们就会妥协模型对所有其他观察值的解释或预测能力。如果你曾经花费数天试图解读一个模型,却在去除一些异常值后才发现你的系数和预测完全改变了,那就举手吧。
异常值的识别和处理是数据分析项目中最重要的数据准备任务之一。在本章中,我们将探讨一系列用于检测和处理异常值的策略。具体来说,本章的食谱将涵盖以下内容:
-
使用单一变量识别异常值
-
在双变量关系中识别异常值和意外值
-
使用子集来检查变量关系中的逻辑不一致性
-
使用线性回归识别具有显著影响的数据点
-
使用k最近邻(KNN)算法来发现异常值
-
使用隔离森林(Isolation Forest)来发现异常值
-
使用 PandasAI 识别异常值
技术要求
你需要使用 pandas、NumPy 和 Matplotlib 来完成本章的食谱。我使用的是 pandas 2.1.4,但代码也可以在 pandas 1.5.3 或更高版本上运行。
本章的代码可以从本书的 GitHub 仓库下载,链接为github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
使用单一变量识别异常值
异常值的概念有一定的主观性,但它与特定分布的特性密切相关;即其集中趋势、分散度和形态。我们根据变量的分布假设某个值是否为预期或意外值,依据是该值出现在当前分布中的可能性。如果某个值远离均值多个标准差,并且该分布近似为正态分布(对称、偏度低且尾部较瘦),我们更倾向于将其视为异常值。
如果我们设想从均匀分布中识别异常值,这一点会变得清晰。均匀分布没有集中趋势,也没有尾部。每个值出现的概率相同。例如,如果每个国家的 COVID-19 病例数是均匀分布的,最小值为 1,最大值为 10,000,000,那么 1 或 10,000,000 都不会被认为是异常值。
我们需要了解一个变量的分布情况,之后才能识别异常值。几个 Python 库提供了帮助我们理解感兴趣变量分布的工具。在本指南中,我们将使用其中的一些工具来识别当某个值偏离范围时,是否需要引起关注。
准备工作
除了 pandas 和 numpy,你还需要 matplotlib、statsmodels 和 scipy 库来运行本指南中的代码。你可以通过在终端客户端或 PowerShell(Windows 系统)中输入 pip install matplotlib、pip install statsmodels 和 pip install scipy 来安装这些库。你可能还需要安装 openpyxl 来保存 Excel 文件。
在本指南中,我们将处理 COVID-19 病例数据。该数据集包含每个国家的 COVID-19 总病例数和死亡人数。
数据说明
Our World in Data 提供了 COVID-19 的公共数据,网址为 ourworldindata.org/covid-cases。该数据集包括总病例数、死亡人数、已进行的测试数量、医院床位数量,以及诸如中位年龄、国内生产总值和人类发展指数等人口统计数据。人类发展指数是标准生活水平、教育水平和预期寿命的综合衡量标准。本指南使用的数据集于 2024 年 3 月 3 日下载。
操作步骤...
我们详细查看 COVID-19 数据中一些关键连续变量的分布情况。我们分析分布的集中趋势和形态,生成正态性度量和可视化图表:
- 加载
pandas、numpy、matplotlib、statsmodels和scipy库,以及 COVID-19 病例数据文件。
同时,设置 COVID-19 病例和人口统计数据列:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm
import scipy.stats as scistat
covidtotals = pd.read_csv("data/covidtotals.csv")
covidtotals.set_index("iso_code", inplace=True)
totvars = ['location','total_cases',
... 'total_deaths','total_cases_pm',
... 'total_deaths_pm']
demovars = ['population','pop_density',
... 'median_age','gdp_per_capita',
... 'hosp_beds','hum_dev_ind']
- 获取 COVID-19 病例数据的描述性统计信息。
创建一个只包含关键信息的 DataFrame:
covidtotalsonly = covidtotals.loc[:, totvars]
covidtotalsonly.describe()
total_cases total_deaths total_cases_pm total_deaths_pm
count 231 231 231 231
mean 3,351,599 30,214 206,178 1,262
std 11,483,212 104,779 2 03,858 1,315
min 4 0 354 0
25% 25,672 178 21,822 141
50% 191,496 1,937 133,946 827
75% 1,294,286 14,150 345,690 1,998
max 103,436,829 1,127,152 763,475 6,508
-
显示更详细的百分位数据。我们指示只对数值进行操作,因此会跳过位置列。
covidtotalsonly.quantile(np.arange(0.0, 1.1, 0.1), numeric_only=True)total_cases total_deaths total_cases_pm total_deaths_pm 0.0 4.0 0.0 354.5 0.0 0.1 8,359.0 31.0 3,138.6 32.9 0.2 17,181.0 126.0 10,885.7 105.3 0.3 38,008.0 294.0 35,834.6 210.5 0.4 74,129.0 844.0 86,126.2 498.8 0.5 191,496.0 1,937.0 133,946.3 827.0 0.6 472,755.0 4,384.0 220,429.4 1,251.3 0.7 1,041,111.0 9,646.0 293,737.4 1,697.6 0.8 1,877,065.0 21,218.0 416,608.1 2,271.7 0.9 5,641,992.0 62,288.0 512,388.4 3,155.9 1.0 103,436,829.0 1,127,152.0 763,475.4 6,507.7
注意
从 pandas 版本 2.0.0 开始,quantile函数的numeric_only参数默认值为False。我们需要将numeric_only的值设置为True,以便让quantile跳过location列。
你还应该显示偏度和峰度。偏度和峰度分别描述了分布的对称性和尾部的肥胖程度。对于total_cases和total_deaths,这两个值显著高于如果变量呈正态分布时的预期值:
covidtotalsonly.skew(numeric_only=True)
total_cases 6.3
total_deaths 7.1
total_cases_pm 0.8
total_deaths_pm 1.3
dtype: float64
covidtotalsonly.kurtosis(numeric_only=True)
total_cases 47.1
total_deaths 61.7
total_cases_pm -0.4
total_deaths_pm 1.3
dtype: float64
原型正态分布的偏度为0,峰度为3。
- 测试 COVID-19 数据的正态性。
使用scipy库中的 Shapiro-Wilk 检验。输出检验的p值(若* p *值低于0.05,则可在 95%的置信水平上拒绝正态分布的null假设):
def testnorm(var, df):
stat, p = scistat.shapiro(df[var])
return p
print("total cases: %.5f" % testnorm("total_cases", covidtotalsonly))
print("total deaths: %.5f" % testnorm("total_deaths", covidtotalsonly))
print("total cases pm: %.5f" % testnorm("total_cases_pm", covidtotalsonly))
print("total deaths pm: %.5f" % testnorm("total_deaths_pm", covidtotalsonly))
total cases: 0.00000
total deaths: 0.00000
total cases pm: 0.00000
total deaths pm: 0.00000
- 显示总病例数和每百万总病例数的常规量化-量化图(
qqplots)。
直线显示了如果分布是正态分布时的样子:
sm.qqplot(covidtotalsonly[['total_cases']]. \
... sort_values(['total_cases']), line='s')
plt.title("QQ Plot of Total Cases")
sm.qqplot(covidtotals[['total_cases_pm']]. \
... sort_values(['total_cases_pm']), line='s')
plt.title("QQ Plot of Total Cases Per Million")
plt.show()
这将生成以下散点图:

图 4.1:COVID-19 病例分布与正态分布的比较
通过按人口调整每百万总病例数列后,分布更接近正态分布:

图 4.2:每百万 COVID-19 病例的分布与正态分布的比较
- 显示总病例的异常值范围。
定义连续变量异常值的一种方法是基于第三四分位数以上或第一四分位数以下的距离。如果该距离超过 1.5 倍的四分位差(第一四分位数和第三四分位数之间的距离),则该值被认为是异常值。本步骤中的计算表明,超过 3,197,208 的值可以被视为异常值。在这种情况下,我们可以忽略小于 0 的异常值阈值,因为这是不可能的:
thirdq, firstq = covidtotalsonly.total_cases.quantile(0.75), covidtotalsonly.total_cases.quantile(0.25)
interquartilerange = 1.5*(thirdq-firstq)
outlierhigh, outlierlow = interquartilerange+thirdq, firstq-interquartilerange
print(outlierlow, outlierhigh, sep=" <--> ")
-1877250 <--> 3197208
- 生成异常值的 DataFrame 并将其写入 Excel。
遍历四个 COVID-19 病例列。按照前一步的操作计算每列的异常值阈值。从 DataFrame 中选择那些高于上限阈值或低于下限阈值的行。添加表示所检验变量(varname)的异常值和阈值级别的列:
def getoutliers():
... dfout = pd.DataFrame(columns=covidtotals. \
... columns, data=None)
... for col in covidtotalsonly.columns[1:]:
... thirdq, firstq = covidtotalsonly[col].\
... quantile(0.75),covidtotalsonly[col].\
... quantile(0.25)
... interquartilerange = 1.5*(thirdq-firstq)
... outlierhigh, outlierlow = \
... interquartilerange+thirdq, \
... firstq-interquartilerange
... df = covidtotals.loc[(covidtotals[col]> \
... outlierhigh) | (covidtotals[col]< \
... outlierlow)]
... df = df.assign(varname = col,
... threshlow = outlierlow,
... threshhigh = outlierhigh)
... dfout = pd.concat([dfout, df])
... return dfout
...
outliers = getoutliers()
outliers.varname.value_counts()
total_deaths 39
total_cases 33
total_deaths_pm 4
Name: varname, dtype: int64
outliers.to_excel("views/outlierscases.xlsx")
这将生成以下 Excel 文件(某些列已隐藏以节省空间):

图 4.3:包含异常值案例的 Excel 文件
根据四分位数法,共识别出 39 个国家在total_deaths值上为异常值,33 个total_cases异常值。注意,total_cases_pm没有异常值。
- 更加仔细地查看每百万总死亡数的异常值。
使用我们在上一步骤中创建的varname列来选择total_deaths_pm的离群值。显示可能有助于解释这些列极端值的列(median_age和hum_dev_ind)。我们还显示了这些列的 25(th)、50(th)和 75^(th)百分位的全数据集对比值:
outliers.loc[outliers.varname=="total_deaths_pm",
['location','total_deaths_pm','total_cases_pm',
'median_age','hum_dev_ind']]. \
sort_values(['total_deaths_pm'], ascending=False)
location total_deaths_pm \
PER Peru 6,507.7
BGR Bulgaria 5,703.5
BIH Bosnia and Herzegovina 5,066.3
HUN Hungary 4,918.3
total_cases_pm median_age hum_dev_ind
PER 133,239.0 29.1 0.8
BGR 195,767.9 44.7 0.8
BIH 124,806.3 42.5 0.8
HUN 223,685.2 43.4 0.9
covidtotals[['total_deaths_pm','median_age',
'hum_dev_ind']]. \
quantile([0.25,0.5,0.75])
total_deaths_pm median_age hum_dev_ind
0.25 141.18 22.05 0.60
0.50 827.05 29.60 0.74
0.75 1,997.51 38.70 0.83
所有四个国家的死亡人数每百万均远超 75^(th)百分位。四个国家中的三个国家在中位年龄和人类发展指数方面接近或超过了 75^(th)百分位。出乎意料的是,人类发展指数与每百万死亡人数之间存在正相关关系。我们将在下一个配方中显示相关性矩阵。
-
显示总病例数的直方图:
plt.hist(covidtotalsonly['total_cases']/1000, bins=7) plt.title("Total COVID-19 Cases (thousands)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这段代码会生成以下图形:

图 4.4:COVID-19 总病例数的直方图
-
对 COVID-19 数据进行对数转换。显示总病例数的对数转换后的直方图:
covidlogs = covidtotalsonly.copy() for col in covidlogs.columns[1:]: ... covidlogs[col] = np.log1p(covidlogs[col]) plt.hist(covidlogs['total_cases'], bins=7) plt.title("Total COVID-19 Cases (log)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这段代码会生成以下内容:

图 4.5:COVID-19 总病例数的对数转换直方图
我们在前面的步骤中使用的工具为我们提供了有关 COVID-19 病例和死亡的分布,以及离群值所在位置的相当多的信息。
它是如何工作的……
步骤 3中显示的百分位数据反映了病例和死亡数据的偏斜性。例如,如果我们观察 20^(th)到 30^(th)百分位之间的数值范围,并将其与 70^(th)到 80^(th)百分位之间的范围进行比较,会发现较高百分位的每个变量的范围都远大于较低百分位的范围。这一点从偏度和峰度的非常高值中得到了证实,而正态分布的偏度和峰度值分别为0和3。我们在步骤 4中进行了正态性检验,结果表明,COVID-19 变量的分布在高度显著性水平下不符合正态分布。
这与我们在步骤 5中运行的qqplots一致。总病例数和每百万总病例数的分布与正态分布有显著差异,如直线所示。许多病例集中在零附近,右尾的斜率急剧增加。
我们在步骤 6 和 7中识别了离群值。使用 1.5 倍四分位差来确定离群值是一个合理的经验法则。我喜欢将这些值和相关数据输出到 Excel 文件中,以便查看我能在数据中发现什么模式。当然,这通常会引发更多的问题。我们将在下一个配方中尝试解答其中的一些问题,但我们现在可以考虑的一个问题是,是什么原因导致每百万死亡人数较高的国家,如步骤 8所示。中位年龄和人类发展指数似乎可能是其中的一部分原因。值得进一步探索这些双变量关系,我们将在后续的配方中进行探索。
我们在步骤 7中识别异常值的前提是假设正态分布,但这个假设我们已经证明是不成立的。查看步骤 9中的总病例分布,它看起来更像是对数正态分布,值集中在0附近,并呈右偏。我们在步骤 10中对数据进行了转换,并绘制了转换结果。
还有更多……
我们也可以使用标准差,而不是四分位差,来识别步骤 6 和 7中的异常值。
我应该在这里补充一点,异常值不一定是数据收集或测量错误,我们可能需要,也可能不需要对数据进行调整。然而,极端值可能会对我们的分析产生有意义且持久的影响,特别是在像这样的较小数据集上。
我们对 COVID-19 病例数据的总体印象是相对干净的;也就是说,严格定义下并没有太多无效值。独立地查看每个变量,而不考虑它与其他变量的关系,无法识别出明显的清晰数据错误。然而,变量的分布在统计学上是相当有问题的。基于这些变量构建统计模型将变得复杂,因为我们可能需要排除参数检验。
还值得记住的是,我们对什么构成异常值的理解是由我们对正态分布的假设所塑造的。相反,如果我们让期望值由数据的实际分布来引导,我们对极端值的理解会有所不同。如果我们的数据反映的是一个社会、或生物学、或物理过程,这些过程本身就不是正态分布(如均匀分布、对数分布、指数分布、威布尔分布、泊松分布等),那么我们对异常值的定义应该相应调整。
另见
箱线图在这里也可能会有启发作用。我们在第五章中对这些数据做了箱线图,使用可视化识别意外值。我们在第八章中更详细地探讨了变量转换,编码、转换与特征缩放。
我们将在下一个配方中探讨这个数据集中双变量关系,以便从中获得任何关于异常值和意外值的见解。在后续章节中,我们将考虑补充缺失数据和对极端值进行调整的策略。
识别双变量关系中的异常值和意外值
一个值即使不是极端值,如果它没有显著偏离分布均值,也可能是意外的。当第二个变量有某些特定值时,第一个变量的一些值会变得意外。这在一个变量是分类变量而另一个是连续变量时,尤其容易说明。
以下图表显示了几年期间每天的鸟类观察数量,但对两个地点的分布做了不同的展示。一个地点的平均每天观察数为 33,另一个为 52(这是虚拟数据)。总体平均值(未显示)为 42。那么,58 次每日观察应该如何解读?它是异常值吗?这显然取决于观察的是哪一个地点。
如果在地点 A 有 58 次观察,58 将是一个异常高的数值。而对于地点 B 来说,58 次观察与该地点的平均值并没有太大不同:

图 4.6:按地点分类的每日鸟类观察数
这提示了一个有用的经验法则:每当某个感兴趣的变量与另一个变量显著相关时,在尝试识别异常值时(或者在进行任何涉及该变量的统计分析时),我们应当考虑到这种关系。将这一点表述得更准确一点,并扩展到两个变量都是连续的情况。如果我们假设变量 x 和变量 y 之间存在线性关系,那么我们可以用熟悉的 y = mx + b 方程来描述这种关系,其中 m 是斜率,b 是 y 截距。然后我们可以预期,y 会随着 x 增加 1 单位而增加 m。异常值是那些偏离这一关系较大的值,其中 y 的值远高于或低于根据 x 的值所预测的值。这可以扩展到多个 x 或预测变量。
在本例中,我们演示了如何通过检查一个变量与另一个变量的关系来识别异常值和意外值。在本章接下来的例子中,我们使用多元方法来进一步改进我们的异常值检测。
准备工作
在本例中,我们使用了matplotlib和seaborn库。你可以通过终端客户端或 PowerShell(在 Windows 中)输入pip install matplotlib和pip install seaborn来安装它们。
操作方法...
我们检查了 COVID-19 数据库中总病例数和总死亡数之间的关系。我们仔细查看了那些死亡人数高于或低于根据病例数预期值的国家:
-
加载
pandas、matplotlib、seaborn以及 COVID-19 累积数据:import pandas as pd import matplotlib.pyplot as plt import seaborn as sns covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) -
生成累积数据和人口统计学列的相关性矩阵。
不出所料,总病例数和总死亡数之间的相关性较高(0.76),而每百万总病例数与每百万总死亡数之间的相关性较低(0.44)。人均 GDP 与每百万病例数之间有强相关性(0.66)(注意,并未显示所有相关性):
covidtotals.corr(method="pearson", numeric_only=True)
total_cases total_deaths \
total_cases 1.00 0.76
total_deaths 0.76 1.00
total_cases_pm 0.10 0.01
total_deaths_pm 0.15 0.27
population 0.70 0.47
pop_density -0.03 -0.04
median_age 0.29 0.19
gdp_per_capita 0.19 0.13
hosp_beds 0.21 0.05
vac_per_hund 0.02 -0.07
aged_65_older 0.29 0.19
life_expectancy 0.19 0.11
hum_dev_ind 0.26 0.21
total_cases_pm ... aged_65_older \
total_cases 0.10 ... 0.29
total_deaths 0.01 ... 0.19
total_cases_pm 1.00 ... 0.72
total_deaths_pm 0.44 ... 0.68
population -0.13 ... -0.01
pop_density 0.19 ... 0.07
median_age 0.74 ... 0.92
gdp_per_capita 0.66 ... 0.51
hosp_beds 0.48 ... 0.65
vac_per_hund 0.24 ... 0.35
aged_65_older 0.72 ... 1.00
life_expectancy 0.69 ... 0.73
hum_dev_ind 0.76 ... 0.78
life_expectancy hum_dev_ind
total_cases 0.19 0.26
total_deaths 0.11 0.21
total_cases_pm 0.69 0.76
total_deaths_pm 0.49 0.60
population -0.04 -0.02
pop_density 0.20 0.14
median_age 0.83 0.90
gdp_per_capita 0.68 0.75
hosp_beds 0.46 0.57
vac_per_hund 0.67 0.51
aged_65_older 0.73 0.78
life_expectancy 1.00 0.91
hum_dev_ind 0.91 1.00
[13 rows x 13 columns]
- 检查一些国家是否存在与总病例数相比,总死亡人数异常高或低的情况。
使用qcut创建一个列,将数据分成分位数。显示按总死亡数分位数排列的总病例数分位数交叉表:
covidtotals['total_cases_q'] = pd.\
... qcut(covidtotals['total_cases'],
... labels=['very low','low','medium',
... 'high','very high'], q=5, precision=0)
covidtotals['total_deaths_q'] = pd.\
... qcut(covidtotals['total_deaths'],
... labels=['very low','low','medium',
... 'high','very high'], q=5, precision=0)
pd.crosstab(covidtotals.total_cases_q,
... covidtotals.total_deaths_q)
total_deaths_q very low low medium high very high
total_cases_q
very low 36 10 1 0 0
low 11 26 8 1 0
medium 0 9 27 10 0
high 0 1 8 31 6
very high 0 0 2 4 40
- 看看那些不符合对角线关系的国家。
有一个国家病例总数很高,但死亡总数很低。由于covidtotals和covidtotalsonly数据框有相同的索引,我们可以使用从后者创建的布尔序列来返回前者的选定行:
covidtotals.loc[(covidtotals. \
total_cases_q=="high") & \
(covidtotals.total_deaths_q=="low")].T
iso_code QAT
lastdate 2023-06-25
location Qatar
total_cases 514,524.00
total_deaths 690.00
total_cases_pm 190,908.72
total_deaths_pm 256.02
population 2695131
pop_density 227.32
median_age 31.90
gdp_per_capita 116,935.60
hosp_beds 1.20
vac_per_hund NaN
aged_65_older 1.31
life_expectancy 80.23
hum_dev_ind 0.85
region West Asia
- 绘制总病例数与总死亡数的散点图。
使用 Seaborn 的regplot方法,除了散点图之外,还生成线性回归线:
ax = sns.regplot(x=covidtotals.total_cases/1000, y=covidtotals.total_deaths)
ax.set(xlabel="Cases (thousands)", ylabel="Deaths", title="Total COVID-19 Cases and Deaths by Country")
plt.show()
这产生了以下散点图:

图 4.7:带有线性回归线的总病例数与死亡数散点图
- 检查回归线之上的意外值。
仔细观察数据中明显位于回归线之上或之下的国家,看看这些国家的病例数和死亡数坐标。有两个国家的病例数少于 4000 万,但死亡人数超过 40 万:
covidtotals.loc[(covidtotals.total_cases<40000000) \
& (covidtotals.total_deaths>400000)].T
iso_code BRA RUS
lastdate 2023-10-01 2024-01-28
location Brazil Russia
total_cases 37,519,960.00 23,774,451.00
total_deaths 702,116.00 401,884.00
total_cases_pm 174,257.35 164,286.55
total_deaths_pm 3,260.90 2,777.11
population 215313504 144713312
pop_density 25.04 8.82
median_age 33.50 39.60
gdp_per_capita 14,103.45 24,765.95
hosp_beds 2.20 8.05
vac_per_hund NaN NaN
aged_65_older 8.55 14.18
life_expectancy 75.88 72.58
hum_dev_ind 0.77 0.82
region South America Eastern Europe
- 检查回归线下方的意外值。
有两个国家的病例数超过 3000 万,但死亡数少于 10 万:
covidtotals.loc[(covidtotals.total_cases>30000000) \
& (covidtotals.total_deaths<100000)].T
iso_code JPN KOR
lastdate 2023-05-14 2023-09-10
location Japan South Korea
total_cases 33,803,572.00 34,571,873.00
total_deaths 74,694.00 35,934.00
total_cases_pm 272,715.69 667,207.06
total_deaths_pm 602.61 693.50
population 123951696 51815808
pop_density 347.78 527.97
median_age 48.20 43.40
gdp_per_capita 39,002.22 35,938.37
hosp_beds 13.05 12.27
vac_per_hund NaN NaN
aged_65_older 27.05 13.91
life_expectancy 84.63 83.03
hum_dev_ind 0.92 0.92
region East Asia East Asia
-
绘制每百万人总病例数与每百万人总死亡数的散点图:
ax = sns.regplot(x="total_cases_pm", y="total_deaths_pm", data=covidtotals) ax.set(xlabel="Cases Per Million", ylabel="Deaths Per Million", title="Total COVID-19 Cases per Million and Deaths per Million by Country") plt.show()
这产生了以下散点图:

图 4.8:每百万人病例数与死亡数散点图,带有线性回归线
前面的步骤考察了变量之间的关系,以识别异常值。
其工作原理…
通过观察双变量关系,我们提出了一些在前一个步骤中未显现出来的问题。我们确认了预期中的关系,比如总病例数与总死亡数的关系,但这也让偏离这种关系的情况更加引人注目。根据一定数量的病例数,有可能有实质性的解释来说明异常高的死亡率,但测量误差或病例报告不准确也不能排除。
步骤 2显示了总病例数和总死亡数之间的高度相关性(0.76),但即便如此,仍然存在一些差异。我们在步骤 3中将病例数和死亡数划分为分位数,然后做一个分位数值的交叉表。大多数国家位于对角线上或接近对角线。然而,有一个国家的病例数非常高,但死亡数较低,即卡塔尔。合理的怀疑是是否存在潜在的报告问题。
我们在步骤 5中绘制了总病例数与总死亡数的散点图。两者之间强烈的向上倾斜关系得到了确认,但有一些国家的死亡数位于回归线之上。我们可以看到,巴西和俄罗斯的死亡数比根据病例数预测的要高,而日本和韩国的死亡数则远低于预测值。
不出所料,在每百万案例和每百万死亡人数的散点图中,回归线周围的散点更为分散。虽然存在正相关关系,但回归线的斜率并不陡峭。
还有更多...
我们已经开始对数据的样貌有了较好的了解,但以这种形式的数据并不能让我们检查单变量分布和双变量关系如何随时间变化。例如,一个国家每百万死亡人数超过每百万案例人数的原因,可能是因为自首次确诊病例以来已经过去了更多的时间。我们无法在累积数据中探索这一点。为此,我们需要每日数据,在接下来的章节中我们将探讨这个问题。
这个食谱,以及之前的那个,展示了数据清理如何渗透到探索性数据分析中,即使在你刚开始对数据有所了解时。我一定会在数据探索和我们现在所做的工作之间做出区分。我们正在尝试了解数据是如何相互关联的,以及为什么在某些情况下,某些变量会取某些值而在其他情况下不取。我们希望做到当我们开始进行分析时,数据中不会有大的惊讶。
我发现做一些小的调整来规范这个过程很有帮助。我对那些尚未准备好进行分析的文件使用不同的命名规范。即使没有别的,这也帮助我提醒自己,在这个阶段生成的任何数据都远未准备好分发。
另见
我们仍然没有做太多工作来检查可能的数据问题,这些问题只有在检查数据子集时才会显现出来;例如,声称自己不工作的人的正工资收入值(这两个变量都来自国家纵向调查或NLS)。我们将在下一个食谱中解决这个问题。
在第五章中,我们将使用 Matplotlib 和 Seaborn 做更多工作,主题是利用可视化识别异常值。
使用子集工具检查变量关系中的逻辑不一致性
在某些时候,数据问题归结为推理逻辑问题,比如当变量 y 小于某个量 b 时,变量 x 必须大于某个量 a。一旦完成一些初步的数据清理,检查逻辑不一致性就变得很重要。pandas通过子集工具如loc和布尔索引,使这种错误检查相对简单。我们可以将这些工具与 Series 和 DataFrame 的汇总方法结合使用,从而轻松地将特定行的值与整个数据集或某些行的子集进行比较。我们还可以轻松地对列进行聚合。关于变量之间的逻辑关系的任何问题,都可以通过这些工具得到解答。我们将在这个食谱中演示一些示例。
准备工作
我们将使用 NLS 数据,主要涉及就业和教育方面的数据。在本例中,我们多次使用 apply 和 lambda 函数,但我们会在第九章,在聚合时修复混乱数据中详细介绍它们的使用。然而,即使你没有这些工具的经验,也不需要回顾第九章,就能跟随操作。
数据说明
美国劳工统计局进行的国家青少年纵向调查(NLS)始于 1997 年,调查对象为 1980 至 1985 年间出生的一组人群,每年进行一次跟踪,直到 2023 年。本次例子中,我从调查中的数百个数据项中提取了 106 个变量,涵盖了成绩、就业、收入和对政府态度等信息。NLS 数据可以从nlsinfo.org下载。
操作方法:
我们对 NLS 数据进行了多次逻辑检查,比如有研究生入学记录但没有本科入学记录的个体,或者有工资收入但没有工作周数的个体。我们还检查给定个体在不同时期之间关键值的巨大变化:
-
导入
pandas,然后加载 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
查看一些就业和教育数据。
数据集中包含 2000 年至 2023 年每年的工作周数,以及 1997 年 2 月到 2022 年 10 月每月的大学入学状态。我们利用 loc 访问器来选择从冒号左侧指定的列到右侧指定的列的所有数据。例如,nls97.loc[:, "colenroct15":"colenrfeb22"]:
nls97[['wageincome20','highestgradecompleted',
'highestdegree']].head(3).T
personid 135335 999406 \
wageincome20 NaN 115,000
highestgradecompleted NaN 14
highestdegree 4\. Bachelors 2\. High School
personid 151672
wageincome20 NaN
highestgradecompleted 16
highestdegree 4\. Bachelors
nls97.loc[:, "weeksworked18":"weeksworked22"].head(3).T
personid 135335 999406 151672
weeksworked18 NaN 52 52
weeksworked19 NaN 52 9
weeksworked20 NaN 52 0
weeksworked21 NaN 46 0
weeksworked22 NaN NaN 3
nls97.loc[:, "colenroct15":"colenrfeb22"].head(2).T
personid 135335 999406
colenroct15 1\. Not enrolled 1\. Not enrolled
colenrfeb16 NaN 1\. Not enrolled
colenroct16 NaN 1\. Not enrolled
colenrfeb17 NaN 1\. Not enrolled
colenroct17 NaN 1\. Not enrolled
colenrfeb18 NaN 1\. Not enrolled
colenroct18 NaN 1\. Not enrolled
colenrfeb19 NaN 1\. Not enrolled
colenroct19 NaN 1\. Not enrolled
colenrfeb20 NaN 1\. Not enrolled
colenroct20 NaN 1\. Not enrolled
colenrfeb21 NaN 1\. Not enrolled
colenroct21 NaN 1\. Not enrolled
colenrfeb22 NaN NaN
显示有工资收入但没有工作周数的个体:
nls97.loc[(nls97.weeksworked20==0) &
(nls97.wageincome20>0),
['weeksworked20','wageincome20']]
weeksworked20 wageincome20
personid
674877 0 40,000
692251 0 12,000
425230 0 150,000
391939 0 10,000
510545 0 72,000
... ...
947109 0 1,000
706862 0 85,000
956396 0 130,000
907078 0 10,000
274042 0 130,000
[132 rows x 2 columns]
- 检查个体是否曾经在四年制大学就读过。
链接多个方法。首先,创建一个包含以 colenr 开头的列的 DataFrame(nls97.filter(like="colenr"))。这些是每年 10 月和 2 月的大学入学列。然后,使用 apply 运行一个 lambda 函数,检查每个 colenr 列的第一个字符(apply(lambda x: x.str[0:1]=='3'))。这将为所有大学入学列返回一个 True 或 False 值;如果字符串的第一个值为 3,表示四年制大学入学,则返回 True。最后,使用 any 函数测试从前一步返回的任何值是否为 True(any(axis=1))。这将识别个体是否在 1997 年 2 月到 2022 年 10 月期间曾就读四年制大学。这里的第一个语句仅用于说明前两步的结果。要获得所需的结果,只需运行第二个语句,查看个体是否曾在某个时刻入读四年制大学:
nls97.filter(like="colenr").\
apply(lambda x: x.str[0:1]=='3').\
head(2).T
personid 135335 999406
colenrfeb97 False False
colenroct97 False False
colenrfeb98 False False
colenroct98 False False
colenrfeb99 False False
colenroct99 True False
colenrfeb00 True False
colenroct00 True True
colenrfeb01 True True
colenroct01 True False
colenrfeb02 True False
colenroct02 True True
colenrfeb03 True True
nls97.filter(like="colenr").\
apply(lambda x: x.str[0:1]=='3').\
any(axis=1).head(2)
personid
135335 True
999406 True
dtype: bool
- 显示有研究生入学记录但没有本科入学记录的个体。
我们可以使用在步骤 4中测试的内容进行一些检查。我们希望找到那些在任何一个月的colenr字段的首字符为4(研究生入学),但从未出现过3(本科入学)值的个体。注意测试的第二部分前面的~符号,用于取反。共有 24 个个体符合这个条件:
nobach = nls97.loc[nls97.filter(like="colenr").\
apply(lambda x: x.str[0:1]=='4').\
any(axis=1) & ~nls97.filter(like="colenr").\
apply(lambda x: x.str[0:1]=='3').\
any(axis=1), "colenrfeb17":"colenrfeb22"]
len(nobach)
24
nobach.head(2).T
personid 793931 787976
.....abbreviated to save space
colenrfeb01 1\. Not enrolled 1\. Not enrolled
colenroct01 2\. 2-year college 1\. Not enrolled
colenrfeb02 2\. 2-year college 1\. Not enrolled
colenroct02 2\. 2-year college 1\. Not enrolled
colenrfeb03 2\. 2-year college 1\. Not enrolled
colenroct03 1\. Not enrolled 1\. Not enrolled
colenrfeb04 2\. 2-year college 1\. Not enrolled
colenroct04 4\. Graduate program 1\. Not enrolled
colenrfeb05 4\. Graduate program 1\. Not enrolled
.....
colenrfeb14 1\. Not enrolled 1\. Not enrolled
colenroct14 1\. Not enrolled 2\. 2-year college
colenrfeb15 1\. Not enrolled 2\. 2-year college
colenroct15 1\. Not enrolled 2\. 2-year college
colenrfeb16 1\. Not enrolled 1\. Not enrolled
colenroct16 1\. Not enrolled 4\. Graduate program
colenrfeb17 1\. Not enrolled 4\. Graduate program
colenroct17 1\. Not enrolled 4\. Graduate program
colenrfeb18 1\. Not enrolled 4\. Graduate program
colenroct18 1\. Not enrolled 1\. Not enrolled
.....
- 显示拥有学士学位或更高学位,但没有四年制大学入学的个体。
使用isin来将highestdegree字段的首字符与列表中的所有值进行比较(nls97.highestdegree.str[0:1].isin(['4','5','6','7'])):
nls97.highestdegree.value_counts().sort_index()
highestdegree
0\. None 877
1\. GED 1167
2\. High School 3531
3\. Associates 766
4\. Bachelors 1713
5\. Masters 704
6\. PhD 64
7\. Professional 130
Name: count, dtype: int64
no4yearenrollment = \
... nls97.loc[nls97.highestdegree.str[0:1].\
... isin(['4','5','6','7']) & \
... ~nls97.filter(like="colenr").\
... apply(lambda x: x.str[0:1]=='3').\
... any(axis=1), "colenrfeb97":"colenrfeb22"]
len(no4yearenrollment)
42
no4yearenrollment.head(2).T
personid 417244 124616
.....abbreviated to save space
colenroct04 1\. Not enrolled 2\. 2-year college
colenrfeb05 1\. Not enrolled 2\. 2-year college
colenroct05 1\. Not enrolled 1\. Not enrolled
colenrfeb06 1\. Not enrolled 1\. Not enrolled
colenroct06 1\. Not enrolled 1\. Not enrolled
colenrfeb07 1\. Not enrolled 1\. Not enrolled
colenroct07 1\. Not enrolled 1\. Not enrolled
colenrfeb08 1\. Not enrolled 1\. Not enrolled
colenroct08 1\. Not enrolled 1\. Not enrolled
colenrfeb09 2\. 2-year college 1\. Not enrolled
colenroct09 2\. 2-year college 1\. Not enrolled
colenrfeb10 2\. 2-year college 1\. Not enrolled
colenroct10 2\. 2-year college 1\. Not enrolled
colenrfeb11 2\. 2-year college 1\. Not enrolled
colenroct11 2\. 2-year college 1\. Not enrolled
colenrfeb12 2\. 2-year college 1\. Not enrolled
colenroct12 1\. Not enrolled 1\. Not enrolled
colenrfeb13 1\. Not enrolled 1\. Not enrolled
- 显示高工资收入的个体。
将高工资定义为高于平均值三个标准差的收入。看起来工资收入的值已经在 380,288 美元处被截断:
highwages = \
nls97.loc[nls97.wageincome20 >
nls97.wageincome20.mean()+ \
(nls97.wageincome20.std()*3),
['wageincome20']]
highwages
wageincome20
personid
989896 380,288
718416 380,288
693498 380,288
811201 380,288
553982 380,288
...
303838 380,288
366297 380,288
436132 380,288
964406 380,288
433818 380,288
[104 rows x 1 columns]
- 显示在最近一年中,工作周数变化较大的个体。
计算每个人 2016 到 2020 年间的平均工作周数(nls97.loc[:, "weeksworked16":"weeksworked20"].mean(axis=1))。我们通过axis=1来表示按列计算每个个体的平均值,而不是按个体计算。然后,我们找到那些平均值不在 2021 年工作周数的一半到两倍之间的行。(注意我们早些时候使用的~操作符)。我们还表示,对于那些 2021 年工作周数为null的行,我们不感兴趣。共有 1,099 个个体在 2021 年与 2016 至 2020 年平均值相比,工作周数发生了大幅变化:
workchanges = nls97.loc[~nls97.loc[:,
"weeksworked16":"weeksworked20"].mean(axis=1).\
between(nls97.weeksworked21*0.5,\
nls97.weeksworked21*2) \
& ~nls97.weeksworked21.isnull(),
"weeksworked16":"weeksworked21"]
len(workchanges)
1099
workchanges.head(6).T
personid 151672 620126 ... 692251 483488
weeksworked16 53 45 ... 0 53
weeksworked17 52 0 ... 0 52
weeksworked18 52 0 ... 0 52
weeksworked19 9 0 ... 0 52
weeksworked20 0 0 ... 0 15
weeksworked21 0 0 ... 51 13
[6 rows x 6 columns]
- 显示最高学历和最高学位的不一致之处。
使用crosstab函数,显示highestgradecompleted根据highestdegree的分类情况,筛选highestgradecompleted小于 12 的个体。这些个体中有相当一部分表明他们已经完成了高中学业,这在美国是比较不寻常的,除非完成的最高年级低于 12 年级:
ltgrade12 = nls97.loc[nls97.highestgradecompleted<12, ['highestgradecompleted','highestdegree']]
pd.crosstab(ltgrade12.highestgradecompleted, ltgrade12.highestdegree)
highestdegree 0\. None 1\. GED \
highestgradecompleted
5 0 0
6 11 4
7 23 7
8 108 82
9 98 182
10 105 207
11 113 204
highestdegree 2\. High School 3\. Associates
highestgradecompleted
5 1 0
6 0 1
7 1 0
8 7 0
9 8 1
10 14 1
11 42 2
这些步骤揭示了 NLS 数据中的若干逻辑不一致。
它是如何工作的……
如果你是第一次看到这个语法,可能会觉得做这类子集筛选的语法有点复杂。但你会逐渐习惯它,而且它允许你快速对数据运行任何你能想到的查询。
一些不一致或意外的值表明可能存在受访者或录入错误,值得进一步调查。当weeks worked的值为0时,很难解释正的工资收入值。其他意外的值可能根本不是数据问题,而是表明我们应该小心如何使用这些数据。例如,我们可能不想单独使用 2021 年的工作周数。相反,我们可能考虑在许多分析中使用三年平均值。
另见
第三章,评估你的数据中的选择和组织列与选择行食谱展示了这里使用的一些数据子集技术。我们将在第九章,修复聚合数据时的脏数据中更详细地讲解apply函数。
使用线性回归来识别具有显著影响的数据点
本章剩余的教程使用统计建模来识别离群值。这些技术的优点在于它们不太依赖于关注变量的分布,并且比单变量或双变量分析考虑得更多。这使得我们能够识别那些原本不显眼的离群值。另一方面,通过考虑更多因素,多变量技术可能会提供证据,表明一个先前可疑的值实际上在预期范围内,并提供有意义的信息。
在本教程中,我们使用线性回归来识别对目标或因变量模型有过大影响的观察值(行)。这可能表明某些观察值的一个或多个值极端,以至于影响了其他所有观察值的模型拟合。
准备开始
本教程中的代码需要 matplotlib 和 statsmodels 库。你可以通过在终端窗口或 PowerShell(在 Windows 上)输入 pip install matplotlib 和 pip install statsmodels 来安装它们。
我们将处理有关每个国家 COVID-19 总病例和死亡数据。
如何操作…
我们将使用 statsmodels 的 OLS 方法来拟合每百万人总病例数的线性回归模型。然后,我们将识别出对该模型有最大影响的国家:
-
导入
pandas、matplotlib和statsmodels,并加载 COVID-19 疫情数据:import pandas as pd import matplotlib.pyplot as plt import statsmodels.api as sm covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) -
创建分析文件并生成描述性统计数据。
仅获取分析所需的列。删除分析列中有缺失数据的行:
xvars = ['pop_density','median_age','gdp_per_capita']
covidanalysis = covidtotals.loc[:,['total_cases_pm'] + xvars].dropna()
covidanalysis.describe()
total_cases_pm pop_density median_age gdp_per_capita
count 180 180 180 180
mean 167,765 204 30 18,290
std 190,965 631 9 19,392
min 354 2 15 661
25% 11,931 36 22 3,790
50% 92,973 82 29 11,822
75% 263,162 205 38 26,785
max 763,475 7,916 48 116,936
- 拟合线性回归模型。
有充分的概念性理由相信,人口密度、中位年龄和人均 GDP 可能是每百万人总病例数的预测因子。我们在模型中使用这三个变量:
def getlm(df):
... Y = df.total_cases_pm
... X = df[['pop_density',
'median_age','gdp_per_capita']]
... X = sm.add_constant(X)
... return sm.OLS(Y, X).fit()
...
lm = getlm(covidanalysis)
lm.summary()
coef std err t P>|t|
----------------------------------------------------------------
Const -2.382e+05 3.41e+04 -6.980 0.000
pop_density 12.4060 14.664 0.846 0.399
median_age 11570 1291.446 8.956 0.000
gdp_per_capita 2.9674 0.621 4.777 0.000
- 确定对模型有过大影响的国家。
Cook 距离值大于 0.5 的数据应仔细审查:
influence = lm.get_influence().summary_frame()
influence.loc[influence.cooks_d>0.5, ['cooks_d']]
cooks_d
iso_code
QAT 0.70
SGP 3.12
covidanalysis.loc[influence.cooks_d>0.5]
total_cases_pm pop_density median_age gdp_per_capita
iso_code
QAT 190,909 227 32 116,936
SGP 531,184 7,916 42 85,535
- 创建影响图。
具有更高 Cook 距离值的国家显示出更大的圆圈:
fig, ax = plt.subplots(figsize=(8,8))
sm.graphics.influence_plot(lm, ax = ax, alpha=5, criterion="cooks")
plt.show()
这将生成以下图表:

图 4.9:影响图,包括具有最高 Cook 距离的国家
- 在不包括两个离群值的情况下运行模型。
删除这些离群值会影响模型的每个系数,特别是人口密度(即使在 95% 的置信水平下仍然不显著):
covidanalysisminusoutliers = covidanalysis.loc[influence.cooks_d<0.5]
lm = getlm(covidanalysisminusoutliers)
lm.summary()
coef std err t P>|t|
--------------------------------------------------------------
const -2.158e+05 3.43e+04 -6.288 0.000
pop_density 61.2396 34.260 1.788 0.076
median_age 9968.5170 1346.416 7.404 0.000
gdp_per_capita 4.1112 0.704 5.841 0.000
这让我们大致了解了哪些国家在与人口统计变量和每百万人总病例数的关系上与其他国家最为不同。
它是如何工作的...
Cook’s 距离是衡量每个观察值对模型影响程度的指标。两个异常值对模型的巨大影响在 步骤 6 中得到了验证,当我们在不包含这些异常值的情况下重新运行模型时。分析师需要问的问题是,像这些异常值是否提供了重要的信息,还是扭曲了模型并限制了其适用性。第一次回归结果中,median_age 的系数为 11570,表示中位数年龄每增加一年,病例数每百万人增加 11570。这一数字在去除异常值后的模型中大幅缩小,降至 9969。
回归输出中的 P>|t| 值告诉我们系数是否显著不同于 0。在第一次回归中,median_age 和 gdp_per_capita 的系数在 99% 的显著性水平上显著;也就是说,P>|t| 值小于 0.01。
还有更多内容…
在这个食谱中,我们运行了一个线性回归模型,并非主要是因为我们对模型的参数估计感兴趣,而是因为我们想要确定是否有任何观察值对我们可能进行的多元分析产生了潜在的过大影响。显然,这在此情况下确实是成立的。
通常,删除异常值是有道理的,正如我们在这里所做的那样,但这并不总是如此。当我们有能够很好地捕捉异常值不同之处的自变量时,其他自变量的参数估计会更不容易受到扭曲。我们也可以考虑转换,例如我们在前一个食谱中进行的对数转换,以及我们在接下来的两个食谱中将要进行的标准化。根据你的数据,适当的转换可以通过限制极端值的残差大小来减少异常值的影响。
使用 k-最近邻找出异常值
无监督机器学习工具可以帮助我们识别与其他观察结果不同的观察值,特别是在我们没有标签数据的情况下;也就是说,当没有目标变量或因变量时。(在前一个食谱中,我们使用了每百万人总病例数作为因变量。)即使选择目标和因素相对简单,识别异常值而不对变量之间的关系做任何假设也可能会很有帮助。我们可以使用 k-最近邻(KNN)来找出与其他观察值最不同的观测值,即那些它们的值与最近邻值之间差异最大的值。
准备工作
你需要 Python 异常值检测 (PyOD) 和 scikit-learn 来运行这个食谱中的代码。你可以通过在终端或 PowerShell(在 Windows 系统中)输入 pip install pyod 和 pip install sklearn 来安装这两个工具。
操作步骤…
我们将使用 KNN 来识别属性最异常的国家:
-
加载
pandas、pyod和sklearn,以及 COVID-19 病例数据:import pandas as pd from pyod.models.knn import KNN from sklearn.preprocessing import StandardScaler covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) -
为分析列创建一个标准化的 DataFrame:
standardizer = StandardScaler() analysisvars = ['location','total_cases_pm', ... 'total_deaths_pm', 'pop_density', ... 'median_age','gdp_per_capita'] covidanalysis = covidtotals.loc[:, analysisvars].dropna() covidanalysisstand = standardizer.fit_transform(covidanalysis.iloc[:, 1:]) -
运行 KNN 模型并生成异常得分。
我们通过将污染参数设置为 0.1 来创建一个任意数量的异常值:
clf_name = 'KNN'
clf = KNN(contamination=0.1)
clf.fit(covidanalysisstand)
KNN(algorithm='auto', contamination=0.1, leaf_size=30, method='largest',
metric='minkowski', metric_params=None, n_jobs=1, n_neighbors=5, p=2,
radius=1.0)
y_pred = clf.labels_
y_scores = clf.decision_scores_
- 显示模型的预测结果。
从y_pred和y_scores的 NumPy 数组中创建一个数据框。将索引设置为covidanalysis数据框的索引,以便稍后能够轻松地将其与该数据框合并。注意,异常值的决策得分都高于内点(异常值=0)的得分:
pred = pd.DataFrame(zip(y_pred, y_scores),
... columns=['outlier','scores'],
... index=covidanalysis.index)
pred.sample(10, random_state=2)
outlier scores
iso_code
BHR 1 2.69
BRA 0 0.75
ZWE 0 0.21
BGR 1 1.62
CHN 0 0.94
BGD 1 1.52
GRD 0 0.68
UZB 0 0.37
MMR 0 0.37
ECU 0 0.58
pred.outlier.value_counts()
0 162
1 18
Name: outlier, dtype: int64
pred.groupby(['outlier'])[['scores']].agg(['min','median','max'])
scores
min median max
outlier
0 0.08 0.60 1.40
1 1.42 1.65 11.94
- 显示异常值的 COVID-19 数据。
首先,合并covidanalysis和pred数据框:
covidanalysis.join(pred).\
... loc[pred.outlier==1,\
... ['location','total_cases_pm',
... 'total_deaths_pm','scores']].\
... sort_values(['scores'],
... ascending=False).head(10)
location total_cases_pm \
iso_code
SGP Singapore 531,183.84
QAT Qatar 190,908.72
BHR Bahrain 473,167.02
LUX Luxembourg 603,439.46
PER Peru 133,239.00
BRN Brunei 763,475.44
MDV Maldives 356,423.66
MLT Malta 227,422.82
ARE United Arab Emirates 113,019.21
BGR Bulgaria 195,767.89
total_deaths_pm scores
iso_code
SGP 346.64 11.94
QAT 256.02 3.04
BHR 1,043.31 2.69
LUX 1,544.16 2.49
PER 6,507.66 2.27
BRN 396.44 2.26
MDV 603.29 1.98
MLT 1,687.63 1.96
ARE 248.81 1.69
BGR 5,703.52 1.62
这些步骤展示了我们如何利用 KNN 基于多变量关系来识别异常值。
工作原理...
PyOD 是一个 Python 异常值检测工具包。我们在这里将其作为 scikit-learn 的 KNN 包的封装器使用,这简化了某些任务。
本例的重点不是构建模型,而是通过考虑我们拥有的所有数据,了解哪些观察结果(国家)是显著的异常值。这个分析支持我们逐渐形成的看法,即新加坡和卡塔尔在我们的数据集中与其他国家有很大不同。它们具有非常高的决策得分。(步骤 5中的表格按得分降序排列。)
像巴林和卢森堡这样的国家也可能被视为异常值,尽管这种判断不那么明确。之前的方案没有表明它们对回归模型有压倒性的影响。然而,那个模型没有同时考虑每百万病例和每百万死亡人数。这也可能解释为什么新加坡在这里比卡塔尔更为异常。新加坡既有很高的每百万病例,又有低于平均水平的每百万死亡。
Scikit-learn 使得数据标准化变得非常简单。我们在步骤 2中使用了标准化器,它为数据框中的每个值返回了z分数。z分数将每个变量值减去该变量的均值,并除以该变量的标准差。许多机器学习工具需要标准化的数据才能良好运作。
还有更多...
KNN 是一个非常流行的机器学习算法。它易于运行和解释。它的主要限制是,在大型数据集上运行时速度较慢。
我们跳过了构建机器学习模型时通常会采取的一些步骤。例如,我们没有创建单独的训练集和测试集。PyOD 允许轻松完成此操作,但在这里我们并不需要这么做。
另请参见
我们在第八章《编码、转换和标准化特征》中详细介绍了数据转换。关于使用 KNN 的一个好资源是《使用机器学习进行数据清理与探索》,这本书也由我撰写。
PyOD 工具包提供了大量有监督和无监督学习技术,用于检测数据中的异常值。你可以在pyod.readthedocs.io/en/latest/找到相关文档。
使用隔离森林检测异常值
孤立森林是一种相对较新的机器学习技术,用于识别异常值。它之所以快速流行,部分原因是其算法优化于发现异常值而不是正常值。它通过对数据的连续分区来找到异常值,直到一个数据点被孤立出来。需要更少分区来孤立的点将获得较高的异常分数。这个过程在系统资源上相对容易。在这个配方中,我们演示了如何使用它来检测异常的 COVID-19 病例和死亡人数。
准备工作
要运行本配方中的代码,您需要安装 scikit-learn 和 Matplotlib。您可以在终端或 PowerShell(Windows 中)中输入pip install sklearn和pip install matplotlib来安装它们。
如何做到这一点……
我们将使用孤立森林来找出那些属性表明它们是最异常的国家:
-
加载
pandas、matplotlib以及从sklearn中加载的StandardScaler和IsolationForest模块:import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import StandardScaler from sklearn.ensemble import IsolationForest covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) -
创建一个标准化分析的 DataFrame。
首先,删除所有带有缺失数据的行:
analysisvars = ['location','total_cases_pm','total_deaths_pm',
... 'pop_density','median_age','gdp_per_capita']
standardizer = StandardScaler()
covidtotals.isnull().sum()
lastdate 0
location 0
total_cases 0
total_deaths 0
total_cases_pm 0
total_deaths_pm 0
population 0
pop_density 11
median_age 24
gdp_per_capita 27
hosp_beds 45
region 0
dtype: int64
covidanalysis = covidtotals.loc[:, analysisvars].dropna()
covidanalysisstand = standardizer.fit_transform(covidanalysis.iloc[:, 1:])
- 运行孤立森林模型来检测异常值。
将标准化数据传递给fit方法。共有 18 个国家被识别为异常值。(这些国家的异常值为-1。)这是由0.1的污染度数确定的:
clf=IsolationForest(n_estimators=100,
max_samples='auto', contamination=.1,
max_features=1.0)
clf.fit(covidanalysisstand)
IsolationForest(contamination=0.1)
covidanalysis['anomaly'] = \
clf.predict(covidanalysisstand)
covidanalysis['scores'] = \
clf.decision_function(covidanalysisstand)
covidanalysis.anomaly.value_counts()
1 156
-1 18
Name: anomaly, dtype: int64
- 创建异常值和正常值 DataFrame。
根据异常分数列出前 10 个异常值:
inlier, outlier = \
covidanalysis.loc[covidanalysis.anomaly==1],\
covidanalysis.loc[covidanalysis.anomaly==-1]
outlier[['location','total_cases_pm',
'total_deaths_pm','median_age',
'gdp_per_capita','scores']].\
sort_values(['scores']).\
head(10)
location total_cases_pm total_deaths_pm \
iso_code
SGP Singapore 531,183.84 346.64
BHR Bahrain 473,167.02 1,043.31
BRN Brunei 763,475.44 396.44
QAT Qatar 190,908.72 256.02
PER Peru 133,239.00 6,507.66
MLT Malta 227,422.82 1,687.63
MDV Maldives 356,423.66 603.29
LUX Luxembourg 603,439.46 1,544.16
BGR Bulgaria 195,767.89 5,703.52
BGD Bangladesh 11,959.46 172.22
median_age gdp_per_capita scores
iso_code
SGP 42.40 85,535.38 -0.26
BHR 32.40 43,290.71 -0.09
BRN 32.40 71,809.25 -0.09
QAT 31.90 116,935.60 -0.08
PER 29.10 12,236.71 -0.08
MLT 42.40 36,513.32 -0.06
MDV 30.60 15,183.62 -0.06
LUX 39.70 94,277.96 -0.06
BGR 44.70 18,563.31 -0.04
BGD 27.50 3,523.98 -0.04
-
绘制异常值和正常值:
ax = plt.axes(projection='3d') ax.set_title('Isolation Forest Anomaly Detection') ax.set_zlabel("Cases Per Million") ax.set_xlabel("GDP Per Capita") ax.set_ylabel("Median Age") ax.scatter3D(inlier.gdp_per_capita, inlier.median_age, inlier.total_cases_pm, label="inliers", c="blue") ax.scatter3D(outlier.gdp_per_capita, outlier.median_age, outlier.total_cases_pm, label="outliers", c="red") ax.legend() plt.tight_layout() plt.show()
这将生成以下图表:

图 4.10:按人均 GDP、中位年龄和每百万人病例的正常值和异常值国家
上述步骤演示了使用孤立森林作为异常检测的替代方法。
它是如何工作的……
在本配方中,我们使用孤立森林的方式类似于前一配方中使用 KNN 的方式。在步骤 3中,我们传递了一个标准化的数据集给孤立森林的fit方法,然后使用它的predict和decision_function方法来获取异常标志和分数,分别在步骤 4中使用异常标志将数据分成正常值和异常值。
我们在步骤 5中绘制正常值和异常值。由于图中只有三个维度,它并没有完全捕捉到我们孤立森林模型中的所有特征,但是异常值(红色点)明显具有更高的人均 GDP 和中位年龄;这些通常位于正常值的右侧和后方。
孤立森林的结果与 KNN 结果非常相似。新加坡、巴林和卡塔尔是四个最高(最负)异常分数中的三个国家。
还有更多……
孤立森林是 KNN 的一个很好的替代方法,特别是在处理大数据集时。其算法的高效性使其能够处理大样本和大量变量。
我们在过去三个食谱中使用的异常检测技术旨在改进多变量分析和机器学习模型的训练。然而,我们可能希望排除它们帮助我们在分析过程中较早识别的异常值。例如,如果排除卡塔尔对我们的建模有意义,那么排除卡塔尔在某些描述性统计中的数据可能也是合理的。
另见
除了对异常检测有用外,Isolation Forest 算法在直观上也相当令人满意。(我认为 KNN 也可以说是一样的。)你可以在这里阅读更多关于 Isolation Forest 的内容:cs.nju.edu.cn/zhouzh/zhouzh.files/publication/icdm08b.pdf。
使用 PandasAI 识别异常值
我们可以使用 PandasAI 来支持本章中我们为识别异常值所做的一些工作。我们可以根据单变量分析检查极端值。我们还可以查看双变量和多变量关系。PandasAI 还将帮助我们轻松生成可视化。
准备工作
你需要安装 PandasAI 以运行这个食谱中的代码。你可以通过pip install pandasai来安装。我们将再次使用 COVID-19 数据,这些数据可以在 GitHub 仓库中找到,代码也是如此。
你还需要一个来自 OpenAI 的 API 密钥。你可以在platform.openai.com获取一个。你需要注册一个账户,然后点击右上角的个人资料,再点击查看 API 密钥。
PandasAI 库发展迅速,自从我开始编写这本书以来,一些内容已经发生了变化。我在本食谱中使用的是 PandasAI 版本 2.0.30。使用的 Pandas 版本也很重要。我在本食谱中使用的是 Pandas 版本 2.2.1。
如何操作...
我们通过以下步骤创建一个 PandasAI 实例,并用它来查找 COVID-19 数据中的极端和意外值:
-
我们导入
pandas和PandasAI库:import pandas as pd from pandasai.llm.openai import OpenAI from pandasai import SmartDataframe llm = OpenAI(api_token="Your API key") -
我们加载 COVID-19 数据并创建一个
PandasAI SmartDataframe:covidtotals = pd.read_csv("data/covidtotals.csv") covidtotalssdf = SmartDataframe(covidtotals, config={"llm": llm}) -
我们可以将自然语言查询传递给
chat方法的SmartDataframe。这包括绘制图表:covidtotalssdf.chat("Plot histogram of total cases per million")
这生成了以下图表:

图 4.11:每百万总病例的直方图
-
我们还可以创建一个箱型图:
covidtotalssdf.chat("Show box plot of total cases per million")
这生成了以下图表:

图 4.12:每百万总病例的箱型图
-
我们还可以显示病例与死亡之间关系的散点图。我们指定希望使用
regplot,以确保绘制回归线:covidtotalssdf.chat("regplot total_deaths_pm on total_cases_pm")
这生成了以下图表:

图 4.13:病例与死亡之间关系的散点图
-
显示总病例的高值和低值:
covidtotalssdf.chat("Show total cases per million for 7 highest values and 7 lowest values of total cases per million sorted by total cases per million")iso_code location total_cases_pm 190 SVN Slovenia 639,408 67 FRO Faeroe Islands 652,484 194 KOR South Korea 667,207 12 AUT Austria 680,263 180 SMR San Marino 750,727 52 CYP Cyprus 760,161 30 BRN Brunei 763,475 228 YEM Yemen 354 148 NER Niger 363 40 TCD Chad 434 204 TZA Tanzania 660 186 SLE Sierra Leone 904 32 BFA Burkina Faso 975 54 COD Democratic Republic of Congo 1,003
这将高组的数据按升序排序,然后对低组的数据进行升序排序。
-
我们可以找到每个地区病例数最多的国家:
covidtotalssdf.chat("Show total cases per million for locations with highest total cases per million in each region")location total_cases_pm region Caribbean Martinique 626,793 Central Africa Sao Tome and Principe 29,614 Central America Costa Rica 237,539 Central Asia Armenia 162,356 East Africa Reunion 507,765 East Asia Brunei 763,475 Eastern Europe Cyprus 760,161 North Africa Tunisia 93,343 North America Saint Pierre and Miquelon 582,158 Oceania / Aus Niue 508,709 South America Falkland Islands 505,919 South Asia Bahrain 473,167 Southern Africa Saint Helena 401,037 West Africa Cape Verde 108,695 West Asia Israel 512,388 Western Europe San Marino 750,727 -
我们可以让
chat向我们展示病例数较高而死亡数相对较低的国家:covidtotalssdf.chat("Show total cases per million and total deaths per million for locations with high total_cases_pm and low total_deaths_pm")location total_cases_pm total_deaths_pm 30 Brunei 763,475 396 46 Cook Islands 422,910 117 68 Falkland Islands 505,919 0 81 Greenland 211,899 372 93 Iceland 562,822 499 126 Marshall Islands 387,998 409 142 Nauru 424,947 79 150 Niue 508,709 0 156 Palau 346,439 498 167 Qatar 190,909 256 172 Saint Barthelemy 500,910 455 173 Saint Helena 401,037 0 177 Saint Pierre and Miquelon 582,158 340 187 Singapore 531,184 347 209 Tonga 158,608 112 214 Tuvalu 259,638 88 217 United Arab Emirates 113,019 249 226 Vietnam 118,387 440
这些只是 PandasAI 如何帮助我们通过极少的代码找到异常值或意外值的几个示例。
它是如何工作的……
我们在这个食谱中使用了 PandasAI 和 OpenAI 提供的大型语言模型。你只需要一个 API 令牌。你可以从platform.openai.com获取。获得令牌后,开始通过自然语言查询数据库时,只需导入 OpenAI 和SmartDataframe模块并实例化一个SmartDataframe对象。
我们在步骤 2中创建了一个SmartDataframe对象,代码为covidtotalssdf = SmartDataframe(covidtotals, config={"llm": llm})。一旦我们有了SmartDataframe,就可以通过其chat方法传递各种自然语言指令。在这个食谱中,这些指令从请求可视化、查找最高和最低值到检查数据子集的值不等。
定期检查pandasai.log文件是一个好主意,该文件与您的 Python 脚本位于同一文件夹中。以下是 PandasAI 在响应covidtotalssdf.chat("Show total cases per million and total deaths per million for locationss with high total_cases_pm and low total_deaths_pm")时生成的代码:
import pandas as pd
# Filter rows with high total_cases_pm and low total_deaths_pm
filtered_df = dfs[0][(dfs[0]['total_cases_pm'] > 100000) & (dfs[0]['total_deaths_pm'] < 500)]
# Select only the required columns
result_df = filtered_df[['location', 'total_cases_pm', 'total_deaths_pm']]
result = {"type": "dataframe", "value": result_df}
```
```py
2024-04-18 09:30:01 [INFO] 执行步骤 4:CachePopulation
2024-04-18 09:30:01 [INFO] 执行步骤 5:CodeCleaning
2024-04-18 09:30:01 [INFO]
代码运行中:
filtered_df = dfs[0][(dfs[0]['total_cases_pm'] > 100000) & (dfs[0]['total_deaths_pm'] < 500)]
result_df = filtered_df[['location', 'total_cases_pm', 'total_deaths_pm']]
result = {'type': 'dataframe', 'value': result_df}
还有更多…
我们在步骤 4中生成了一个箱型图,这是一个非常有用的工具,用于可视化连续变量的分布。箱体显示了四分位距,即第一四分位数和第三四分位数之间的距离。箱内的线表示中位数。在第五章《使用可视化识别异常值》中,我们会详细讲解箱型图。
另请参见
第三章《衡量您的数据》中关于使用生成型 AI 创建描述性统计的食谱提供了更多信息,介绍了 PandasAI 如何使用 OpenAI,以及如何生成总体和按组统计数据及可视化。我们在本书中随时使用 PandasAI,凡是它能够改善我们的数据准备工作或简化操作时,我们都会使用它。
总结
本章介绍了用于识别数据中异常值的 pandas 工具。我们探索了多种单变量、双变量和多变量方法,用以检测足够偏离范围或其他异常的观测值,这些异常值可能会扭曲我们的分析。这些方法包括使用四分位距来识别极值,调查与相关变量的关系,以及分别使用线性回归和 KNN 等参数化和非参数化的多变量技术。我们还展示了可视化如何帮助我们更好地理解变量的分布情况,以及它如何与相关变量一起变化。下一章我们将详细探讨如何创建和解释可视化。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者讨论:

第五章:使用可视化工具识别意外值
在上一章的多个配方中,我们已初步接触了可视化的内容。我们使用了直方图和 QQ 图来检查单一变量的分布,使用散点图查看两个变量之间的关系。但这仅仅是我们在 Matplotlib 和 Seaborn 库中丰富的可视化工具的冰山一角。熟悉这些工具及其似乎无穷无尽的功能,可以帮助我们发现通过标准描述性统计无法轻易察觉的模式和异常。
例如,箱形图是一个非常适合可视化超出特定范围的值的工具。这些工具可以通过分组箱形图或小提琴图进行扩展,允许我们比较不同数据子集之间的分布。我们还可以用散点图做更多的探索,而不仅仅是上一章所做的那样,甚至可以获得一些关于多变量关系的见解。直方图如果将多个直方图绘制在同一图表中,或者创建堆叠直方图时,也能提供更多的见解。本章将深入探讨这些功能。
本章的配方具体展示了以下内容:
-
使用直方图检查连续变量的分布
-
使用箱形图识别连续变量的异常值
-
使用分组箱形图发现特定组中的意外值
-
使用小提琴图同时检查分布形态和异常值
-
使用散点图查看双变量关系
-
使用折线图检查连续变量的趋势
-
基于相关矩阵生成热图
技术要求
完成本章中的配方,你将需要 Pandas、Numpy、Matplotlib 和 Seaborn。我使用的是pandas 2.1.4,但代码在pandas 1.5.3或更高版本上也能运行。
本章中的代码可以从本书的 GitHub 仓库下载,https://github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
使用直方图检查连续变量的分布
统计学家用来理解单个变量分布的首选可视化工具是直方图。直方图将一个连续变量绘制在X轴上,分箱由研究者确定,频率分布则显示在Y轴上。
直方图提供了一个清晰且有意义的分布形态图示,包括集中趋势、偏度(对称性)、超额峰度(相对较胖的尾部)和分散程度。这对于统计检验至关重要,因为许多检验会假设变量的分布。此外,我们对数据值的预期应该以对分布形态的理解为指导。例如,来自正态分布的 90^(th)百分位值与来自均匀分布的值有着非常不同的含义。
我要求初学统计学的学生做的第一项任务之一是从一个小样本手动构建直方图。下一节课我们将学习箱型图。直方图和箱型图一起为后续分析提供了坚实的基础。在我的数据科学工作中,我尽量记得在数据导入和清理之后,尽快为所有感兴趣的连续变量构建直方图和箱型图。在本食谱中,我们将创建直方图,接下来的两个食谱中我们将创建箱型图。
准备工作
我们将使用Matplotlib库生成直方图。在 Matplotlib 中,一些任务可以快速且直接地完成,直方图就是其中之一。在本章中,我们将根据哪个工具更容易生成所需的图形,来在 Matplotlib 和基于 Matplotlib 的 Seaborn 之间切换。
我们还将使用statsmodels库。你可以使用pip install matplotlib和pip install statsmodels通过 pip 安装 Matplotlib 和 statsmodels。
在本食谱中,我们将处理地球表面温度和 COVID-19 病例的数据。地表温度数据框包含每个天气站的一行数据。COVID-19 数据框包含每个国家的一行数据,其中包括总病例数和人口统计信息。
注意
地表温度数据框包含来自全球超过 12,000 个站点的 2023 年平均温度读数(单位:°C),尽管大多数站点位于美国。原始数据来自全球历史气候网络(GHCN)集成数据库。美国国家海洋和大气管理局提供该数据供公众使用,网址为www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly。
我们的世界数据提供了供公众使用的 COVID 数据,网址为ourworldindata.org/covid-cases。本食谱中使用的数据是 2024 年 3 月 3 日下载的。
如何操作……
我们仔细查看了 2023 年各天气站的地表温度分布,以及每个国家每百万人的 COVID-19 总病例数。我们首先进行一些描述性统计,然后绘制 QQ 图、直方图和堆积直方图:
- 导入
pandas、matplotlib和statsmodels库。
此外,加载地表温度和 COVID-19 病例的数据:
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.api as sm
landtemps = pd.read_csv("data/landtemps2023avgs.csv")
covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=["lastdate"])
covidtotals.set_index("iso_code", inplace=True)
- 显示一些天气站的温度行。
latabs列是纬度的值,不包括北方或南方指示符;因此,位于大约 30 度北纬的埃及开罗和位于大约 30 度南纬的巴西阿雷格里港有相同的值:
landtemps[['station','country','latabs',
... 'elevation','avgtemp']].\
... sample(10, random_state=1)
station country \
11924 WOLF_POINT_29_ENE United States
10671 LITTLE_GRASSY United States
10278 FLOWERY_TRAIL_WASHINGTON United States
8436 ROCKSPRINGS United States
1715 PETERBOROUGH Canada
5650 TRACY_PUMPING_PLT United States
335 NEPTUNE_ISLAND Australia
372 EUDUNDA Australia
2987 KOZHIKODE India
7588 TRYON United States
latabs elevation avgtemp
11924 48 636 6
10671 37 1,859 10
10278 48 792 8
8436 30 726 20
1715 44 191 8
5650 38 19 18
335 35 32 16
372 34 415 16
2987 11 5 30
7588 35 366 16
- 显示一些描述性统计信息。
此外,查看偏度和峰度:
landtemps.describe()
latabs elevation avgtemp
count 12,137 12,137 12,137
mean 40 598 12
std 13 775 8
min 0 -350 -57
25% 35 78 6
50% 41 271 11
75% 47 824 17
max 90 9,999 34
landtemps.avgtemp.skew()
-0.3856060165979757
landtemps.avgtemp.kurtosis()
2.7939884544586033
- 绘制平均温度的直方图。
此外,在整体均值处绘制一条线:
plt.hist(landtemps.avgtemp)
plt.axvline(landtemps.avgtemp.mean(), color='red', linestyle='dashed', linewidth=1)
plt.title("Histogram of Average Temperatures (Celsius)")
plt.xlabel("Average Temperature")
plt.ylabel("Frequency")
plt.show()
这将产生以下直方图:

图 5.1:2019 年各气象站的平均温度直方图
- 运行 QQ 图,检查分布与正态分布的偏离情况。
注意到大部分温度分布沿着红线分布(如果分布完全正常,所有点都会落在红线上,但尾部明显偏离正常分布):
sm.qqplot(landtemps[['avgtemp']].sort_values(['avgtemp']), line='s')
plt.title("QQ Plot of Average Temperatures")
plt.show()
这产生了以下 QQ 图:

图 5.2:各气象站的平均温度与正态分布的对比图
- 显示每百万的总 COVID-19 病例的偏度和峰度。
这来自 COVID-19 数据框,其中每一行代表一个国家:
covidtotals.total_cases_pm.skew()
0.8349032460009967
covidtotals.total_cases_pm.kurtosis()
-0.4280595203351645
- 做一个 COVID-19 病例数据的堆叠直方图。
从四个地区选择数据。(堆叠直方图如果类别超过这个数目会显得很杂乱。)定义一个 getcases 函数,返回一个地区内各国的 total_cases_pm Series。将这些 Series 传递给 hist 方法([getcases(k) for k in showregions])以创建堆叠直方图。注意,西欧与其他地区的差异,几乎所有每百万病例数超过 500,000 的国家都位于该地区:
showregions = ['Oceania / Aus','East Asia','Southern Africa',
... 'Western Europe']
def getcases(regiondesc):
... return covidtotals.loc[covidtotals.\
... region==regiondesc,
... 'total_cases_pm']
...
plt.hist([getcases(k) for k in showregions],\
... color=['blue','mediumslateblue','plum','mediumvioletred'],\
... label=showregions,\
... stacked=True)
plt.title("Stacked Histogram of Cases Per Million for Selected Regions")
plt.xlabel("Cases Per Million")
plt.ylabel("Frequency")
plt.legend()
plt.show()
这产生了以下堆叠直方图:

图 5.3:按地区分布的各国病例数的堆叠直方图,每百万人的不同病例数水平
- 在同一图形中显示多个直方图。
这允许不同的 x 和 y 坐标轴值。我们需要循环遍历每个坐标轴,并为每个子图选择 showregions 中的一个不同地区:
fig, axes = plt.subplots(2, 2)
fig.suptitle("Histograms of COVID-19 Cases Per Million by Selected Regions")
axes = axes.ravel()
for j, ax in enumerate(axes):
... ax.hist(covidtotals.loc[covidtotals.region==showregions[j]].\
... total_cases_pm, bins=7)
... ax.set_title(showregions[j], fontsize=10)
... for tick in ax.get_xticklabels():
... tick.set_rotation(45)
...
plt.tight_layout()
fig.subplots_adjust(top=0.88)
plt.show()
这产生了以下直方图:

图 5.4:按每百万病例不同水平,按地区分布的国家数量的直方图
之前的步骤演示了如何通过直方图和 QQ 图来可视化连续变量的分布。
它是如何工作的……
步骤 4 显示了显示直方图是多么容易。这可以通过将一个 Series 传递给 Matplotlib 的 pyplot 模块中的 hist 方法来完成。(我们为 Matplotlib 使用了 plt 的别名。)我们也可以传递任何 ndarray,甚至是数据 Series 的列表。
我们还可以很好地访问图形及其坐标轴的属性。我们可以设置每个坐标轴的标签、刻度标记和刻度标签。我们还可以指定图例的内容及其外观。在本章中,我们将经常利用这一功能。
我们将多个 Series 传递给 步骤 7 中的 hist 方法,以生成堆叠直方图。每个 Series 是一个地区内各国的 total_cases_pm(每百万人的病例数)值。为了获得每个地区的 Series,我们为 showregions 中的每个项调用 getcases 函数。我们为每个 Series 选择颜色,而不是让系统自动分配颜色。我们还使用 showregions 列表选择图例的标签。
在步骤 8中,我们首先指定要创建四个子图,分为两行两列。这就是 plt.subplots(2, 2) 的返回结果,它返回了图形和四个坐标轴。我们通过 for j, ax in enumerate(axes) 循环遍历坐标轴。在每次循环中,我们从 showregions 中选择一个不同的区域来绘制直方图。在每个坐标轴内,我们循环遍历刻度标签并改变其旋转角度。我们还调整子图的起始位置,以腾出足够的空间放置图形标题。请注意,在这种情况下,我们需要使用 suptitle 来添加标题,使用 title 只会将标题添加到子图中。
还有更多...
陆地温度数据并不是完全正态分布的,正如步骤 3-5中的直方图以及偏度和峰度测量所显示的那样。它偏向左侧(偏度为 -0.39),且尾部接近正态分布(峰度为 2.79,接近 3)。虽然存在一些极端值,但与数据集的整体规模相比,它们并不多。虽然数据分布不是完美的钟形曲线,但陆地温度数据框比 COVID-19 病例数据要容易处理得多。
COVID-19 的 每百万病例 变量的偏度和峰度显示它与正态分布相距较远。偏度为 0.83,峰度为 -0.43,表明该分布具有一定的正偏态,并且尾部比正态分布更窄。这在直方图中也得到了体现,即使我们按地区查看数据。大多数地区有许多国家的每百万病例数非常低,只有少数几个国家病例数很高。本章中的使用分组箱型图揭示某一特定组中的异常值示例显示几乎每个地区都有异常值。
如果你完成了本章中的所有示例,并且你对 Matplotlib 和 Seaborn 相对较为陌生,你会发现这两个库要么是灵活实用,要么是灵活得让人困惑。甚至很难选择一种策略并坚持下去,因为你可能需要以某种特定的方式设置图形和坐标轴才能获得你想要的可视化效果。在处理这些示例时,记住两件事是很有帮助的:第一,通常你需要创建一个图形和一个或多个子图;第二,主要的绘图函数通常是类似的,因此 plt.hist 和 ax.hist 都能经常奏效。
使用箱型图识别连续变量的异常值
箱型图本质上是我们在第四章:使用单个变量识别异常值中所做工作的图形表示,在这一章中我们使用了四分位距(IQR)的概念——即第一个四分位数和第三个四分位数之间的距离——来确定异常值。任何大于(1.5 * IQR)+ 第三个四分位数的值,或小于第一个四分位数 - (1.5 * IQR) 的值,都被认为是异常值。箱型图正是揭示了这一点。
准备工作
我们将使用关于 COVID-19 病例和死亡数据的累计数据,以及国家纵向调查(NLS)数据。你需要安装 Matplotlib 库,以便在你的计算机上运行代码。
如何操作…
我们使用箱线图来展示学术能力评估测试(SAT)分数、工作周数以及 COVID-19 病例和死亡数的形状和分布:
- 加载
pandas和matplotlib库。
另外,加载 NLS 和 COVID-19 数据:
import pandas as pd
import matplotlib.pyplot as plt
nls97 = pd.read_csv("data/nls97f.csv", low_memory=False)
nls97.set_index("personid", inplace=True)
covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=["lastdate"])
covidtotals.set_index("iso_code", inplace=True)
- 绘制 SAT 语言成绩的箱线图。
首先生成一些描述性统计。boxplot方法生成一个矩形,表示四分位数间距(IQR),即第一和第三四分位数之间的值。须根从该矩形延伸到 1.5 倍的 IQR。任何超过须根范围的值(我们标记为异常值阈值)都被视为异常值(我们使用annotate标记第一和第三四分位点、中位数以及异常值阈值):
nls97.satverbal.describe()
count 1,406
mean 500
std 112
min 14
25% 430
50% 500
75% 570
max 800
Name: satverbal, dtype: float64
plt.boxplot(nls97.satverbal.dropna(), labels=['SAT Verbal'])
plt.annotate('outlier threshold', xy=(1.05,780), xytext=(1.15,780), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02))
plt.annotate('3rd quartile', xy=(1.08,570), xytext=(1.15,570), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02))
plt.annotate('median', xy=(1.08,500), xytext=(1.15,500), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02))
plt.annotate('1st quartile', xy=(1.08,430), xytext=(1.15,430), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02))
plt.annotate('outlier threshold', xy=(1.05,220), xytext=(1.15,220), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02))
plt.title("Boxplot of SAT Verbal Score")
plt.show()
这将生成以下箱线图:

图 5.5:带有四分位数范围和异常值标签的 SAT 语言成绩箱线图
-
接下来,显示一些关于工作周数的描述性统计:
weeksworked = nls97.loc[:, ['highestdegree', 'weeksworked20','weeksworked21']] weeksworked.describe()weeksworked20 weeksworked21 count 6,971 6,627 mean 38 36 std 21 18 min 0 0 25% 21 35 50% 52 43 75% 52 50 max 52 52 -
绘制工作周数的箱线图:
plt.boxplot([weeksworked.weeksworked20.dropna(), weeksworked.weeksworked21.dropna()], labels=['Weeks Worked 2020','Weeks Worked 2021']) plt.title("Boxplots of Weeks Worked") plt.tight_layout() plt.show()
这将生成以下箱线图:

图 5.6:并排显示的两个变量的箱线图
- 显示 COVID-19 数据的一些描述性统计。
创建一个标签列表(totvarslabels),供后续步骤使用:
totvars = ['total_cases','total_deaths',
... 'total_cases_pm','total_deaths_pm']
totvarslabels = ['cases','deaths',
... 'cases per million','deaths per million']
covidtotalsonly = covidtotals[totvars]
covidtotalsonly.describe()
total_cases total_deaths total_cases_pm total_deaths_pm
count 231 231 231 231
mean 3,351,599 30,214 206,178 1,262
std 11,483,212 104,779 203,858 1,315
min 4 0 354 0
25% 25,672 178 21,822 141
50% 191,496 1,937 133,946 827
75% 1,294,286 14,150 345,690 1,998
max 103,436,829 1,127,152 763,475 6,508
-
绘制每百万病例和死亡数的箱线图:
fig, ax = plt.subplots() plt.title("Boxplots of COVID-19 Cases and Deaths Per Million") ax.boxplot([covidtotalsonly.total_cases_pm,covidtotalsonly.total_deaths_pm],\ ... labels=['cases per million','deaths per million']) plt.tight_layout() plt.show()
这将生成以下箱线图:

图 5.7:并排显示的两个变量的箱线图
- 将箱线图作为单独的子图显示在一张图上。
当变量值差异很大时(例如 COVID-19 病例和死亡数),在一张图上查看多个箱线图会很困难。幸运的是,Matplotlib 允许我们在每张图上创建多个子图,每个子图可以使用不同的x和y轴。我们还可以将数据以千为单位呈现,以提高可读性:
fig, axes = plt.subplots(2, 2)
fig.suptitle("Boxplots of COVID-19 Cases and Deaths in Thousands")
axes = axes.ravel()
for j, ax in enumerate(axes):
ax.boxplot(covidtotalsonly.iloc[:, j]/1000, labels=[totvarslabels[j]])
plt.tight_layout()
fig.subplots_adjust(top=0.9)
plt.show()
这将生成以下箱线图:

图 5.8:具有不同 y 轴的箱线图
箱线图是一种相对简单但极其有用的方式,用于查看变量的分布情况。它们使得在一个图形中同时可视化数据的分布、集中趋势和异常值变得非常容易。
它是如何工作的…
使用 Matplotlib 创建箱线图相当简单,正如步骤 2所示。只需将一个系列传递给 pyplot(我们使用plt别名)即可。我们调用 pyplot 的show方法来显示图形。这一步也展示了如何使用annotate向图形添加文本和符号。我们在步骤 4中通过将多个系列传递给 pyplot 来展示多个箱线图。
当尺度差异很大时(例如 COVID-19 结果数据:病例数、死亡数、每百万病例数和每百万死亡数),在单一图形中显示多个箱形图可能会很困难。步骤 7展示了处理这一问题的方法之一。我们可以在一张图上创建多个子图。首先,我们指定希望有四个子图,分布在两列两行。这就是plt.subplots(2, 2)所得到的结果,它返回一个图形和四个坐标轴。然后,我们可以遍历这些坐标轴,在每个上调用boxplot。挺巧妙的!
然而,由于某些极端值的存在,仍然很难看到病例和死亡数的四分位距(IQR)。在下一个示例中,我们去除一些极端值,以便更好地可视化剩余数据。
还有更多内容...
步骤 2中的 SAT 语文分数的箱形图表明该数据呈现相对正态分布。中位数接近 IQR 的中心位置。这并不令人惊讶,因为我们所做的描述性统计显示均值和中位数的值相同。然而,较低端的异常值空间远大于上端。(实际上,SAT 语文分数非常低似乎不可信,应该进行检查。)
步骤 4中的 2020 年和 2021 年工作的周数的箱形图显示了与 SAT 分数相比,变量分布差异非常大。中位数远高于均值,这表明存在负偏态。此外,注意到 2020 年值的分布上端没有胡须或异常值,因为中位数处于最大值附近或等于最大值。
另见
一些箱形图表明我们正在检查的数据并非呈正态分布。第四章中的识别异常值示例涵盖了一些正态分布检验。它还展示了如何更仔细地查看超出异常值阈值的值:即箱形图中的圆点。
使用分组箱形图发现特定组中的意外值
在前面的示例中,我们看到箱形图是检查连续变量分布的一个很好的工具。当我们想查看不同部分的数据集是否具有不同的分布时,箱形图也很有用,例如不同年龄组的薪资、按婚姻状况划分的子女数量,或不同哺乳动物物种的胎儿大小。分组箱形图是通过类别查看变量分布差异的一个方便直观的方式。
准备工作
我们将使用 NLS 和 COVID-19 病例数据。你需要在计算机上安装 Matplotlib 和 Seaborn 库,以便运行本示例中的代码。
如何操作...
我们生成了按最高学位获得情况分类的工作周数的描述性统计数据。然后,我们使用分组箱形图来可视化按学位类别划分的工作周数分布以及按地区划分的 COVID-19 病例分布。
-
导入
pandas、matplotlib和seaborn库:import pandas as pd import matplotlib.pyplot as plt import seaborn as sns nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=["lastdate"]) covidtotals.set_index("iso_code", inplace=True) -
查看每个学位获得水平的工作周数的中位数以及第一和第三四分位数值。
首先,定义一个返回这些值的函数作为 Series,然后使用apply对每个组调用该函数:
def gettots(x):
... out = {}
... out['min'] = x.min()
... out['qr1'] = x.quantile(0.25)
... out['med'] = x.median()
... out['qr3'] = x.quantile(0.75)
... out['max'] = x.max()
... out['count'] = x.count()
... return pd.Series(out)
...
nls97.groupby(['highestdegree'])['weeksworked21'].\
apply(gettots).unstack()
min qr1 med qr3 max count
highestdegree
0\. None 0 0 39 49 52 487
1\. GED 0 7 42 50 52 853
2\. High School 0 27 42 50 52 2,529
3\. Associates 0 38 43 49 52 614
4\. Bachelors 0 40 43 50 52 1,344
5\. Masters 0 41 45 52 52 614
6\. PhD 0 41 44 49 52 59
7\. Professional 0 41 45 51 52 105
- 绘制按最高学位划分的工作周数的箱线图。
使用 Seaborn 库绘制这些箱线图。首先,创建一个子图并命名为myplt。这使得稍后访问子图属性更加方便。使用boxplot的order参数按最高学位排序。注意,对于那些从未获得学位的人群,低端没有异常值或胡须,因为这些人群的 IQR 几乎覆盖了所有的值范围。25%分位数的值为 0:
myplt = \
sns.boxplot(x='highestdegree',y='weeksworked21',
data=nls97,
order=sorted(nls97.highestdegree.dropna().unique()))
myplt.set_title("Boxplots of Weeks Worked by Highest Degree")
myplt.set_xlabel('Highest Degree Attained')
myplt.set_ylabel('Weeks Worked 2021')
myplt.set_xticklabels(myplt.get_xticklabels(), rotation=60, horizontalalignment='right')
plt.tight_layout()
plt.show()
这将产生以下的箱线图:

图 5.9:按最高学位划分的工作周数的箱线图,包含 IQR 和异常值
- 查看按区域划分的每百万人病例的最小值、最大值、中位数,以及第一和第三四分位数值。
使用在步骤 2中定义的gettots函数:
covidtotals.groupby(['region'])['total_cases_pm'].\
apply(gettots).unstack()
min qr1 med qr3 max count
region
Caribbean 2,979 128,448 237,966 390,758 626,793 26
Central Af 434 2,888 4,232 9,948 29,614 11
Central Am 2,319 38,585 70,070 206,306 237,539 7
Central As 1,787 7,146 45,454 79,795 162,356 6
East Africa 660 2,018 4,062 71,435 507,765 15
East Asia 8,295 26,930 69,661 285,173 763,475 15
East. Eu 104,252 166,930 223,685 459,646 760,161 21
North Afr 4,649 6,058 34,141 74,463 93,343 5
North Am 60,412 108,218 214,958 374,862 582,158 4
Oceania/Aus 4,620 75,769 259,196 356,829 508,709 24
South Am 19,529 101,490 133,367 259,942 505,919 14
South As 5,630 11,959 31,772 80,128 473,167 9
South. Af 4,370 15,832 40,011 67,775 401,037 10
West Af 363 1,407 2,961 4,783 108,695 16
West Asia 354 78,447 123,483 192,995 512,388 16
West. Eu 32,178 289,756 465,940 587,523 750,727 32
- 绘制按区域划分的每百万人病例的箱线图。
由于区域数量较多,需要翻转坐标轴。同时,绘制一个蜂群图,以显示按区域划分的国家数量。蜂群图为每个区域中的每个国家显示一个点。由于极端值,某些 IQR(四分位距)难以观察:
sns.boxplot(x='total_cases_pm', y='region', data=covidtotals)
sns.swarmplot(y="region", x="total_cases_pm", data=covidtotals, size=2, color=".3", linewidth=0)
plt.title("Boxplots of Total Cases Per Million by Region")
plt.xlabel("Cases Per Million")
plt.ylabel("Region")
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
这将产生以下的箱线图:

图 5.10:按区域划分的每百万人病例的箱线图和蜂群图,包含 IQR 和异常值
-
显示每百万人病例的最高值:
highvalue = covidtotals.total_cases_pm.quantile(0.9) highvalue512388.401covidtotals.loc[covidtotals.total_cases_pm>=highvalue,\ ['location','total_cases_pm']]location total_cases_pm iso_code AND Andorra 601,367.7 AUT Austria 680,262.6 BRN Brunei 763,475.4 CYP Cyprus 760,161.5 DNK Denmark 583,624.9 FRO Faeroe Islands 652,484.1 FRA France 603,427.6 GIB Gibraltar 628,882.7 GRC Greece 540,380.1 GLP Guadeloupe 513,528.3 GGY Guernsey 557,817.1 ISL Iceland 562,822.0 ISR Israel 512,388.4 JEY Jersey 599,218.4 LVA Latvia 528,300.3 LIE Liechtenstein 548,113.3 LUX Luxembourg 603,439.5 MTQ Martinique 626,793.1 PRT Portugal 549,320.5 SPM Saint Pierre and Miquelon 582,158.0 SMR San Marino 750,727.2 SGP Singapore 531,183.8 SVN Slovenia 639,407.7 KOR South Korea 667,207.1 -
重新绘制不包含极端值的箱线图:
sns.boxplot(x='total_cases_pm', y='region', data=covidtotals.loc[covidtotals.total_cases_pm<highvalue]) sns.swarmplot(y="region", x="total_cases_pm", data=covidtotals.loc[covidtotals.total_cases_pm<highvalue], size=3, color=".3", linewidth=0) plt.title("Total Cases Without Extreme Values") plt.xlabel("Cases Per Million") plt.ylabel("Region") plt.tight_layout() plt.show()
这将产生以下的箱线图:

图 5.11:按区域划分的每百万人病例的箱线图(不包含极端值)
这些分组箱线图展示了按人口调整后的病例分布在各个区域之间的变化情况。
工作原理...
我们在本配方中使用 Seaborn 库来创建图形。我们也可以使用 Matplotlib。实际上,Seaborn 是基于 Matplotlib 构建的,扩展了 Matplotlib 的某些功能,并使一些操作变得更加简单。与 Matplotlib 的默认设置相比,它有时能生成更加美观的图形。
在创建包含多个箱线图的图形之前,最好先准备一些描述性信息。在步骤 2中,我们获取每个学位获得水平的第一个和第三个四分位数值,以及中位数。我们通过首先创建一个名为gettots的函数来实现,该函数返回一个包含这些值的序列。我们通过以下语句将gettots应用于数据框中的每个组:
nls97.groupby(['highestdegree'])['weeksworked21'].apply(gettots).unstack()
groupby方法创建一个包含分组信息的数据框,并将其传递给apply函数。然后,gettots为每个组计算汇总值。unstack方法将返回的行进行重塑,将每组中的多行(每个汇总统计量一行)转换为每组一行,并为每个汇总统计量创建列。
在Step 3中,我们为每个学位层次生成一个箱线图。当我们使用 Seaborn 的boxplot方法时,通常不需要为创建的子图对象命名。但在这一步中,我们将其命名为myplt,以便稍后可以轻松更改属性,例如刻度标签。我们使用set_xticklabels旋转x轴上的标签,以避免它们重叠。
在Step 5中,我们翻转箱线图的轴,因为区域的层级比连续变量每百万病例数的刻度多。为此,我们将total_cases_pm作为第一个参数的值,而不是第二个。我们还做了一个 swarm plot,以便了解每个地区的观察数量(国家)。
极端值有时会使箱线图难以查看。箱线图显示异常值和四分位数间距(IQR),但当异常值是第三或第一四分位值的数倍时,IQR 矩形将非常小而难以查看。在Step 7中,我们删除所有大于或等于 512,388 的total_cases_pm值。这改善了可视化细节的呈现。
还有更多内容…
在Step 3中的教育程度的周工作箱线图显示了工作周数的高变异性,这在单变量分析中并不明显。教育程度越低,工作周数的波动就越大。2021 年,持有高中以下学历的个体的工作周数存在相当大的变异性,而拥有大学学位的个体的变异性非常小。
这对于我们理解以工作周数衡量的异常值是非常相关的。例如,一个拥有大学学位的人工作了 20 周,算是一个异常值,但如果他们只有高中以下文凭,则不会被视为异常值。
每百万人口病例数的箱线图也邀请我们更加灵活地思考什么是异常值。例如,在北非的每百万人口病例数的异常值在整个数据集中并不算是高异常值。北非的最大值实际上低于西欧的第一四分位数值。
当我看箱线图时,我首先注意到的是中位数在 IQR 中的位置。如果中位数与中心完全不接近,我知道我在处理的不是正态分布的变量。它还让我对偏斜的方向有了很好的感觉。如果它靠近 IQR 的底部,意味着中位数比第三四分位数接近第一四分位数,那么存在正偏斜。比较东欧和西欧的箱线图。大量低值和少量高值使东欧的中位数接近西欧的第一四分位数值。
另请参见
在第九章 解决混乱数据聚合中,我们更多地使用groupby。在第十一章 整理和重塑数据中,我们更多地使用stack和unstack。
使用小提琴图检查分布形状和异常值
小提琴图将直方图和箱线图结合在一张图中。它们显示了 IQR、中位数、须条,以及各个数值范围内观察值的频率。如果没有实际的小提琴图,很难想象这是如何做到的。我们使用与上一个食谱中箱线图相同的数据生成几个小提琴图,以便更容易理解它们的工作原理。
准备工作
我们将使用 NLS 数据。你需要在计算机上安装 Matplotlib 和 Seaborn 才能运行本食谱中的代码。
如何操作…
我们使用小提琴图来查看分布的范围和形态,并将它们显示在同一图表中。然后我们按组进行小提琴图绘制:
-
加载
pandas、matplotlib和seaborn,以及 NLS 数据:import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
绘制 SAT verbal 分数的小提琴图:
sns.violinplot(y=nls97.satverbal, color="wheat", orient="v") plt.title("Violin Plot of SAT Verbal Score") plt.ylabel("SAT Verbal") plt.text(0.08, 780, 'outlier threshold', horizontalalignment='center', size='x-small') plt.text(0.065, nls97.satverbal.quantile(0.75), '3rd quartile', horizontalalignment='center', size='x-small') plt.text(0.05, nls97.satverbal.median(), 'Median', horizontalalignment='center', size='x-small') plt.text(0.065, nls97.satverbal.quantile(0.25), '1st quartile', horizontalalignment='center', size='x-small') plt.text(0.08, 210, 'outlier threshold', horizontalalignment='center', size='x-small') plt.text(-0.4, 500, 'frequency', horizontalalignment='center', size='x-small') plt.show()
这将产生以下小提琴图:

图 5.12:SAT verbal 分数的小提琴图,带有 IQR 和异常值阈值的标签
-
获取工作周数的描述性统计:
nls97.loc[:, ['weeksworked20','weeksworked21']].describe()weeksworked20 weeksworked21 count 6,971 6,627 mean 38 36 std 21 18 min 0 0 25% 21 35 50% 52 43 75% 52 50 max 52 52 -
显示 2020 年和 2021 年的工作周数。
使用更面向对象的方法,以便更容易访问某些轴的属性。请注意,weeksworked 分布是双峰的,分布的顶部和底部都有峰值。另外,注意 2020 年和 2021 年的 IQR 非常不同:
myplt = sns.violinplot(data=nls97.loc[:, ['weeksworked20','weeksworked21']])
myplt.set_title("Violin Plots of Weeks Worked")
myplt.set_xticklabels(["Weeks Worked 2020","Weeks Worked 2021"])
plt.show()
这将产生以下小提琴图:

图 5.13:显示两个变量分布范围和形态的小提琴图,按组并排展示
- 按性别和婚姻状况绘制工资收入的小提琴图。
首先,创建一个合并的婚姻状况列。指定性别为 x 轴,工资为 y 轴,新的合并婚姻状况列为 hue。hue 参数用于分组,这将与 x 轴已使用的任何分组一起添加。我们还指定 scale="count",以便根据每个类别中的观察值数量生成大小不同的小提琴图:
nls97["maritalstatuscollapsed"] = \
nls97.maritalstatus.replace(['Married',
'Never-married','Divorced','Separated',
'Widowed'],\
['Married','Never Married','Not Married',
'Not Married','Not Married'])
sns.violinplot(x="gender", y="wageincome20", hue="maritalstatuscollapsed",
data=nls97, scale="count")
plt.title("Violin Plots of Wage Income by Gender and Marital Status")
plt.xlabel('Gender')
plt.ylabel('Wage Income 2020')
plt.legend(title="", loc="upper center", framealpha=0, fontsize=8)
plt.tight_layout()
plt.show()
这将产生以下小提琴图:

图 5.14:显示两个不同组之间分布范围和形态的小提琴图
-
按最高学历绘制工作周数的小提琴图:
nls97 = nls97.sort_values(['highestdegree']) myplt = sns.violinplot(x='highestdegree',y='weeksworked21', data=nls97) myplt.set_xticklabels(myplt.get_xticklabels(), rotation=60, horizontalalignment='right') myplt.set_title("Violin Plots of Weeks Worked by Highest Degree") myplt.set_xlabel('Highest Degree Attained') myplt.set_ylabel('Weeks Worked 2021') plt.tight_layout() plt.show()
这将产生以下小提琴图:

图 5.15:按组显示分布范围和形态的小提琴图
这些步骤展示了小提琴图如何告诉我们 DataFrame 中连续变量的分布情况,以及它们在不同组之间可能的变化。
工作原理…
类似于箱型图,小提琴图显示了中位数、第一四分位数、第三四分位数和胡须。它们还显示了变量值的相对频率。(当小提琴图垂直显示时,相对频率就是某一点的宽度。)第 2 步中生成的小提琴图及其相关注释提供了一个很好的示例。从小提琴图中我们可以看出,SAT 语文成绩的分布与正态分布没有显著差异,除了低端的极端值外。最大隆起(最大宽度)出现在中位数处,从那里对称地下降。中位数与第一和第三四分位数的距离大致相等。
我们可以通过将一个或多个数据系列传递给violinplot方法来在 Seaborn 中创建小提琴图。我们也可以传递整个 DataFrame 中的一列或多列。在第 4 步中,我们这么做是因为我们希望绘制多个连续变量。
有时我们需要稍微调整图例,以使其既具有信息性,又不显得突兀。在第 5 步中,我们使用了以下命令来移除图例标题(因为从数值中已经很清楚),将图例放置在图形的最佳位置,并使框体透明(framealpha=0):
plt.legend(title="", loc="upper center", framealpha=0, fontsize=8)
还有更多…
一旦你掌握了小提琴图,你就会欣赏到它在一张图上呈现的海量信息。我们可以了解分布的形状、中心趋势和分散程度。我们也可以轻松地展示不同数据子集的这些信息。
2020 年工作周数的分布与 2021 年工作周数的分布差异足够大,以至于让细心的分析师停下来思考。2020 年的四分位距(IQR)为 31(从 21 到 52),而 2021 年为 15(从 35 到 50)。(2020 年的工作周数分布可能受到疫情的影响。)
在检查第 5 步中生成的小提琴图时,揭示了一个关于工资收入分布的不寻常现象。已婚男性的收入在分布的顶部出现了集中现象,已婚女性也有类似的现象。这对于工资收入分布来说是相当不寻常的。事实证明,工资收入有一个上限为$380,288。这是我们在未来包括工资收入的分析中必须考虑的一个因素。
不同性别和婚姻状况的收入分布形状相似,都在中位数下方略有隆起,并具有延伸的正尾。四分位距的长度相对相似。然而,已婚男性的分布明显高于(或在选择的方向上偏右)其他组。
按学历划分的工作周数的提琴图显示出不同组别间的分布差异,正如我们在上一节的箱型图中发现的那样。更清晰的一点是,低学历人群的分布呈双峰态。在没有大学学位的人群中,工作周数较少的集中在低周数(例如工作 5 周或更少),而没有高中文凭的人群在 2021 年工作 5 周或更少的可能性几乎和工作 50 周或更多的可能性一样。
在本食谱中,我们仅使用了 Seaborn 来生成提琴图。Matplotlib 也可以生成提琴图,但 Matplotlib 中提琴图的默认图形与 Seaborn 的图形差别很大。
另请参阅
将本节中本食谱的提琴图与本章前面的直方图、箱型图和分组箱型图进行比较可能会有所帮助。
使用散点图查看双变量关系
我的感觉是,数据分析师依赖的图表中,散点图是最常见的图表之一,可能只有直方图例外。我们都非常习惯查看可以在二维平面上展示的关系。散点图捕捉了重要的现实世界现象(变量之间的关系),并且对大多数人来说非常直观。这使得它们成为我们可视化工具箱中不可或缺的一部分。
准备工作
本食谱需要Matplotlib和Seaborn。我们将使用landtemps数据集,它提供了 2023 年全球 12,137 个气象站的平均温度数据。
如何操作……
我们在上一章提升了散点图技能,能够可视化更加复杂的关系。我们通过在一张图表中显示多个散点图、创建三维散点图以及显示多条回归线,来展示平均温度、纬度和海拔之间的关系:
-
加载
pandas、numpy、matplotlib和seaborn:import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns landtemps = pd.read_csv("data/landtemps2023avgs.csv") -
运行纬度(
latabs)与平均温度的散点图:plt.scatter(x="latabs", y="avgtemp", data=landtemps) plt.xlabel("Latitude (N or S)") plt.ylabel("Average Temperature (Celsius)") plt.yticks(np.arange(-60, 40, step=20)) plt.title("Latitude and Average Temperature in 2023") plt.show()
结果如下散点图:

图 5.16:按平均温度绘制的纬度散点图
- 用红色显示高海拔点。
创建低海拔和高海拔的数据框。请注意,在每个纬度下,高海拔点通常较低(即温度较低):
low, high = landtemps.loc[landtemps.elevation<=1000], landtemps.loc[landtemps.elevation>1000]
plt.scatter(x="latabs", y="avgtemp", c="blue", data=low)
plt.scatter(x="latabs", y="avgtemp", c="red", data=high)
plt.legend(('low elevation', 'high elevation'))
plt.xlabel("Latitude (N or S)")
plt.ylabel("Average Temperature (Celsius)")
plt.title("Latitude and Average Temperature in 2023")
plt.show()
结果如下散点图:

图 5.17:按平均温度和海拔绘制的纬度散点图
- 查看温度、纬度和海拔的三维图。
看起来在高海拔站点的纬度增加下,温度的下降趋势较为陡峭:
fig = plt.figure()
plt.suptitle("Latitude, Temperature, and Elevation in 2023")
ax = plt.axes(projection='3d')
ax.set_xlabel("Elevation")
ax.set_ylabel("Latitude")
ax.set_zlabel("Avg Temp")
ax.scatter3D(low.elevation, low.latabs, low.avgtemp, label="low elevation", c="blue")
ax.scatter3D(high.elevation, high.latabs, high.avgtemp, label="high elevation", c="red")
ax.legend()
plt.show()
结果如下散点图:

图 5.18:按平均温度绘制的纬度和海拔的三维散点图
- 显示纬度与温度的回归线。
使用regplot获取回归线:
sns.regplot(x="latabs", y="avgtemp", color="blue", data=landtemps)
plt.title("Latitude and Average Temperature in 2023")
plt.xlabel("Latitude (N or S)")
plt.ylabel("Average Temperature")
plt.show()
结果如下散点图:

图 5.19:纬度与平均温度的散点图,带有回归线
- 显示低海拔和高海拔车站的回归线。
这次我们使用lmplot,而不是regplot。这两种方法有类似的功能。不出所料,高海拔车站的回归线看起来具有较低的截距(即线与y轴的交点)以及更陡峭的负斜率:
landtemps['elevation'] = np.where(landtemps.elevation<=1000,'low','high')
sns.lmplot(x="latabs", y="avgtemp", hue="elevation", palette=dict(low="blue", high="red"), facet_kws=dict(legend_out=False), data=landtemps)
plt.xlabel("Latitude (N or S)")
plt.ylabel("Average Temperature")
plt.yticks(np.arange(-60, 40, step=20))
plt.title("Latitude and Average Temperature in 2023")
plt.show()
这将产生以下的散点图:

图 5.20:纬度与温度的散点图,带有不同海拔的回归线
-
显示一些位于低海拔和高海拔回归线上的车站。我们可以使用我们在步骤 3中创建的
high和lowDataFrame:high.loc[(high.latabs>38) & \ ... (high.avgtemp>=18), ... ['station','country','latabs', ... 'elevation','avgtemp']]station country latabs elevation avgtemp 82 YEREVAN Armenia 40 1,113 19 3968 LAJES_AB Portugal 39 1,016 19low.loc[(low.latabs>47) & \ ... (low.avgtemp>=14), ... ['station','country','latabs', ... 'elevation','avgtemp']]station country latabs elevation avgtemp 1026 COURTENAY_PUNTLEDGE Canada 50 40 16 1055 HOWE_SOUNDPAM_ROCKS Canada 49 5 14 1318 FORESTBURG_PLANT_SITE Canada 52 663 18 2573 POINTE_DU_TALUT France 47 43 14 2574 NANTES_ATLANTIQUE France 47 27 14 4449 USTORDYNSKIJ Russia 53 526 17 6810 WALKER_AH_GWAH_CHING United States 47 430 20 7050 MEDICINE_LAKE_3_SE United States 48 592 16 8736 QUINCY United States 47 392 14 9914 WINDIGO_MICHIGAN United States 48 213 16
散点图是查看两个变量之间关系的好方法。这些步骤还展示了我们如何为数据的不同子集显示这些关系。
它是如何工作的……
我们只需提供x和y的列名以及一个 DataFrame,就可以运行一个散点图。无需其他更多的操作。我们可以访问与运行直方图和箱线图时相同的图形和坐标轴属性——标题、坐标轴标签、刻度线和标签等。请注意,为了访问像坐标轴标签(而不是图形上的标签)这样的属性,我们使用set_xlabels或set_ylabels,而不是xlabels或ylabels。
3D 图稍微复杂一些。首先,我们将坐标轴的投影设置为3d——plt.axes(projection='3d'),就像我们在步骤 4中做的那样。然后我们可以为每个子图使用scatter3D方法。
由于散点图旨在说明回归变量(x变量)与因变量之间的关系,因此在散点图上看到最小二乘回归线是非常有帮助的。Seaborn 提供了两种方法来做到这一点:regplot和lmplot。我通常使用regplot,因为它资源消耗较少。但有时,我需要lmplot的特性。我们在步骤 6中使用lmplot及其hue属性,为每个海拔水平生成单独的回归线。
在步骤 7中,我们查看一些异常值:那些温度明显高于其所属组的回归线的车站。我们想要调查葡萄牙的LAJES_AB车站和亚美尼亚的YEREVAN车站的数据((high.latabs>38) & (high.avgtemp>=18))。这些车站的平均温度高于根据给定纬度和海拔水平预测的温度。
还有更多……
我们看到了纬度与平均温度之间的预期关系。随着纬度的增加,温度下降。但是,海拔是另一个重要因素。能够同时可视化所有三个变量有助于我们更容易识别异常值。当然,温度的其他影响因素也很重要,比如暖流。遗憾的是,这些数据在当前的数据集中没有。
散点图非常适合可视化两个连续变量之间的关系。通过一些调整,Matplotlib 和 Seaborn 的散点图工具也可以通过增加第三维度(当第三维度为分类变量时,通过颜色的创意使用,或通过改变点的大小)来提供对三个变量之间关系的理解(第四章中使用线性回归识别具有高影响力的数据点的实例展示了这一点)。
另见
这是一本关于可视化的章节,着重于通过可视化识别意外值。但这些图形也迫切需要我们在第四章中进行的多变量分析,在数据子集中的异常值识别。特别是,线性回归分析和对残差的深入分析,对于识别异常值会很有帮助。
使用折线图检查连续变量的趋势
可视化连续变量在规律时间间隔内的值的典型方法是通过折线图,尽管有时对于较少的时间间隔,柱状图也可以使用。在本配方中,我们将使用折线图来展示变量趋势,并检查趋势的突变以及按组别的时间差异。
准备工作
本配方将处理每日的 COVID-19 病例数据。在之前的配方中,我们使用了按国家统计的总数。每日数据提供了每个国家每日新增病例和新增死亡人数,以及我们在其他配方中使用的相同人口统计变量。你需要安装 Matplotlib 才能运行本配方中的代码。
如何操作……
我们使用折线图来可视化每日 COVID-19 病例和死亡趋势。我们按地区创建折线图,并使用堆叠图来更好地理解一个国家如何影响整个地区的病例数量:
-
导入
pandas、matplotlib以及matplotlib.dates和日期格式化工具:import pandas as pd import numpy as np import matplotlib.pyplot as plt import matplotlib.dates as mdates from matplotlib.dates import DateFormatter coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) -
查看几行 COVID-19 每日数据:
coviddaily.sample(2, random_state=1).T628 26980 iso_code AND PRT casedate 2020-03-15 2022-12-04 location Andorra Portugal continent Europe Europe new_cases 1 3,963 new_deaths 0 69 population 79843 10270857 pop_density 164 112 median_age NaN 46 gdp_per_capita NaN 27,937 hosp_beds NaN 3 vac_per_hund NaN NaN aged_65_older NaN 22 life_expectancy 84 82 hum_dev_ind 1 1 region Western Europe Western Europe -
按天计算新增病例和死亡人数。
选择 2023 年 7 月 1 日到 2024 年 3 月 3 日之间的日期,然后使用groupby汇总每一天所有国家的病例和死亡数据:
coviddailytotals = \
coviddaily.loc[coviddaily.casedate.\
between('2023-07-01','2024-03-03')].\
groupby(['casedate'])[['new_cases','new_deaths']].\
sum().\
reset_index()
coviddailytotals.sample(7, random_state=1)
casedate new_cases new_deaths
27 2024-01-07 181,487 1,353
3 2023-07-23 254,984 596
22 2023-12-03 282,319 1,535
18 2023-11-05 158,346 1,162
23 2023-12-10 333,155 1,658
17 2023-10-29 144,325 905
21 2023-11-26 238,282 1,287
- 按天显示新增病例和新增死亡的折线图。
在不同的子图中显示病例和死亡数据:
fig = plt.figure()
plt.suptitle("New COVID-19 Cases and Deaths By Day Worldwide 2023-2024")
ax1 = plt.subplot(2,1,1)
ax1.plot(coviddailytotals.casedate, coviddailytotals.new_cases)
ax1.xaxis.set_major_formatter(DateFormatter("%b"))
ax1.set_xlabel("New Cases")
ax2 = plt.subplot(2,1,2)
ax2.plot(coviddailytotals.casedate, coviddailytotals.new_deaths)
ax2.xaxis.set_major_formatter(DateFormatter("%b"))
ax2.set_xlabel("New Deaths")
plt.tight_layout()
fig.subplots_adjust(top=0.88)
plt.show()
这将产生以下的折线图:

图 5.21:全球 COVID-19 病例和死亡的每日趋势线
-
按天和地区计算新增病例和死亡人数:
regiontotals = \ coviddaily.loc[coviddaily.casedate.\ between('2023-07-01','2024-03-03')].\ groupby(['casedate','region'])\ [['new_cases','new_deaths']].\ sum().\ reset_index() regiontotals.sample(7, random_state=1)casedate region new_cases new_deaths 110 2023-08-13 West Asia 2,313 25 147 2023-09-03 Central Asia 600 7 494 2024-02-04 Oceania / Aus 12,594 38 325 2023-11-19 East Asia 20,088 15 189 2023-09-17 West Africa 85 0 218 2023-10-01 South America 4,203 54 469 2024-01-21 Oceania / Aus 17,503 129 -
按选定地区显示新增病例的折线图。
遍历showregions中的各个地区。为每个地区绘制按天计算的new_cases总数的折线图。使用gca方法获取x轴并设置日期格式:
showregions = ['East Asia','Southern Africa',
... 'North America','Western Europe']
for j in range(len(showregions)):
... rt = regiontotals.loc[regiontotals.\
... region==showregions[j],
... ['casedate','new_cases']]
... plt.plot(rt.casedate, rt.new_cases,
... label=showregions[j])
plt.title("New COVID-19 Cases By Day and Region in 2023-2024")
plt.gca().get_xaxis().set_major_formatter(DateFormatter("%b"))
plt.ylabel("New Cases")
plt.legend()
plt.show()
这将产生以下的折线图:
图 5.22:按地区显示的 COVID-19 每日趋势线
- 使用堆叠图来更仔细地检查一个地区的趋势。
查看南美洲是否是由一个国家(巴西)推动了趋势线。为南美洲按天创建一个new_cases的 DataFrame(sa)。然后,将巴西的new_cases系列添加到sa DataFrame 中。接着,在sa DataFrame 中为南美洲的病例创建一个新的 Series,去除巴西的病例(sacasesnobr):
sa = \
coviddaily.loc[(coviddaily.casedate.\
between('2023-01-01','2023-10-31')) & \
(coviddaily.region=='South America'),
['casedate','new_cases']].\
groupby(['casedate'])\
[['new_cases']].\
sum().\
reset_index().\
rename(columns={'new_cases':'sacases'})
br = coviddaily.loc[(coviddaily.\
location=='Brazil') & \
(coviddaily.casedate. \
between('2023-01-01','2023-10-31')),
['casedate','new_cases']].\
rename(columns={'new_cases':'brcases'})
sa = pd.merge(sa, br, left_on=['casedate'], right_on=['casedate'], how="left")
sa.fillna({"sacases": 0},
inplace=True)
sa['sacasesnobr'] = sa.sacases-sa.brcases
fig = plt.figure()
ax = plt.subplot()
ax.stackplot(sa.casedate, sa.sacases, sa.sacasesnobr, labels=['Brazil','Other South America'])
ax.xaxis.set_major_formatter(DateFormatter("%m-%d"))
plt.title("New COVID-19 Cases in South America in 2023")
plt.tight_layout()
plt.legend(loc="upper left")
plt.show()
这将生成以下堆叠图:

图 5.23 – 巴西及南美洲其他地区每日病例的堆叠趋势
这些步骤展示了如何使用折线图查看变量随时间的变化趋势,并且如何在一张图上显示不同组的趋势。
它是如何工作的……
在绘制折线图之前,我们需要对每日 COVID-19 数据进行一些处理。在步骤 3中,我们使用groupby来汇总每个国家每天的新病例和死亡病例。在步骤 5中,我们使用groupby来汇总每个地区和每天的病例和死亡人数。
在步骤 4中,我们使用plt.subplot(2,1,1)设置了第一个子图。这将为我们提供一个包含两行一列的图形。第三个参数中的1表示这个子图将是第一个,也就是最上面的子图。我们可以传入一个日期数据序列以及y轴的数值。到目前为止,这与我们在使用hist、scatterplot、boxplot和violinplot方法时做的基本相同。但由于我们在处理日期数据,这里我们利用 Matplotlib 的日期格式化工具,并通过xaxis.set_major_formatter(DateFormatter("%b"))来设置只显示月份。由于我们在使用子图,我们使用set_xlabel而不是xlabel来指示我们想要的X轴标签。
我们在步骤 6中为四个选定的地区展示了折线图。我们通过对每个想要绘制的地区调用plot来实现这一点。我们本可以对所有地区进行绘制,但那样图形将过于复杂,难以查看。
在步骤 7中,我们需要做一些额外的处理,将巴西的新病例从南美洲的病例中分离出来。完成这一操作后,我们可以绘制一个堆叠图,将南美洲的病例(不包括巴西)和巴西的病例分别显示。这张图表明,2023 年南美洲的新病例趋势主要受到巴西趋势的影响。
还有更多内容……
步骤 6中生成的图表揭示了一些潜在的数据问题。2023 年 4 月,东亚地区出现了一个不寻常的峰值。检查这些总数是否存在数据收集错误非常重要。
很难忽视不同地区趋势的差异。当然,这些差异背后有实际的原因。不同的曲线反映了我们所知道的不同国家和地区传播速度的现实情况。然而,值得探索趋势线方向或斜率的任何重大变化,以确保我们能确认数据的准确性。
另见
我们将在第九章更详细地介绍groupby,在第十章像在步骤 7中那样合并数据时,解决数据问题。
基于相关矩阵生成热图
两个变量之间的相关性是衡量它们一起移动程度的指标。相关系数为 1 意味着两个变量完全正相关。一个变量增大时,另一个也增大。值为-1 意味着它们完全负相关。一个变量增大时,另一个减小。相关系数为 1 或-1 很少发生,但大于 0.5 或小于-0.5 的相关可能仍具有意义。有几种测试可以告诉我们这种关系是否具有统计学意义(如皮尔逊、斯皮尔曼和肯德尔)。因为这是关于可视化的章节,我们将专注于查看重要的相关性。
准备工作
运行此配方中的代码需要安装 Matplotlib 和 Seaborn。两者都可以使用pip安装,命令为pip install matplotlib和pip install seaborn。
如何做...
我们首先展示 COVID-19 数据的部分相关矩阵,并展示一些关键关系的散点图。然后展示相关矩阵的热图,以可视化所有变量之间的相关性:
-
导入
matplotlib和seaborn,并加载 COVID-19 总数据:import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=["lastdate"]) -
生成相关矩阵。
查看矩阵的一部分:
corr = covidtotals.corr(numeric_only=True)
corr[['total_cases','total_deaths',
'total_cases_pm','total_deaths_pm']]
total_cases total_deaths \
total_cases 1.00 0.76
total_deaths 0.76 1.00
total_cases_pm 0.10 0.01
total_deaths_pm 0.15 0.27
population 0.70 0.47
pop_density -0.03 -0.04
median_age 0.29 0.19
gdp_per_capita 0.19 0.13
hosp_beds 0.21 0.05
vac_per_hund 0.02 -0.07
aged_65_older 0.29 0.19
life_expectancy 0.19 0.11
hum_dev_ind 0.26 0.21
total_cases_pm total_deaths_pm
total_cases 0.10 0.15
total_deaths 0.01 0.27
total_cases_pm 1.00 0.44
total_deaths_pm 0.44 1.00
population -0.13 -0.07
pop_density 0.19 0.02
median_age 0.74 0.69
gdp_per_capita 0.66 0.29
hosp_beds 0.48 0.39
vac_per_hund 0.24 -0.07
aged_65_older 0.72 0.68
life_expectancy 0.69 0.49
hum_dev_ind 0.76 0.60
- 显示中位年龄和国内生产总值(GDP)每人的散点图按百万人口病例。
指示我们希望子图共享y轴值,使用sharey=True:
fig, axes = plt.subplots(1,2, sharey=True)
sns.regplot(x="median_age", y="total_cases_pm", data=covidtotals, ax=axes[0])
sns.regplot(x="gdp_per_capita", y="total_cases_pm", data=covidtotals, ax=axes[1])
axes[0].set_xlabel("Median Age")
axes[0].set_ylabel("Cases Per Million")
axes[1].set_xlabel("GDP Per Capita")
axes[1].set_ylabel("")
plt.suptitle("Scatter Plots of Age and GDP with Cases Per Million")
plt.tight_layout()
fig.subplots_adjust(top=0.92)
plt.show()
这导致以下散点图:

图 5.24:中位年龄和 GDP 按百万人口病例并排的散点图
-
生成相关矩阵的热图:
sns.heatmap(corr, xticklabels=corr.columns, yticklabels=corr.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这导致以下热图:

图 5.25:COVID-19 数据的热图,最强相关性显示为红色和桃色
热图是可视化我们 DataFrame 中所有关键变量如何相互相关的好方法。
它是如何工作的...
DataFrame 的corr方法生成所有数值变量与其他数值变量的相关系数。我们在步骤 2中显示了部分矩阵。在步骤 3中,我们制作了中位年龄按百万人口病例和 GDP 每人按百万人口病例的散点图。这些图表显示了当相关系数为 0.74 时(中位年龄与百万人口病例)和当相关系数为 0.66 时(GDP 每人与百万人口病例)。年龄较高或 GDP 较高的国家 tend to have higher cases per million of population.
热图提供了我们在步骤 2中创建的相关矩阵的可视化。所有红色方块表示相关系数为 1.0(即变量与自身的相关性)。桃色矩形表示显著的正相关,如中位数年龄、人均 GDP、人类发展指数和百万案例数之间的相关性。深色矩形表示强负相关关系,例如每十万人接种疫苗与每百万人死亡数之间的关系。
还有更多……
在进行探索性分析或统计建模时,我发现随时拥有一个相关矩阵或热图非常有帮助。当我能够牢记这些双变量关系时,我能更好地理解数据。
另见
我们将在第四章中更详细地讨论检查两个变量关系的工具,具体是识别双变量关系中的异常值和意外值这一部分,在数据子集中的异常值识别。
总结
直方图、箱线图、散点图、小提琴图、折线图和热图都是理解变量分布的基本工具。散点图、小提琴图和热图还可以帮助我们更好地理解变量之间的关系,无论它们是连续的还是分类的。在本章中,我们使用了所有这些工具来创建可视化。在下一章中,我们将探讨如何在 pandas 中创建新的 Series,或修改现有 Series 中的值。
留下评论!
喜欢这本书吗?通过在亚马逊上留下评论,帮助像你一样的读者。扫描下面的二维码,获取你选择的免费电子书。

第六章:使用 Series 操作清理和探索数据
我们可以将本书前几章的配方视为本质上是诊断性的。我们导入了一些原始数据,然后生成了关于关键变量的描述性统计数据。这使我们对这些变量的值分布情况有了一个大致的了解,并帮助我们识别出异常值和意外值。然后,我们检查了变量之间的关系,以寻找模式,以及这些模式的偏差,包括逻辑上的不一致性。简而言之,到目前为止,我们的主要目标是弄清楚数据到底发生了什么。
然而,在数据探索和清理项目开始不久后,我们通常需要更改一些变量在某些观察中的初始值。例如,我们可能需要创建一个基于一个或多个其他列值的新列,或者我们可能想要改变某些值,这些值可能在某个范围内,比如小于 0,或者超过某个阈值,可能需要将它们设置为均值,或者设置为缺失。幸运的是,pandas Series 对象提供了大量用于操作数据值的方法。
本章的配方展示了如何使用 pandas 方法来更新 Series 的值,一旦我们确定了需要做什么。理想情况下,我们需要花时间仔细检查数据,在操作变量值之前。我们应该首先有集中趋势的度量、分布形状和范围的指示、相关性和可视化,然后再更新变量的值,或者基于它们创建新的变量。在更新变量值之前,我们还应该对异常值和缺失值有一个清晰的认识,了解它们如何影响汇总统计数据,并对填补新值或其他调整的初步计划有所准备。
完成这些工作后,我们就可以开始进行一些数据清理任务了。这些任务通常涉及直接操作 pandas Series 对象,无论是修改现有 Series 的值,还是创建一个新的 Series。这通常涉及条件性地更改值,仅更改满足特定标准的值,或者基于该 Series 的现有值或另一个 Series 的值,分配多个可能的值。
我们分配这些值的方式在很大程度上取决于 Series 的数据类型,无论是要更改的 Series 还是标准 Series。查询和清理字符串数据与处理日期或数值数据的任务有很大不同。对于字符串数据,我们通常需要评估某个字符串片段是否具有某个值,去除字符串中的一些无意义字符,或将其转换为数值或日期值。对于日期数据,我们可能需要查找无效的或超出范围的日期,甚至计算日期间隔。
幸运的是,pandas Series 提供了大量用于操作字符串、数值和日期值的工具。在本章中,我们将探讨一些最有用的工具。具体来说,我们将涵盖以下几个例子:
-
从 pandas Series 中获取值
-
显示 pandas Series 的汇总统计信息
-
更改 Series 值
-
有条件地更改 Series 值
-
评估和清理字符串 Series 数据
-
处理日期
-
使用 OpenAI 进行 Series 操作
让我们开始吧!
技术要求
你需要 pandas、NumPy 和 Matplotlib 来完成本章中的例子。我使用的是 pandas 2.1.4,但该代码也可以在 pandas 1.5.3 或更高版本上运行。
本章节中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
从 pandas Series 中获取值
pandas Series 是一种一维的类数组结构,它采用 NumPy 数据类型。每个 Series 也有一个索引,这是数据标签的数组。如果在创建 Series 时没有指定索引,它将使用默认的 0 到 N-1 的索引。
创建 pandas Series 的方式有很多种,包括从列表、字典、NumPy 数组或标量中创建。在数据清洗工作中,我们最常通过选择 DataFrame 的列来访问数据 Series,使用属性访问(dataframename.columname)或括号符号(dataframename['columnname'])。属性访问不能用来设置 Series 的值,但括号符号可以用于所有 Series 操作。
在本例中,我们将探讨从 pandas Series 中获取值的几种方法。这些技术与我们在 第三章《数据度量》中介绍的从 pandas DataFrame 获取行的方法非常相似。
做好准备
在本例中,我们将使用 国家纵向调查 (NLS) 的数据,主要是关于每个受访者的高中 平均绩点 (GPA) 数据。
数据说明
国家青少年纵向调查由美国劳工统计局进行。该调查始于 1997 年,调查对象为 1980 年至 1985 年间出生的一群人,并且每年都会进行跟踪调查,直到 2023 年。调查数据可供公众使用,网址:nlsinfo.org。
如何实现…
在这个例子中,我们使用括号操作符以及 loc 和 iloc 访问器来选择 Series 值。让我们开始吧:
-
导入所需的
pandas和 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True)
注意
是否使用括号运算符、loc 访问器或 iloc 访问器,主要是个人偏好问题。通常,当你知道要访问的行的索引标签时,使用 loc 访问器更方便。而当通过绝对位置引用行更为简便时,括号运算符或 iloc 访问器可能会是更好的选择。这个例子中展示了这一点。
- 从 GPA 总体列创建一个 Series。
使用 head 显示前几个值及其对应的索引标签。head 默认显示的值数量是 5。Series 的索引与 DataFrame 的索引相同,即 personid:
gpaoverall = nls97.gpaoverall
type(gpaoverall)
pandas.core.series.Series
gpaoverall.head()
personid
135335 3.09
999406 2.17
151672 NaN
750699 2.53
781297 2.43
Name: gpaoverall, dtype: float64
gpaoverall.index
Index([135335, 999406, 151672, 750699, 781297, 613800,
403743, 474817, 530234, 351406,
...
290800, 209909, 756325, 543646, 411195, 505861,
368078, 215605, 643085, 713757],
dtype='int64', name='personid', length=8984)
- 使用括号运算符选择 GPA 值。
使用切片创建一个 Series,包含从第一个值到第五个值的所有值。注意我们得到了与 第 2 步 中 head 方法相同的值。在 gpaoverall[:5] 中不包含冒号左边的值意味着它将从开头开始。gpaoverall[0:5] 将返回相同的结果。同样,gpaoverall[-5:] 显示的是从第五个到最后一个位置的值。这与 gpaoverall.tail() 返回的结果相同:
gpaoverall[:5]
135335 3.09
999406 2.17
151672 NaN
750699 2.53
781297 2.43
Name: gpaoverall, dtype: float64
gpaoverall.tail()
personid
505861 NaN
368078 NaN
215605 3.22
643085 2.30
713757 NaN
Name: gpaoverall, dtype: float64
gpaoverall[-5:]
personid
505861 NaN
368078 NaN
215605 3.22
643085 2.30
713757 NaN
Name: gpaoverall, dtype: float64
- 使用
loc访问器选择值。
我们将一个索引标签(在此案例中为 personid 的值)传递给 loc 访问器以返回一个标量。如果我们传递一个索引标签列表,无论是一个还是多个,我们将得到一个 Series。我们甚至可以传递一个通过冒号分隔的范围。这里我们将使用 gpaoverall.loc[135335:151672]:
gpaoverall.loc[135335]
3.09
gpaoverall.loc[[135335]]
personid
135335 3.09
Name: gpaoverall, dtype: float64
gpaoverall.loc[[135335,999406,151672]]
personid
135335 3.09
999406 2.17
151672 NaN
Name: gpaoverall, dtype: float64
gpaoverall.loc[135335:151672]
personid
135335 3.09
999406 2.17
151672 NaN
Name: gpaoverall, dtype: float64
- 使用
iloc访问器选择值。
iloc 与 loc 的区别在于,它接受的是行号列表,而不是标签。它的工作方式类似于括号运算符切片。在这一步中,我们传递一个包含 0 的单项列表。然后,我们传递一个包含五个元素的列表 [0,1,2,3,4],以返回一个包含前五个值的 Series。如果我们传递 [:5] 给访问器,也会得到相同的结果:
gpaoverall.iloc[[0]]
personid
135335 3.09
Name: gpaoverall, dtype: float64
gpaoverall.iloc[[0,1,2,3,4]]
personid
135335 3.09
999406 2.17
151672 NaN
750699 2.53
781297 2.43
Name: gpaoverall, dtype: float64
gpaoverall.iloc[:5]
personid
135335 3.09
999406 2.17
151672 NaN
750699 2.53
781297 2.43
Name: gpaoverall, dtype: float64
gpaoverall.iloc[-5:]
personid
505861 NaN
368078 NaN
215605 3.22
643085 2.30
713757 NaN
Name: gpaoverall, dtype: float64
访问 pandas Series 值的这些方法——括号运算符、loc 访问器和 iloc 访问器——都有许多使用场景,特别是 loc 访问器。
它是如何工作的...
在 第 3 步 中,我们使用 [] 括号运算符执行了类似标准 Python 的切片操作来创建一个 Series。这个运算符允许我们根据位置轻松选择数据,方法是使用列表或通过切片符号表示的值范围。该符号的形式为 [start:end:step],如果没有提供 step,则假定 step 为 1。当 start 使用负数时,它表示从原始 Series 末尾开始的行数。
在步骤 4中使用的loc访问器通过索引标签选择数据。由于personid是 Series 的索引,我们可以将一个或多个personid值的列表传递给loc访问器,以获取具有这些标签及相关 GPA 值的 Series。我们还可以将一个标签范围传递给访问器,它将返回一个包含从冒号左侧到右侧(包括)的索引标签的 GPA 值的 Series。例如,gpaoverall.loc[135335:151672]将返回personid在135335到151672之间(包括这两个值)的 GPA 值的 Series。
如步骤 5所示,iloc访问器使用的是行位置,而不是索引标签。我们可以传递一个整数列表或使用切片表示法传递一个范围。
显示 pandas Series 的汇总统计数据
pandas 有许多生成汇总统计数据的方法。我们可以分别使用mean、median、max和min方法轻松获得 Series 的平均值、中位数、最大值或最小值。非常方便的describe方法将返回所有这些统计数据,以及其他一些数据。我们还可以使用quantile方法获得 Series 中任意百分位的值。这些方法可以应用于 Series 的所有值,或者仅用于选定的值。接下来的示例中将展示如何使用这些方法。
准备工作
我们将继续使用 NLS 中的总体 GPA 列。
如何操作...
让我们仔细看看整个数据框和选定行的总体 GPA 分布。为此,请按照以下步骤操作:
-
导入
pandas和numpy并加载 NLS 数据:import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
获取一些描述性统计数据:
gpaoverall = nls97.gpaoverall gpaoverall.mean()2.8184077281812145gpaoverall.describe()count 6,004.00 mean 2.82 std 0.62 min 0.10 25% 2.43 50% 2.86 75% 3.26 max 4.17 Name: gpaoverall, dtype: float64gpaoverall.quantile(np.arange(0.1,1.1,0.1))0.10 2.02 0.20 2.31 0.30 2.52 0.40 2.70 0.50 2.86 0.60 3.01 0.70 3.17 0.80 3.36 0.90 3.60 1.00 4.17 Name: gpaoverall, dtype: float64 -
显示 Series 子集的描述性统计数据:
gpaoverall.loc[gpaoverall.between(3,3.5)].head(5)personid 135335 3.09 370417 3.41 684388 3.00 984178 3.15 730045 3.44 Name: gpaoverall, dtype: float64gpaoverall.loc[gpaoverall.between(3,3.5)].count()1679gpaoverall.loc[(gpaoverall<2) | (gpaoverall>4)].sample(5, random_state=10)personid 382527 1.66 436086 1.86 556245 4.02 563504 1.94 397487 1.84 Name: gpaoverall, dtype: float64gpaoverall.loc[gpaoverall>gpaoverall.quantile(0.99)].\ ... agg(['count','min','max'])count 60.00 min 3.98 max 4.17 Name: gpaoverall, dtype: float64 -
测试所有值中的某一条件。
检查 GPA 值是否超过 4,并确保所有值都大于或等于 0。(我们通常期望 GPA 在 0 到 4 之间。)还要统计缺失值的数量:
(gpaoverall>4).any() # any person has GPA greater than 4
True
(gpaoverall>=0).all() # all people have GPA greater than or equal 0
False
(gpaoverall>=0).sum() # of people with GPA greater than or equal 0
6004
(gpaoverall==0).sum() # of people with GPA equal to 0
0
gpaoverall.isnull().sum() # of people with missing value for GPA
2980
- 基于不同列的值,显示 Series 的子集描述性统计数据。
显示 2020 年工资收入高于第 75 百分位的个人以及低于第 25 百分位的个人的高中平均 GPA:
nls97.loc[nls97.wageincome20 > nls97.wageincome20.quantile(0.75),'gpaoverall'].mean()
3.0672837022132797
nls97.loc[nls97.wageincome20 < nls97.wageincome20.quantile(0.25),'gpaoverall'].mean()
2.6852676399026763
-
显示包含分类数据的 Series 的描述性统计和频率:
nls97.maritalstatus.describe()count 6675 unique 5 top Married freq 3068 Name: maritalstatus, dtype: objectnls97.maritalstatus.value_counts()Married 3068 Never-married 2767 Divorced 669 Separated 148 Widowed 23 Name: maritalstatus, dtype: int64
一旦我们有了 Series,我们可以使用 pandas 的多种工具来计算该 Series 的描述性统计数据。
它是如何工作的……
Series 的describe方法非常有用,因为它能很好地展示连续变量的集中趋势和分布情况。查看每个十分位的值通常也很有帮助。我们在步骤 2中通过将 0.1 到 1.0 之间的值列表传递给 Series 的quantile方法来获得这些信息。
我们可以在 Series 的子集上使用这些方法。在第 3 步中,我们获得了 GPA 值在 3 到 3.5 之间的计数。我们还可以根据与汇总统计量的关系来选择值;例如,gpaoverall>gpaoverall.quantile(0.99) 选择 GPA 值大于第 99^(百分位)的值。然后,我们通过方法链将结果 Series 传递给 agg 方法,返回多个汇总统计量(agg(['count','min','max']))。
有时,我们需要测试某个条件是否在 Series 中的所有值上都成立。any 和 all 方法对于此操作非常有用。any 当 Series 中至少有一个值满足条件时返回 True(例如,(gpaoverall>4).any())。all 当 Series 中所有值都满足条件时返回 True。当我们将测试条件与 sum 链接时((gpaoverall>=0).sum()),我们可以得到所有 True 值的计数,因为 pandas 在执行数值操作时将 True 视为 1。
(gpaoverall>4) 是一种简写方式,用于创建一个与 gpaoverall 具有相同索引的布尔 Series。当 gpaoverall 大于 4 时,其值为 True,否则为 False:
(gpaoverall>4)
personid
135335 False
999406 False
151672 False
750699 False
781297 False
505861 False
368078 False
215605 False
643085 False
713757 False
Name: gpaoverall, Length: 8984, dtype: bool
我们有时需要为通过另一个 Series 过滤后的 Series 生成汇总统计数据。在第 5 步中,我们通过计算工资收入高于第三四分位数的个体的平均高中 GPA,以及工资收入低于第一四分位数的个体的平均高中 GPA,来完成这项工作。
describe 方法对于连续变量(如 gpaoverall)最为有用,但在与分类变量(如 maritalstatus)一起使用时也能提供有价值的信息(见第 6 步)。它返回非缺失值的计数、不同值的数量、最常出现的类别以及该类别的频率。
然而,在处理分类数据时,value_counts 方法更为常用。它提供了 Series 中每个类别的频率。
还有更多……
使用 Series 是 pandas 数据清理任务中的基础,数据分析师很快就会发现,本篇中使用的工具已成为他们日常数据清理工作流的一部分。通常,从初始数据导入阶段到使用 Series 方法(如 describe、mean、sum、isnull、all 和 any)之间不会间隔太长时间。
另见
本章我们只是浅尝辄止地介绍了数据聚合的内容。我们将在第九章《聚合时修复脏数据》中更详细地讨论这一点。
更改 Series 值
在数据清理过程中,我们经常需要更改数据 Series 中的值或创建一个新的 Series。我们可以更改 Series 中的所有值,或仅更改部分数据的值。我们之前用来从 Series 获取值的大部分技术都可以用来更新 Series 的值,尽管需要进行一些小的修改。
准备工作
在本道菜谱中,我们将处理 NLS 数据中的总体高中 GPA 列。
如何实现…
我们可以为所有行或选择的行更改 pandas Series 中的值。我们可以通过对其他 Series 执行算术操作和使用汇总统计来更新 Series。让我们来看看这个过程:
-
导入
pandas并加载 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
基于标量编辑所有值。
将gpaoverall乘以 100:
nls97.gpaoverall.head()
personid
135335 3.09
999406 2.17
151672 NaN
750699 2.53
781297 2.82
Name: gpaoverall, dtype: float64
gpaoverall100 = nls97['gpaoverall'] * 100
gpaoverall100.head()
personid
135335 309.00
999406 217.00
151672 NaN
750699 253.00
781297 243.00
Name: gpaoverall, dtype: float64
- 使用索引标签设置值。
使用loc访问器通过索引标签指定要更改的值:
nls97.loc[[135335], 'gpaoverall'] = 3
nls97.loc[[999406,151672,750699],'gpaoverall'] = 0
nls97.gpaoverall.head()
personid
135335 3.00
999406 0.00
151672 0.00
750699 0.00
781297 2.43
Name: gpaoverall, dtype: float64
- 使用运算符在多个 Series 之间设置值。
使用+运算符计算孩子的数量,这个数量是住在家里的孩子和不住在家里的孩子的总和:
nls97['childnum'] = nls97.childathome + nls97.childnotathome
nls97.childnum.value_counts().sort_index()
0.00 23
1.00 1364
2.00 1729
3.00 1020
4.00 420
5.00 149
6.00 55
7.00 21
8.00 7
9.00 1
12.00 2
Name: childnum, dtype: int64
- 使用索引标签设置汇总统计值。
使用loc访问器从100061到100292选择personid:
nls97.loc[135335:781297,'gpaoverall'] = nls97.gpaoverall.mean()
nls97.gpaoverall.head()
personid
135335 2.82
999406 2.82
151672 2.82
750699 2.82
781297 2.82
Name: gpaoverall, dtype: float64
- 使用位置设置值。
使用iloc访问器按位置选择。可以使用整数或切片表示法(start:end:step)放在逗号左边,指示应该更改的行。逗号右边使用整数来选择列。gpaoverall列在第 16 个位置(由于列索引是从零开始的,所以是第 15 个位置):
nls97.iloc[0, 15] = 2
nls97.iloc[1:4, 15] = 1
nls97.gpaoverall.head()
personid
135335 2.00
999406 1.00
151672 1.00
750699 1.00
781297 2.43
Name: gpaoverall, dtype: float64
- 在筛选后设置 GPA 值。
将所有超过4的 GPA 值更改为4:
nls97.gpaoverall.nlargest()
personid
312410 4.17
639701 4.11
850001 4.10
279096 4.08
620216 4.07
Name: gpaoverall, dtype: float64
nls97.loc[nls97.gpaoverall>4, 'gpaoverall'] = 4
nls97.gpaoverall.nlargest()
personid
588585 4.00
864742 4.00
566248 4.00
990608 4.00
919755 4.00
Name: gpaoverall, dtype: float64
前面的步骤展示了如何使用标量、算术操作和汇总统计值更新 Series 中的值。
它是如何工作的…
首先需要注意的是,在步骤 2中,pandas 将标量乘法进行了向量化。它知道我们想将标量应用于所有行。实质上,nls97['gpaoverall'] * 100创建了一个临时 Series,所有值都设置为 100,且拥有与gpaoverall Series 相同的索引。然后,它将gpaoverall与这个 100 值的 Series 相乘。这就是所谓的广播。
我们可以运用本章第一道菜谱中学到的许多内容,比如如何从 Series 中获取值,来选择特定的值进行更新。这里的主要区别是,我们使用 DataFrame 的loc和iloc访问器(nls97.loc),而不是 Series 的访问器(nls97.gpaoverall.loc)。这样做是为了避免令人头疼的SettingwithCopyWarning,该警告提示我们在 DataFrame 的副本上设置值。nls97.gpaoverall.loc[[135335]] = 3会触发这个警告,而nls97.loc[[135335], 'gpaoverall'] = 3则不会。
在步骤 4中,我们看到了 pandas 如何处理两个或多个 Series 之间的数值操作。加法、减法、乘法和除法等操作就像我们在标准 Python 中对标量进行的操作,只不过是向量化的。(这得益于 pandas 的索引对齐功能。请记住,同一个 DataFrame 中的 Series 会有相同的索引。)如果你熟悉 NumPy,那么你已经有了对这个过程的良好理解。
还有更多内容…
注意到nls97.loc[[135335], 'gpaoverall']返回一个 Series,而nls97.loc[[135335], ['gpaoverall']]返回一个 DataFrame,这是很有用的:
type(nls97.loc[[135335], 'gpaoverall'])
<class 'pandas.core.series.Series'>
type(nls97.loc[[135335], ['gpaoverall']])
<class 'pandas.core.frame.DataFrame'>
如果loc访问器的第二个参数是字符串,它将返回一个 Series。如果它是一个列表,即使列表只包含一个项,它也会返回一个 DataFrame。
对于我们在本案例中讨论的任何操作,记得关注 pandas 如何处理缺失值。例如,在步骤 4中,如果childathome或childnotathome缺失,那么操作将返回missing。我们将在下一章的识别和修复缺失值案例中讨论如何处理这种情况。
另请参阅
第三章,测量你的数据,更详细地介绍了loc和iloc访问器的使用,特别是在选择行和选择与组织列的案例中。
有条件地更改 Series 值
更改 Series 值通常比前一个案例所示的更为复杂。我们经常需要根据该行数据中一个或多个其他 Series 的值来设置 Series 值。当我们需要根据其他行的值设置 Series 值时,这会变得更加复杂;比如,某个人的先前值,或者一个子集的平均值。我们将在本案例和下一个案例中处理这些复杂情况。
准备工作
在本案例中,我们将处理土地温度数据和 NLS 数据。
数据说明
土地温度数据集包含了来自全球 12,000 多个站点在 2023 年的平均温度数据(单位:摄氏度),尽管大多数站点位于美国。该原始数据集来自全球历史气候网络集成数据库,已由美国国家海洋和大气管理局(NOAA)提供给公众使用,网址:www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly。
如何做……
我们将使用 NumPy 的where和select方法,根据该 Series 的值、其他 Series 的值和汇总统计来赋值。然后我们将使用lambda和apply函数来构建更复杂的赋值标准。我们开始吧:
-
导入
pandas和numpy,然后加载 NLS 和土地温度数据:import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) landtemps = pd.read_csv("data/landtemps2023avgs.csv") -
使用 NumPy 的
where函数创建一个包含两个值的分类 Series。
我们来快速检查一下elevation值的分布情况:
landtemps.elevation.quantile(np.arange(0.2,1.1,0.2))
0.2 47.9
0.4 190.5
0.6 395.0
0.8 1,080.0
1.0 9,999.0
Name: elevation, dtype: float64
landtemps['elevation_group'] = np.where(landtemps.elevation>landtemps.elevation.quantile(0.8),'High','Low')
landtemps.elevation_group = landtemps.elevation_group.astype('category')
landtemps.groupby(['elevation_group'],
observed=False)['elevation'].\
agg(['count','min','max'])
count min max
elevation_group
High 2428 1,080 9,999
Low 9709 -350 1,080
注意
你可能已经注意到,我们将False值传递给了groupby的observed属性。这是所有 pandas 版本在 2.1.0 之前的默认值。在后续的 pandas 版本中,groupby的默认observed=True。当observed为True且groupby中包含分类列时,只会显示观察到的值。这不会影响前一步的汇总统计结果。我仅在此处提到它,以提醒你即将发生的默认值变化。在本章其余部分我将忽略它。
- 使用 NumPy 的
where方法创建一个包含三个值的分类 Series。
将 80^(th)百分位以上的值设置为'High',介于中位数和 80^(th)百分位之间的值设置为'Medium',剩余的值设置为'Low':
landtemps['elevation_group'] = \
np.where(landtemps.elevation>
landtemps.elevation.quantile(0.8),'High',
np.where(landtemps.elevation>landtemps.elevation.\
median(),'Medium','Low'))
landtemps.elevation_group = landtemps.elevation_group.astype('category')
landtemps.groupby(['elevation_group'])['elevation'].\
agg(['count','min','max'])
count min max
elevation_group
High 2428 1,080 9,999
Low 6072 -350 271
Medium 3637 271 1,080
- 使用 NumPy 的
select方法来评估一系列条件。
设置一组测试条件,并为结果设置另一个列表。我们希望 GPA 低于 2 且没有学位的个人归为一个类别,GPA 较高但没有学位的个人归为第二个类别,拥有学位但 GPA 较低的个人归为第三个类别,剩余的个人归为第四个类别:
test = [(nls97.gpaoverall<2) &
(nls97.highestdegree=='0\. None'),
nls97.highestdegree=='0\. None',
nls97.gpaoverall<2]
result = ['1\. Low GPA/No Dip','2\. No Diploma',
'3\. Low GPA']
nls97['hsachieve'] = np.select(test, result, '4\. Did Okay')
nls97[['hsachieve','gpaoverall','highestdegree']].\
sample(7, random_state=6)
hsachieve gpaoverall highestdegree
personid
102951 1\. Low GPA/No Dip 1.4 0\. None
583984 4\. Did Okay 3.3 2\. High School
116430 4\. Did Okay NaN 3\. Associates
859586 4\. Did Okay 2.3 2\. High School
288527 4\. Did Okay 2.7 4\. Bachelors
161698 4\. Did Okay 3.4 4\. Bachelors
943703 2\. No Diploma NaN 0\. None
nls97.hsachieve.value_counts().sort_index()
hsachieve
1\. Low GPA/No Dip 90
2\. No Diploma 787
3\. Low GPA 464
4\. Did Okay 7643
Name: count, dtype: int64
虽然 NumPy 的select方法在相对简单的条件赋值中非常方便,但当赋值操作较为复杂时,它可能会变得难以使用。在这种情况下,我们可以使用自定义函数,而不是使用select。
- 让我们使用
apply和自定义函数来执行与前一步相同的 Series 值赋值操作。我们创建一个名为gethsachieve的函数,包含将值分配给新变量hsachieve2的逻辑。我们将此函数传递给apply并指定axis=1,以便将该函数应用于所有行。
我们将在下一步中使用相同的技术来处理一个更复杂的赋值操作,该操作基于更多的列和条件。
def gethsachieve(row):
if (row.gpaoverall<2 and row.highestdegree=="0\. None"):
hsachieve2 = "1\. Low GPA/No Dip"
elif (row.highestdegree=="0\. None"):
hsachieve2 = "2\. No Diploma"
elif (row.gpaoverall<2):
hsachieve2 = "3\. Low GPA"
else:
hsachieve2 = '4\. Did Okay'
return hsachieve2
nls97['hsachieve2'] = nls97.apply(gethsachieve,axis=1)
nls97.groupby(['hsachieve','hsachieve2']).size()
hsachieve hsachieve2
1\. Low GPA/No Dip 1\. Low GPA/No Dip 90
2\. No Diploma 2\. No Diploma 787
3\. Low GPA 3\. Low GPA 464
4\. Did Okay 4\. Did Okay 7643
dtype: int64
请注意,在这一步中,我们得到了与前一步中hsachieve相同的hsachieve2值。
- 现在,让我们使用
apply和自定义函数进行更复杂的计算,该计算基于多个变量的值。
以下的getsleepdeprivedreason函数创建一个变量,用于根据调查对象可能因为什么原因导致每晚睡眠时间少于 6 小时来对其进行分类。我们根据 NLS 调查中关于受访者的就业状态、与受访者同住的孩子数、工资收入和最高完成的学业年级等信息来进行分类:
def getsleepdeprivedreason(row):
sleepdeprivedreason = "Unknown"
if (row.nightlyhrssleep>=6):
sleepdeprivedreason = "Not Sleep Deprived"
elif (row.nightlyhrssleep>0):
if (row.weeksworked20+row.weeksworked21 < 80):
if (row.childathome>2):
sleepdeprivedreason = "Child Rearing"
else:
sleepdeprivedreason = "Other Reasons"
else:
if (row.wageincome20>=62000 or row.highestgradecompleted>=16):
sleepdeprivedreason = "Work Pressure"
else:
sleepdeprivedreason = "Income Pressure"
else:
sleepdeprivedreason = "Unknown"
return sleepdeprivedreason
-
使用
apply来对所有行运行该函数:nls97['sleepdeprivedreason'] = nls97.apply(getsleepdeprivedreason, axis=1) nls97.sleepdeprivedreason = nls97.sleepdeprivedreason.astype('category') nls97.sleepdeprivedreason.value_counts()sleepdeprivedreason Not Sleep Deprived 5595 Unknown 2286 Income Pressure 453 Work Pressure 324 Other Reasons 254 Child Rearing 72 Name: count, dtype: int64 -
如果我们只需要处理特定的列,并且不需要将它们传递给自定义函数,我们可以使用
lambda函数与transform。让我们通过使用lambda在一个语句中测试多个列来尝试这个方法。
colenr列记录了每个人在每年 2 月和 10 月的入学状态。我们想要测试是否有任何一列大学入学状态的值为3. 4 年制大学。使用filter创建一个包含colenr列的 DataFrame。然后,使用transform调用一个 lambda 函数,测试每个colenr列的第一个字符。(我们只需查看第一个字符,判断它是否为 3。)接着将其传递给any,评估是否有任何(一个或多个)列的第一个字符为 3。(由于空间限制,我们只显示 2000 年至 2004 年之间的大学入学状态,但我们会检查 1997 年到 2022 年之间所有大学入学状态列的值。)这可以通过以下代码看到:
nls97.loc[[999406,750699],
'colenrfeb00':'colenroct04'].T
personid 999406 750699
colenrfeb00 1\. Not enrolled 1\. Not enrolled
colenroct00 3\. 4-year college 1\. Not enrolled
colenrfeb01 3\. 4-year college 1\. Not enrolled
colenroct01 2\. 2-year college 1\. Not enrolled
colenrfeb02 1\. Not enrolled 2\. 2-year college
colenroct02 3\. 4-year college 1\. Not enrolled
colenrfeb03 3\. 4-year college 1\. Not enrolled
colenroct03 3\. 4-year college 1\. Not enrolled
colenrfeb04 3\. 4-year college 1\. Not enrolled
colenroct04 3\. 4-year college 1\. Not enrolled
nls97['baenrollment'] = nls97.filter(like="colenr").\
... transform(lambda x: x.str[0:1]=='3').\
... any(axis=1)
nls97.loc[[999406,750699], ['baenrollment']].T
personid 999406 750699
baenrollment True False
nls97.baenrollment.value_counts()
baenrollment
False 4987
True 3997
Name: count, dtype: int64
上述步骤展示了几种我们可以用来有条件地设置 Series 值的技巧。
它是如何工作的……
如果你曾在 SQL 或 Microsoft Excel 中使用过if-then-else语句,那么 NumPy 的where对你应该是熟悉的。它的形式是where(测试条件,True时的表达式,False时的表达式)。在第 2 步中,我们测试了每行的海拔值是否大于 80^(百分位数)的值。如果为True,则返回'High';否则返回'Low'。这是一个基本的if-then-else结构。
有时,我们需要将一个测试嵌套在另一个测试中。在第 3 步中,我们为海拔创建了三个组:高,中和低。我们在False部分(第二个逗号之后)没有使用简单的语句,而是使用了另一个where语句。这将它从else语句变成了else if语句。它的形式是where(测试条件,True时的语句,where(测试条件,True时的语句,False时的语句))。
当然,可以添加更多嵌套的where语句,但并不推荐这样做。当我们需要评估一个稍微复杂一些的测试时,NumPy 的select方法非常有用。在第 4 步中,我们将测试的列表以及该测试的结果列表传递给了select。我们还为没有任何测试为True的情况提供了一个默认值4. Did Okay。当多个测试为True时,会使用第一个为True的测试。
一旦逻辑变得更加复杂,我们可以使用apply。DataFrame 的apply方法可以通过指定axis=1将 DataFrame 的每一行传递给一个函数。第 5 步演示了如何使用apply和用户定义的函数复现与第 4 步相同的逻辑。
在第 6 步和第 7 步中,我们创建了一个 Series,基于工作周数、与受访者同住的子女数量、工资收入和最高学历来分类缺乏睡眠的原因。如果受访者在 2020 年和 2021 年大部分时间没有工作,且有两个以上的孩子与其同住,则sleepdeprivedreason被设置为“育儿”。如果受访者在 2020 年和 2021 年大部分时间没有工作,且有两个或更少的孩子与其同住,则sleepdeprivedreason被设置为“其他原因”。如果受访者在 2020 年和 2021 年大部分时间有工作,则如果他们有高薪或完成了四年的大学学业,sleepdeprivedreason为“工作压力”,否则为“收入压力”。当然,这些分类有些人为,但它们确实展示了如何通过函数基于其他 Series 之间的复杂关系来创建 Series。
在第 8 步中,我们使用了transform调用一个 lambda 函数,测试每个大学入学值的第一个字符是否是 3。但首先,我们使用filter方法从 DataFrame 中选择所有的大学入学列。我们本可以将lambda函数与apply搭配使用以实现相同的结果,但transform通常更高效。
你可能注意到,我们在第 2 步和第 3 步中创建的新 Series 的数据类型被更改为category。这个新 Series 最初是object数据类型。我们通过将类型更改为category来减少内存使用。
我们在第 2 步中使用了另一个非常有用的方法,虽然是有点偶然的。landtemps.groupby(['elevation_group'])创建了一个 DataFrame 的groupby对象,我们将其传递给一个聚合(agg)函数。这样我们就可以获得每个elevation_group的计数、最小值和最大值,从而验证我们的分组分类是否按预期工作。
还有更多……
自从我有一个数据清理项目没有涉及 NumPy 的where或select语句,或者lambda或apply语句以来,已经有很长一段时间了。在某些时候,我们需要基于一个或多个其他 Series 的值来创建或更新一个 Series。熟练掌握这些技术是个好主意。
每当有一个内置的 pandas 函数能够完成我们的需求时,最好使用它,而不是使用apply。apply的最大优点是它非常通用且灵活,但也正因为如此,它比优化过的函数更占用资源。然而,当我们想要基于现有 Series 之间复杂的关系创建一个 Series 时,它是一个很好的工具。
执行第 6 步和第 7 步的另一种方式是将一个 lambda 函数添加到apply中。这会产生相同的结果:
def getsleepdeprivedreason(childathome, nightlyhrssleep, wageincome, weeksworked20, weeksworked21, highestgradecompleted):
... sleepdeprivedreason = "Unknown"
... if (nightlyhrssleep>=6):
... sleepdeprivedreason = "Not Sleep Deprived"
... elif (nightlyhrssleep>0):
... if (weeksworked16+weeksworked17 < 80):
... if (childathome>2):
... sleepdeprivedreason = "Child Rearing"
... else:
... sleepdeprivedreason = "Other Reasons"
... else:
... if (wageincome>=62000 or highestgradecompleted>=16):
... sleepdeprivedreason = "Work Pressure"
... else:
... sleepdeprivedreason = "Income Pressure"
... else:
... sleepdeprivedreason = "Unknown"
... return sleepdeprivedreason
...
nls97['sleepdeprivedreason'] = nls97.apply(lambda x: getsleepdeprivedreason(x.childathome, x.nightlyhrssleep, x.wageincome, x.weeksworked16, x.weeksworked17, x.highestgradecompleted), axis=1)
这种方法的一个优点是,它更清晰地显示了哪些 Series 参与了计算。
另请参见
我们将在第九章《聚合时修复杂乱数据》中详细讲解 DataFrame 的groupby对象。我们在第三章《了解你的数据》中已经探讨了多种选择 DataFrame 列的技术,包括filter。
评估和清理字符串 Series 数据
Python 和 pandas 中有许多字符串清理方法。这是件好事。由于存储在字符串中的数据种类繁多,因此在进行字符串评估和操作时,拥有广泛的工具非常重要:当按位置选择字符串片段时,当检查字符串是否包含某个模式时,当拆分字符串时,当测试字符串长度时,当连接两个或更多字符串时,当改变字符串大小写时,等等。在本食谱中,我们将探索一些最常用于字符串评估和清理的方法。
准备工作
在本食谱中,我们将使用 NLS 数据。(实际上,NLS 数据对于这个食谱来说有点过于干净。为了演示如何处理带有尾随空格的字符串,我在maritalstatus列的值后添加了尾随空格。)
如何实现...
在本食谱中,我们将执行一些常见的字符串评估和清理任务。我们将使用contains、endswith和findall来分别搜索模式、尾随空格和更复杂的模式。
我们还将创建一个处理字符串值的函数,在将值分配给新 Series 之前,使用replace进行更简单的处理。让我们开始吧:
-
导入
pandas和numpy,然后加载 NLS 数据:import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97ca.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
测试字符串中是否存在某个模式。
使用contains来检查govprovidejobs(政府应该提供就业)响应中的“绝对不”与“可能不”值。在where调用中,首先处理缺失值,确保它们不会出现在第一个else分支中(即第二个逗号后的部分):
nls97.govprovidejobs.value_counts()
2\. Probably 617
3\. Probably not 462
1\. Definitely 454
4\. Definitely not 300
Name: govprovidejobs, dtype: int64
nls97['govprovidejobsdefprob'] = \
np.where(nls97.govprovidejobs.isnull(),
np.nan,
np.where(nls97.govprovidejobs.str.\
contains("not"),"No","Yes"))
pd.crosstab(nls97.govprovidejobs, nls97.govprovidejobsdefprob)
govprovidejobsdefprob No Yes
govprovidejobs
1\. Definitely 0 454
2\. Probably 0 617
3\. Probably not 462 0
4\. Definitely not 300 0
- 处理字符串中的前导或尾随空格。
创建一个永婚状态的 Series。首先,检查maritalstatus的值。注意有两个表示从未结婚的异常值。它们是“Never-married”后有一个额外的空格,而其他“Never-married”的值则没有尾随空格。使用startswith和endswith分别测试是否有前导空格或尾随空格。使用strip去除尾随空格后再测试永婚状态。strip去除前导和尾随空格(lstrip去除前导空格,rstrip去除尾随空格,所以在这个例子中,rstrip也能起作用):
nls97.maritalstatus.value_counts()
Married 3066
Never-married 2764
Divorced 663
Separated 154
Widowed 23
Never-married 2
Name: count, dtype: int64
nls97.maritalstatus.str.startswith(' ').any()
False
nls97.maritalstatus.str.endswith(' ').any()
True
nls97['evermarried'] = \
np.where(nls97.maritalstatus.isnull(),np.nan,
np.where(nls97.maritalstatus.str.\
strip()=="Never-married","No","Yes"))
pd.crosstab(nls97.maritalstatus, nls97.evermarried)
evermarried No Yes
maritalstatus
Divorced 0 663
Married 0 3066
Never-married 2764 0
Never-married 2 0
Separated 0 154
Widowed 0 23
-
使用
isin将字符串值与值列表进行比较:nls97['receivedba'] = \ np.where(nls97.highestdegree.isnull(),np.nan, np.where(nls97.highestdegree.str[0:1].\ isin(['4','5','6','7']),"Yes","No")) pd.crosstab(nls97.highestdegree, nls97.receivedba)receivedba No Yes highestdegree 0\. None 953 0 1\. GED 1146 0 2\. High School 3667 0 3\. Associates 737 0 4\. Bachelors 0 1673 5\. Masters 0 603 6\. PhD 0 54 7\. Professional 0 120
我们有时需要找出字符串中特定字符的位置。这有时是因为我们需要获取该点之前或之后的文本,或者以不同方式处理这些文本。让我们用之前处理过的“最高学历”列来尝试。我们将创建一个新列,该列不包含数字前缀。例如,2. 高中将变为高中。
- 使用
find获取highestdegree值中句点的位置,并提取该位置后的文本。
在此之前,我们将99. Unknown分配给缺失值。虽然这不是必要的,但它帮助我们明确处理所有值(包括缺失值)的方式,同时增加了有用的复杂性。完成后,前导数字可以是 1 位或 2 位数字。
接下来,我们创建一个 lambda 函数onlytext,它将用于识别我们想要的文本的位置,然后利用它提取该文本。然后,我们使用highestdegree Series 的transform方法调用onlytext函数:
nls97.fillna({"highestdegree":"99\. Unknown"},
inplace=True)
onlytext = lambda x: x[x.find(".") + 2:]
highestdegreenonum = nls97.highestdegree.\
astype(str).transform(onlytext)
highestdegreenonum.value_counts(dropna=False).\
sort_index()
highestdegree
Associates 737
Bachelors 1673
GED 1146
High School 3667
Masters 603
None 953
PhD 54
Professional 120
Unknown 31
Name: count, dtype: int64
你可能注意到,在句点和我们想要的文本开始之间有一个空格。为了处理这一点,onlytext函数会从句点后的两个空格处开始提取文本。
注意
为了实现我们想要的结果,我们并不需要给 lambda 函数命名。我们本可以直接在transform方法中输入 lambda 函数。然而,由于 NLS 数据中有多个列具有相似的前缀,创建一个可重用的函数来处理其他列是一个不错的选择。
有时我们需要查找字符串中某个特定值或某种类型的值(比如数字)出现的所有位置。pandas 的findall函数可以用来返回字符串中一个或多个匹配的值。它会返回一个包含满足给定条件的字符串片段的列表。在深入更复杂的例子之前,我们先做一个简单的示范。
使用findall计算每个maritalstatus值中r出现的次数,展示前几行数据。首先,展示maritalstatus的值,然后展示每个值对应的findall返回的列表:
nls97.maritalstatus.head()
personid
100061 Married
100139 Married
100284 Never-married
100292 NaN
100583 Married
Name: maritalstatus, dtype: object
nls97.maritalstatus.head().str.findall("r")
personid
100061 [r, r]
100139 [r, r]
100284 [r, r, r]
100292 NaN
100583 [r, r]
Name: maritalstatus, dtype: object
- 我们还将展示
r出现的次数。
使用concat将maritalstatus值、findall返回的列表和列表的长度显示在同一行:
pd.concat([nls97.maritalstatus.head(),
nls97.maritalstatus.head().str.findall("r"),
nls97.maritalstatus.head().str.findall("r").\
str.len()],
axis=1)
maritalstatus maritalstatus maritalstatus
personid
100061 Married [r, r] 2
100139 Married [r, r] 2
100284 Never-married [r, r, r] 3
100292 NaN NaN NaN
100583 Married [r, r] 2
我们也可以使用findall返回不同类型的值。例如,我们可以使用正则表达式返回字符串中的所有数字列表。在接下来的几步中,我们将展示这一过程。
-
使用
findall创建一个包含所有数字的列表,该列表来源于weeklyhrstv(每周花费的电视观看时间)字符串。传递给findall的"\d+"正则表达式表示我们只想要数字:pd.concat([nls97.weeklyhrstv.head(),\ ... nls97.weeklyhrstv.str.findall("\d+").head()], axis=1)weeklyhrstv weeklyhrstv personid 100061 11 to 20 hours a week [11, 20] 100139 3 to 10 hours a week [3, 10] 100284 11 to 20 hours a week [11, 20] 100292 NaN NaN 100583 3 to 10 hours a week [3, 10] -
使用
findall创建的列表,从weeklyhrstv文本中创建一个数值 Series。
我们来定义一个函数,它为每个weeklyhrstv值提取findall创建的列表中的最后一个元素。getnum函数还会调整该数字,使其更接近这两个数字的中点,当存在多个数字时。然后我们使用apply调用这个函数,将findall为每个值创建的列表传递给它。crosstab显示新的weeklyhrstvnum列达到了我们的预期效果:
def getnum(numlist):
... highval = 0
... if (type(numlist) is list):
... lastval = int(numlist[-1])
... if (numlist[0]=='40'):
... highval = 45
... elif (lastval==2):
... highval = 1
... else:
... highval = lastval - 5
... else:
... highval = np.nan
... return highval
...
nls97['weeklyhrstvnum'] = nls97.weeklyhrstv.str.\
... findall("\d+").apply(getnum)
nls97[['weeklyhrstvnum','weeklyhrstv']].head(7)
weeklyhrstvnum weeklyhrstv
personid
100061 15 11 to 20 hours a week
100139 5 3 to 10 hours a week
100284 15 11 to 20 hours a week
100292 NaN NaN
100583 5 3 to 10 hours a week
100833 5 3 to 10 hours a week
100931 1 Less than 2 hours per week
pd.crosstab(nls97.weeklyhrstv, nls97.weeklyhrstvnum)
weeklyhrstvnum 1 5 15 25 35 45
weeklyhrstv
11 to 20 hours a week 0 0 1145 0 0 0
21 to 30 hours a week 0 0 0 299 0 0
3 to 10 hours a week 0 3625 0 0 0 0
31 to 40 hours a week 0 0 0 0 116 0
Less than 2 hrs. 1350 0 0 0 0 0
More than 40 hrs. 0 0 0 0 0 176
- 用替代值替换 Series 中的值。
weeklyhrscomputer(每周在计算机上花费的时间)Series 目前的值排序不太理想。我们可以通过将这些值替换为表示顺序的字母来解决此问题。我们将首先创建一个包含旧值的列表,以及一个包含新值的列表。然后,使用 Series 的 replace 方法将旧值替换为新值。每当 replace 在旧值列表中找到一个值时,它会将其替换为新值列表中相同位置的值:
comphrsold = ['Less than 1 hour a week',
'1 to 3 hours a week','4 to 6 hours a week',
'7 to 9 hours a week','10 hours or more a week']
comphrsnew = ['A. Less than 1 hour a week',
'B. 1 to 3 hours a week','C. 4 to 6 hours a week',
'D. 7 to 9 hours a week','E. 10 hours or more a week']
nls97.weeklyhrscomputer.value_counts().sort_index()
1 to 3 hours a week 733
10 hours or more a week 3669
4 to 6 hours a week 726
7 to 9 hours a week 368
Less than 1 hour a week 296
Name: weeklyhrscomputer, dtype: int64
nls97.weeklyhrscomputer.replace(comphrsold, comphrsnew, inplace=True)
nls97.weeklyhrscomputer.value_counts().sort_index()
A. Less than 1 hour a week 296
B. 1 to 3 hours a week 733
C. 4 to 6 hours a week 726
D. 7 to 9 hours a week 368
E. 10 hours or more a week 3669
Name: weeklyhrscomputer, dtype: int64
本食谱中的步骤演示了我们在 pandas 中可以执行的一些常见字符串评估和操作任务。
工作原理……
我们经常需要检查一个字符串,以查看其中是否存在某种模式。我们可以使用字符串的 contains 方法来实现这一点。如果我们确切知道期望的模式的位置,可以使用标准的切片符号 [start:stop:step] 来选择从起始位置到结束位置减一的文本。(step 的默认值为 1。)例如,在步骤 4 中,我们使用 nls97.highestdegree.str[0:1] 获取了 highestdegree 的第一个字符。然后,我们使用 isin 来测试第一个字符串是否出现在一个值列表中。 (isin 适用于字符数据和数值数据。)
有时,我们需要从字符串中提取多个满足条件的值。在这种情况下,findall 非常有用,因为它会返回一个满足条件的所有值的列表。它还可以与正则表达式配合使用,当我们寻找的内容比字面值更为通用时。在步骤 8 和 步骤 9 中,我们在寻找任何数字。
还有更多……
在根据另一个 Series 的值创建 Series 时,处理缺失值时需要特别小心。缺失值可能会满足 where 调用中的 else 条件,而这并非我们的意图。在步骤 2、步骤 3 和 步骤 4 中,我们确保通过在 where 调用的开始部分进行缺失值检查,正确处理了缺失值。
我们在进行字符串比较时,也需要注意字母的大小写。例如,Probably 和 probably 并不相等。解决这一问题的一种方法是在进行比较时,使用 upper 或 lower 方法,以防大小写的差异没有实际意义。upper("Probably") == upper("PROBABLY") 实际上是 True。
处理日期
处理日期通常并不简单。数据分析师需要成功地解析日期值,识别无效或超出范围的日期,填补缺失的日期,并计算时间间隔。在这些步骤中,每个环节都会遇到意想不到的挑战,但一旦我们成功解析了日期值并获得了 pandas 中的 datetime 值,就算是迈出了成功的一步。在本食谱中,我们将首先解析日期值,然后再处理接下来的其他挑战。
准备工作
在本食谱中,我们将处理 NLS 和 COVID-19 每日病例数据。COVID-19 每日数据包含每个国家每天的报告数据。(NLS 数据实际上对于这个目的来说过于干净。为了说明如何处理缺失的日期值,我将其中一个出生月份的值设置为缺失。)
数据说明
我们的《全球数据》提供了 COVID-19 的公共数据,链接:ourworldindata.org/covid-cases。本食谱中使用的数据是于 2024 年 3 月 3 日下载的。
如何操作…
在这个食谱中,我们将把数字数据转换为日期时间数据,首先通过确认数据中是否包含有效的日期值,然后使用fillna来替换缺失的日期。接下来,我们将计算一些日期间隔;也就是说,计算 NLS 数据中受访者的年龄,以及 COVID-19 每日数据中自首例 COVID-19 病例以来的天数。让我们开始吧:
-
导入
pandas和dateutils中的relativedelta模块,然后加载 NLS 和 COVID-19 每日病例数据:import pandas as pd from dateutil.relativedelta import relativedelta covidcases = pd.read_csv("data/covidcases.csv") nls97 = pd.read_csv("data/nls97c.csv") nls97.set_index("personid", inplace=True) -
显示出生月份和年份的值。
请注意,出生月份有一个缺失值。除此之外,我们将用来创建birthdate序列的数据看起来相当干净:
nls97[['birthmonth','birthyear']].isnull().sum()
birthmonth 1
birthyear 0
dtype: int64
nls97.birthmonth.value_counts(dropna=False).\
sort_index()
birthmonth
1 815
2 693
3 760
4 659
5 689
6 720
7 762
8 782
9 839
10 765
11 763
12 736
NaN 1
Name: count, dtype: int64
nls97.birthyear.value_counts().sort_index()
1980 1691
1981 1874
1982 1841
1983 1807
1984 1771
Name: birthyear, dtype: int64
- 使用
fillna方法为缺失的出生月份设置值。
将birthmonth的平均值(四舍五入为最接近的整数)传递给fillna。这将用birthmonth的平均值替换缺失的birthmonth值。请注意,现在又有一个人将birthmonth的值设为 6:
nls97.fillna({"birthmonth":\
int(nls97.birthmonth.mean())}, inplace=True)
nls97.birthmonth.value_counts(dropna=False).\
sort_index()
birthmonth
1 815
2 693
3 760
4 659
5 689
6 721
7 762
8 782
9 839
10 765
11 763
12 736
Name: count, dtype: int64
- 使用
month和年份integers来创建日期时间列。
我们可以将字典传递给 pandas 的to_datetime函数。字典需要包含年、月和日的键。请注意,birthmonth、birthyear和birthdate没有缺失值:
nls97['birthdate'] = pd.to_datetime(dict(year=nls97.birthyear, month=nls97.birthmonth, day=15))
nls97[['birthmonth','birthyear','birthdate']].head()
birthmonth birthyear birthdate
personid
100061 5 1980 1980-05-15
100139 9 1983 1983-09-15
100284 11 1984 1984-11-15
100292 4 1982 1982-04-15
100583 6 1980 1980-06-15
nls97[['birthmonth','birthyear','birthdate']].isnull().sum()
birthmonth 0
birthyear 0
birthdate 0
dtype: int64
- 使用日期时间列计算年龄。
首先,定义一个函数,当给定起始日期和结束日期时,计算年龄。请注意,我们创建了一个Timestamp对象rundate,并将其赋值为2024-03-01,以用作年龄计算的结束日期:
def calcage(startdate, enddate):
... age = enddate.year - startdate.year
... if (enddate.month<startdate.month or (enddate.month==startdate.month and enddate.day<startdate.day)):
... age = age -1
... return age
...
rundate = pd.to_datetime('2024-03-01')
nls97["age"] = nls97.apply(lambda x: calcage(x.birthdate, rundate), axis=1)
nls97.loc[100061:100583, ['age','birthdate']]
age birthdate
personid
100061 43 1980-05-15
100139 40 1983-09-15
100284 39 1984-11-15
100292 41 1982-04-15
100583 43 1980-06-15
-
我们可以改用
relativedelta模块来计算年龄。我们只需要执行以下操作:nls97["age2"] = nls97.\ apply(lambda x: relativedelta(rundate, x.birthdate).years, axis=1) -
我们应该确认我们得到的值与步骤 5中的值相同:
(nls97['age']!=nls97['age2']).sum()0nls97.groupby(['age','age2']).size()age age2 39 39 1463 40 40 1795 41 41 1868 42 42 1874 43 43 1690 44 44 294 dtype: int64 -
将字符串列转换为日期时间列。
casedate列是object数据类型,而不是datetime数据类型:
covidcases.iloc[:, 0:6].dtypes
iso_code object
continent object
location object
casedate object
total_cases float64
new_cases float64
dtype: object
covidcases.iloc[:, 0:6].sample(2, random_state=1).T
628 26980
iso_code AND PRT
casedate 2020-03-15 2022-12-04
continent Europe Europe
location Andorra Portugal
total_cases 2 5,541,211
new_cases 1 3,963
covidcases['casedate'] = pd.to_datetime(covidcases.casedate, format='%Y-%m-%d')
covidcases.iloc[:, 0:6].dtypes
iso_code object
continent object
location object
casedate datetime64[ns]
total_cases float64
new_cases float64
dtype: object
-
显示日期时间列的描述性统计数据:
covidcases.casedate.nunique()214covidcases.casedate.describe()count 36501 mean 2021-12-16 05:41:07.954302720 min 2020-01-05 00:00:00 25% 2021-01-31 00:00:00 50% 2021-12-12 00:00:00 75% 2022-10-09 00:00:00 max 2024-02-04 00:00:00 Name: casedate, dtype: object -
创建一个
timedelta对象来捕捉日期间隔。
对于每一天,计算自报告首例病例以来,每个国家的天数。首先,创建一个 DataFrame,显示每个国家新病例的第一天,然后将其与完整的 COVID-19 病例数据合并。接着,对于每一天,计算从firstcasedate到casedate的天数:
firstcase = covidcases.loc[covidcases.new_cases>0,['location','casedate']].\
... sort_values(['location','casedate']).\
... drop_duplicates(['location'], keep='first').\
... rename(columns={'casedate':'firstcasedate'})
covidcases = pd.merge(covidcases, firstcase, left_on=['location'], right_on=['location'], how="left")
covidcases['dayssincefirstcase'] = covidcases.casedate - covidcases.firstcasedate
covidcases.dayssincefirstcase.describe()
count 36501
mean 637 days 01:36:55.862579112
std 378 days 15:34:06.667833980
min 0 days 00:00:00
25% 315 days 00:00:00
50% 623 days 00:00:00
75% 931 days 00:00:00
max 1491 days 00:00:00
Name: dayssincefirstcase, dtype: object
本食谱展示了如何解析日期值并创建日期时间序列,以及如何计算时间间隔。
如何操作…
在 pandas 中处理日期时,第一项任务是将其正确转换为 pandas datetime Series。在 步骤 3、4 和 8 中,我们处理了一些最常见的问题:缺失值、从整数部分转换日期和从字符串转换日期。birthmonth 和 birthyear 在 NLS 数据中是整数。我们确认这些值是有效的日期月份和年份。如果,举例来说,存在月份值为 0 或 20,则转换为 pandas datetime 将失败。
birthmonth 或 birthyear 的缺失值将导致 birthdate 缺失。我们使用 fillna 填充了 birthmonth 的缺失值,将其分配为 birthmonth 的平均值。在 步骤 5 中,我们使用新的 birthdate 列计算了每个人截至 2024 年 3 月 1 日的年龄。我们创建的 calcage 函数会根据出生日期晚于 3 月 1 日的个体进行调整。
数据分析师通常会收到包含日期字符串的文件。当发生这种情况时,to_datetime 函数是分析师的得力助手。它通常足够智能,能够自动推断出字符串日期数据的格式,而无需我们明确指定格式。然而,在 步骤 8 中,我们告诉 to_datetime 使用 %Y-%m-%d 格式处理我们的数据。
步骤 9 告诉我们有 214 个独特的日期报告了 COVID-19 病例。第一次报告的日期是 2020 年 1 月 5 日,最后一次报告的日期是 2024 年 2 月 4 日。
步骤 10 中的前两条语句涉及了一些技巧(排序和去重),我们将在 第九章《汇总时修复杂乱数据》和 第十章《合并 DataFrame 时处理数据问题》中详细探讨。这里你只需要理解目标:创建一个按 location(国家)每行数据表示的 DataFrame,并记录首次报告的 COVID-19 病例日期。我们通过仅选择全数据中 new_cases 大于 0 的行来做到这一点,然后按 location 和 casedate 排序,并保留每个 location 的第一行。接着,我们将 casedate 改名为 firstcasedate,然后将新的 firstcase DataFrame 与 COVID-19 日病例数据合并。
由于 casedate 和 firstcasedate 都是日期时间列,从后者减去前者将得到一个 timedelta 值。这为我们提供了一个 Series,表示每个国家每个报告日期自 new_cases 首次出现后的天数。报告病例日期(casedate)和首次病例日期(firstcasedate)之间的最大持续时间(dayssincefirstcase)是 1491 天,约为 4 年多。这个间隔计算对于我们想要按病毒在一个国家明显存在的时间来追踪趋势,而不是按日期来追踪趋势时非常有用。
另请参见
与其在步骤 10中使用sort_values和drop_duplicates,我们也可以使用groupby来实现类似的结果。在第九章中,我们将深入探索groupby,在聚合时修复杂乱数据。我们还在步骤 10中做了一个合并。第十章,合并 DataFrame 时解决数据问题,将专门讨论这个主题。
使用 OpenAI 进行 Series 操作
本章之前食谱中演示的许多 Series 操作可以借助 AI 工具完成,包括通过 PandasAI 与 OpenAI 的大型语言模型一起使用。在这个食谱中,我们研究如何使用 PandasAI 查询 Series 的值,创建新的 Series,有条件地设置 Series 的值,并对 DataFrame 进行一些基础的重塑。
准备就绪
在这个食谱中,我们将再次使用 NLS 和 COVID-19 每日数据。我们还将使用 PandasAI,它可以通过pip install pandasai安装。你还需要从openai.com获取一个令牌,以便向 OpenAI API 发送请求。
如何操作...
以下步骤创建一个 PandasAI SmartDataframe对象,然后使用该对象的聊天方法提交一系列 Series 操作的自然语言指令:
-
我们首先需要从 PandasAI 导入
OpenAI和SmartDataframe模块。我们还需要实例化一个llm对象:import pandas as pd from pandasai.llm.openai import OpenAI from pandasai import SmartDataframe llm = OpenAI(api_token="Your API Token") -
我们加载 NLS 和 COVID-19 数据并创建一个
SmartDataframe对象。我们传入llm对象以及一个 pandas DataFrame:covidcases = pd.read_csv("data/covidcases.csv") nls97 = pd.read_csv("data/nls97f.csv") nls97.set_index("personid", inplace=True) nls97sdf = SmartDataframe(nls97, config={"llm": llm}) -
现在,我们准备好在我们的
SmartDataframe上生成 Series 的汇总统计信息。我们可以请求单个 Series 的平均值,或者多个 Series 的平均值:nls97sdf.chat("Show average of gpaoverall")2.8184077281812128nls97sdf.chat("Show average for each weeks worked column")Average Weeks Worked 0 weeksworked00 26.42 weeksworked01 29.78 weeksworked02 31.83 weeksworked03 33.51 weeksworked04 35.10 weeksworked05 37.34 weeksworked06 38.44 weeksworked07 39.29 weeksworked08 39.33 weeksworked09 37.51 weeksworked10 37.12 weeksworked11 38.06 weeksworked12 38.15 weeksworked13 38.79 weeksworked14 38.73 weeksworked15 39.67 weeksworked16 40.19 weeksworked17 40.37 weeksworked18 40.01 weeksworked19 41.22 weeksworked20 38.35 weeksworked21 36.17 weeksworked22 11.43 -
我们还可以通过另一个 Series 来汇总 Series 的值,通常是一个分类的 Series:
nls97sdf.chat("Show satmath average by gender")Female Male 0 486.65 516.88 -
我们还可以通过
SmartDataframe的chat方法创建一个新的 Series。我们不需要使用实际的列名。例如,PandasAI 会自动识别我们想要的是childathomeSeries,当我们写下child at home时:nls97sdf = nls97sdf.chat("Set childnum to child at home plus child not at home") nls97sdf[['childnum','childathome','childnotathome']].\ sample(5, random_state=1)childnum childathome childnotathome personid 211230 2.00 2.00 0.00 990746 3.00 3.00 0.00 308169 3.00 1.00 2.00 798458 NaN NaN NaN 312009 NaN NaN NaN -
我们可以使用
chat方法有条件地创建 Series 值:nls97sdf = nls97sdf.chat("evermarried is 'No' when maritalstatus is 'Never-married', else 'Yes'") nls97sdf.groupby(['evermarried','maritalstatus']).size()evermarried maritalstatus No Never-married 2767 Yes Divorced 669 Married 3068 Separated 148 Widowed 23 dtype: int64 -
PandasAI 对你在这里使用的语言非常灵活。例如,以下内容提供了与步骤 6相同的结果:
nls97sdf = nls97sdf.chat("if maritalstatus is 'Never-married' set evermarried2 to 'No', otherwise 'Yes'") nls97sdf.groupby(['evermarried2','maritalstatus']).size()evermarried2 maritalstatus No Never-married 2767 Yes Divorced 669 Married 3068 Separated 148 Widowed 23 dtype: int64 -
我们可以对多个同名的列进行计算:
nls97sdf = nls97sdf.chat("set weeksworkedavg to the average for weeksworked columns")
这将计算所有weeksworked00到weeksworked22列的平均值,并将其分配给一个名为weeksworkedavavg的新列。
-
我们可以根据汇总统计轻松地填补缺失的值:
nls97sdf.gpaenglish.describe()count 5,798 mean 273 std 74 min 0 25% 227 50% 284 75% 323 max 418 Name: gpaenglish, dtype: float64nls97sdf = nls97sdf.chat("set missing gpaenglish to the average") nls97sdf.gpaenglish.describe()count 8,984 mean 273 std 59 min 0 25% 264 50% 273 75% 298 max 418 Name: gpaenglish, dtype: float64 -
我们还可以使用 PandasAI 进行一些重塑,类似于我们在之前的食谱中所做的。回顾一下,我们处理了 COVID-19 病例数据,并希望获取每个国家的第一行数据。让我们首先以传统方式做一个简化版本:
firstcase = covidcases.\ sort_values(['location','casedate']).\ drop_duplicates(['location'], keep='first') firstcase.set_index('location', inplace=True) firstcase.shape(231, 67)firstcase[['iso_code','continent','casedate', 'total_cases','new_cases']].head(2).Tlocation Afghanistan Albania iso_code AFG ALB continent Asia Europe casedate 2020-03-01 2020-03-15 total_cases 1.00 33.00 new_cases 1.00 33.00 -
我们可以通过创建一个
SmartDataframe并使用chat方法来获得相同的结果。这里使用的自然语言非常简单,显示每个国家的第一个 casedate、location 和其他值:covidcasessdf = SmartDataframe(covidcases, config={"llm": llm}) firstcasesdf = covidcasessdf.chat("Show first casedate and location and other values for each country.") firstcasesdf.shape(231, 7)firstcasesdf[['location','continent','casedate', 'total_cases','new_cases']].head(2).Tiso_code ABW AFG location Aruba Afghanistan continent North America Asia casedate 2020-03-22 2020-03-01 total_cases 5.00 1.00 new_cases 5.00 1.00
请注意,PandasAI 会智能地选择需要获取的列。我们只获取我们需要的列,而不是所有列。我们也可以直接将我们想要的列名传递给chat。
这就是一点 PandasAI 和 OpenAI 的魔力!通过传递一句相当普通的句子给chat方法,就完成了所有的工作。
它是如何工作的…
使用 PandasAI 时,大部分工作其实就是导入相关库,并实例化大型语言模型和 SmartDataframe 对象。完成这些步骤后,只需向 SmartDataframe 的 chat 方法发送简单的句子,就足以总结 Series 值并创建新的 Series。
PandasAI 擅长从 Series 中生成简单的统计信息。我们甚至不需要精确记住 Series 名称,正如我们在步骤 3中所见。我们可能使用的自然语言往往比传统的 pandas 方法(如 groupby)更直观。在步骤 4中传递给 chat 的按性别显示 satmath 平均值就是一个很好的例子。
对 Series 进行的操作,包括创建新的 Series,也是相当简单的。在步骤 5中,我们通过指示 SmartDataframe 将住在家中的孩子数与不住在家中的孩子数相加,创建了一个表示孩子总数的 Series(childnum)。我们甚至没有提供字面上的 Series 名称,childathome 和 childnotathome。PandasAI 会自动理解我们的意思。
步骤 6 和 7 展示了使用自然语言进行 Series 操作的灵活性。如果我们在步骤 6中将evermarried 为 ‘No’ 当 maritalstatus 为 ‘Never-married’,否则为 ‘Yes’传递给chat,或者在步骤 7中将如果 maritalstatus 为 ‘Never-married’,则将 evermarried2 设置为 ‘No’,否则为 ‘Yes’传递给chat,我们都会得到相同的结果。
我们还可以通过简单的自然语言指令对 DataFrame 进行较为广泛的重塑,正如在步骤 11中所示。我们将and other values添加到指令中,以获取除了casedate之外的列。PandasAI 还会自动识别出location作为索引是有意义的。
还有更多…
鉴于 PandasAI 工具仍然非常新,数据科学家们现在才开始弄清楚如何将这些工具最佳地集成到我们的数据清理和分析工作流程中。PandasAI 有两个明显的应用场景:1)检查我们以传统方式进行的 Series 操作的准确性;2)在 pandas 或 NumPy 工具不够直观时(如 pandas 的 groupby 或 NumPy 的 where 函数),以更直观的方式进行 Series 操作。
PandasAI 还可以用于构建交互式界面来查询数据存储,如数据仪表盘。我们可以使用 AI 工具帮助终端用户更有效地查询组织数据。正如我们在第三章《衡量你的数据》中所看到的,PandasAI 在快速创建可视化方面也非常出色。
另请参见
在第九章,聚合数据时修复混乱数据中,我们将进行更多的数据聚合操作,包括跨行聚合数据和重新采样日期和时间数据。
总结
本章探讨了多种 pandas Series 方法,用于探索和处理不同类型的数据:数值、字符串和日期。我们学习了如何从 Series 中获取值以及如何生成摘要统计信息。我们还了解了如何更新 Series 中的值,以及如何针对数据子集或根据条件进行更新。我们还探讨了处理字符串或日期 Series 时的特定挑战,以及如何使用 Series 方法来应对这些挑战。最后,我们看到如何利用 PandasAI 来探索和修改 Series。在下一章,我们将探索如何识别和修复缺失值。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第七章:识别并修复缺失值
我想我可以代表许多数据分析师和科学家来说,鲜少有什么看似微小而琐碎的事情能像缺失值那样对我们的分析产生如此大的影响。我们花费大量时间担心缺失值,因为它们可能对我们的分析产生戏剧性的、令人惊讶的影响。尤其是当缺失值不是随机的,而是与因变量相关时,情况尤其如此。例如,如果我们正在做一个收入的纵向研究,但教育水平较低的个体每年更可能跳过收入问题,那么很可能会对我们关于教育水平的参数估计产生偏差。
当然,识别缺失值只解决了问题的一部分。我们还需要决定如何处理它们。我们是删除任何包含缺失值的观测值,还是基于像均值这样的样本统计量插补一个值?或者,基于更有针对性的统计量,例如某个类别的均值,来插补?对于时间序列或纵向数据,我们是否应该考虑用最接近的时间值来填补?或者,是否应该使用更复杂的多变量技术进行插补,可能是基于回归或 k-最近邻方法?
对于前面所有的问题,答案是“是的”。在某个阶段,我们会希望使用这些技术中的每一个。我们希望在做出最终缺失值插补选择时,能够回答为什么或为什么不使用这些可能性。每种方法都将根据情况有其合理性。
本章将介绍识别每个变量的缺失值以及识别缺失值较多的观测值的技术。接着,我们将探讨一些插补策略,例如将缺失值设置为整体均值、某个特定类别的均值或前向填充。我们还将研究多变量插补技术,并讨论它们在何种情况下是合适的。
具体来说,本章将探讨以下几种方法:
-
识别缺失值
-
清理缺失值
-
使用回归进行插补
-
使用 k-最近邻方法进行插补
-
使用随机森林进行插补
-
使用 PandasAI 进行插补
技术要求
你将需要 pandas、NumPy 和 Matplotlib 来完成本章中的示例。我使用的是 pandas 2.1.4,但代码同样适用于 pandas 1.5.3 或更高版本。
本章中的代码可以从本书的 GitHub 仓库下载:github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
识别缺失值
由于识别缺失值是分析师工作流程中的重要部分,我们使用的任何工具都需要使定期检查缺失值变得容易。幸运的是,pandas 使得识别缺失值变得非常简单。
准备就绪
本章我们将使用 国家纵向调查(NLS)数据。NLS 数据每个调查响应者有一条观察记录。每年的就业、收入和大学入学数据都存储在带有后缀的列中,后缀表示年份,如 weeksworked21 和 weeksworked22 分别代表 2021 年和 2022 年的工作周数。
我们还将再次使用 COVID-19 数据。该数据集包含每个国家的观察值,记录了总 COVID-19 病例和死亡人数,以及每个国家的人口统计数据。
数据说明
青年国家纵向调查由美国劳工统计局进行。此调查始于 1997 年,针对的是 1980 至 1985 年出生的群体,每年进行一次跟踪,直到 2023 年。对于此项工作,我从调查的数百个数据项中提取了关于年级、就业、收入和对政府态度的 104 个变量。NLS 数据可以从 nlsinfo.org/ 下载。
Our World in Data 提供了用于公共使用的 COVID-19 数据,网址为 ourworldindata.org/covid-cases。该数据集包括总病例和死亡人数、已做测试数量、医院床位数以及人口统计数据,如中位年龄、国内生产总值和预期寿命。此处使用的数据集是在 2024 年 3 月 3 日下载的。
如何操作...
我们将使用 pandas 函数来识别缺失值和逻辑缺失值(即尽管数据本身不缺失,但却代表缺失的非缺失值)。
-
让我们从加载 NLS 和 COVID-19 数据开始:
import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) covidtotals = pd.read_csv("data/covidtotalswithmissings.csv", low_memory=False) covidtotals.set_index("iso_code", inplace=True) -
接下来,我们统计每个变量的缺失值数量。我们可以使用
isnull方法来测试每个值是否缺失。如果值缺失,它将返回 True,否则返回 False。然后,我们可以使用sum来统计 True 值的数量,因为sum会将每个 True 值视为 1,False 值视为 0。我们指定axis=0来对列进行求和,而不是对行进行求和:covidtotals.shape(231, 16)demovars = ['pop_density','aged_65_older', 'gdp_per_capita','life_expectancy','hum_dev_ind'] covidtotals[demovars].isnull().sum(axis=0)pop_density 22 aged_65_older 43 gdp_per_capita 40 life_expectancy 4 hum_dev_ind 44 dtype: int64
231 个国家中有 43 个国家的 aged_65_older 变量存在空值。几乎所有国家都有 life_expectancy 数据。
-
如果我们想要了解每一行的缺失值数量,可以在求和时指定
axis=1。以下代码创建了一个 Series,demovarsmisscnt,它记录了每个国家人口统计变量的缺失值数量。178 个国家的所有变量都有值,但 16 个国家缺少 5 个变量中的 4 个值,4 个国家所有变量都缺少值:demovarsmisscnt = covidtotals[demovars].isnull().sum(axis=1) demovarsmisscnt.value_counts().sort_index()0 178 1 8 2 14 3 11 4 16 5 4 Name: count, dtype: int64 -
让我们看一看一些缺失值超过 4 的国家。这些国家几乎没有人口统计数据:
covidtotals.loc[demovarsmisscnt>=4, ['location'] + demovars].\ sample(5, random_state=1).Tiso_code FLK SPM \ location Falkland Islands Saint Pierre and Miquelon pop_density NaN NaN aged_65_older NaN NaN gdp_per_capita NaN NaN life_expectancy 81 81 hum_dev_ind NaN NaN iso_code GGY MSR COK location Guernsey Montserrat Cook Islands pop_density NaN NaN NaN aged_65_older NaN NaN NaN gdp_per_capita NaN NaN NaN life_expectancy NaN 74 76 hum_dev_ind NaN NaN NaN -
我们还将检查总病例和死亡人数的缺失值。每百万人的病例和每百万人的死亡人数分别有一个缺失值:
totvars = ['location','total_cases_pm','total_deaths_pm'] covidtotals[totvars].isnull().sum(axis=0)location 0 total_cases_pm 1 total_deaths_pm 1 dtype: int64 -
我们可以轻松检查某个国家是否同时缺失每百万的病例数和每百万的死亡人数。我们看到有
230个国家两者都没有缺失,而仅有一个国家同时缺失这两项数据:totvarsmisscnt = covidtotals[totvars].isnull().sum(axis=1) totvarsmisscnt.value_counts().sort_index()0 230 2 1 Name: count, dtype: int64
有时我们会遇到需要转换为实际缺失值的逻辑缺失值。这发生在数据集设计者使用有效值作为缺失值的代码时。这些通常是像 9、99 或 999 这样的值,取决于变量允许的数字位数。或者它可能是一个更复杂的编码方案,其中有不同的代码表示缺失值的不同原因。例如,在 NLS 数据集中,代码揭示了受访者未回答问题的原因:-3 是无效跳过,-4 是有效跳过,-5 是非访谈。
-
NLS 数据框的最后 4 列包含了关于受访者母亲和父亲完成的最高学位、父母收入以及受访者出生时母亲年龄的数据。我们将从
motherhighgrade列开始,检查这些列的逻辑缺失值。nlsparents = nls97.iloc[:,-4:] nlsparents.loc[nlsparents.motherhighgrade.between(-5,-1), 'motherhighgrade'].value_counts()motherhighgrade -3 523 -4 165 Name: count, dtype: int64 -
有 523 个无效跳过值和 165 个有效跳过值。我们来看几个至少在这四个变量中有一个非响应值的个体:
nlsparents.loc[nlsparents.transform(lambda x: x.between(-5,-1)).any(axis=1)]motherage parentincome fatherhighgrade motherhighgrade personid 135335 26 -3 16 8 999406 19 -4 17 15 151672 26 63000 -3 12 781297 34 -3 12 12 613800 25 -3 -3 12 ... ... ... ... 209909 22 6100 -3 11 505861 21 -3 -4 13 368078 19 -3 13 11 643085 21 23000 -3 14 713757 22 23000 -3 14 [3831 rows x 4 columns] -
对于我们的分析,非响应的原因并不重要。我们只需要统计每列的非响应数量,无论非响应的原因是什么:
nlsparents.transform(lambda x: x.between(-5,-1)).sum()motherage 608 parentincome 2396 fatherhighgrade 1856 motherhighgrade 688 dtype: int64 -
在我们进行分析之前,应该将这些值设置为缺失值。我们可以使用
replace将所有介于 -5 和 -1 之间的值设置为缺失值。当我们检查实际缺失值时,我们得到预期的计数:nlsparents.replace(list(range(-5,0)), np.nan, inplace=True) nlsparents.isnull().sum()motherage 608 parentincome 2396 fatherhighgrade 1856 motherhighgrade 688 dtype: int64
它是如何工作的…
在步骤 8和步骤 9中,我们充分利用了 lambda 函数和 transform 来跨多个列搜索指定范围的值。transform 的工作方式与 apply 类似。两者都是 DataFrame 或 Series 的方法,允许我们将一个或多个数据列传递给一个函数。在这种情况下,我们使用了 lambda 函数,但我们也可以使用命名函数,就像我们在第六章《使用 Series 操作清理和探索数据》中的条件性更改 Series 值教程中所做的那样。
这个教程展示了一些非常实用的 pandas 技巧,用于识别每个变量的缺失值数量以及具有大量缺失值的观测数据。我们还研究了如何找到逻辑缺失值并将其转换为实际缺失值。接下来,我们将首次探索如何清理缺失值。
清理缺失值
在本教程中,我们介绍了一些最直接处理缺失值的方法。这包括删除缺失值的观测数据;为缺失值分配样本范围内的统计量(如均值);以及基于数据的适当子集的均值为缺失值分配值。
如何操作…
我们将查找并移除来自 NLS 数据中那些主要缺失关键变量数据的观测值。我们还将使用 pandas 方法为缺失值分配替代值,例如使用变量均值:
-
让我们加载 NLS 数据并选择一些教育数据。
import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) schoolrecordlist = ['satverbal','satmath','gpaoverall', 'gpaenglish', 'gpamath','gpascience','highestdegree', 'highestgradecompleted'] schoolrecord = nls97[schoolrecordlist] schoolrecord.shape(8984, 8) -
我们可以使用前面章节中探讨的技术来识别缺失值。
schoolrecord.isnull().sum(axis=0)会给出每列的缺失值数量。绝大多数观测值在satverbal上存在缺失值,共 7,578 个缺失值(总共 8,984 个观测值)。只有 31 个观测值在highestdegree上有缺失值:schoolrecord.isnull().sum(axis=0)satverbal 7578 satmath 7577 gpaoverall 2980 gpaenglish 3186 gpamath 3218 gpascience 3300 highestdegree 31 highestgradecompleted 2321 dtype: int64 -
我们可以创建一个 Series
misscnt,它记录每个观测值的缺失变量数量,方法是misscnt = schoolrecord.isnull().sum(axis=1)。949 个观测值的教育数据中有 7 个缺失值,10 个观测值的所有 8 个列都有缺失值。在以下代码中,我们还查看了一些具有 7 个或更多缺失值的观测值。看起来highestdegree通常是唯一一个存在的变量,这并不奇怪,因为我们已经发现highestdegree很少缺失:misscnt = schoolrecord.isnull().sum(axis=1) misscnt.value_counts().sort_index()0 1087 1 312 2 3210 3 1102 4 176 5 101 6 2037 7 949 8 10 dtype: int64schoolrecord.loc[misscnt>=7].head(4).Tpersonid 403743 101705 943703 406679 satverbal NaN NaN NaN NaN satmath NaN NaN NaN NaN gpaoverall NaN NaN NaN NaN gpaenglish NaN NaN NaN NaN gpamath NaN NaN NaN NaN gpascience NaN NaN NaN NaN highestdegree 1\. GED 1\. GED 0\. None 0\. None highestgradecompleted NaN NaN NaN NaN -
我们将删除那些在 8 个变量中有 7 个或更多缺失值的观测值。我们可以通过将
dropna的thresh参数设置为2来实现。这样会删除那些非缺失值少于 2 个的观测值。删除缺失值后,我们得到了预期的观测数:8984-949-10=8025:schoolrecord = schoolrecord.dropna(thresh=2) schoolrecord.shape(8025, 8)schoolrecord.isnull().sum(axis=1).value_counts().sort_index()0 1087 1 312 2 3210 3 1102 4 176 5 101 6 2037 dtype: int64
gpaoverall 存在相当多的缺失值,共计 2,980 个,虽然我们有三分之二的有效观测值 ((8984-2980)/8984)。如果我们能够很好地填补缺失值,这个变量可能是可以保留的。相比于直接删除这些观测值,这样做可能更可取。如果我们能避免丢失这些数据,尤其是如果缺失 gpaoverall 的个体与其他个体在一些重要预测变量上有所不同,我们不希望失去这些数据。
-
最直接的方法是将
gpaoverall的总体均值分配给缺失值。以下代码使用 pandas Series 的fillna方法将所有缺失的gpaoverall值替换为 Series 的均值。fillna的第一个参数是你想要填充所有缺失值的值,在本例中是schoolrecord.gpaoverall.mean()。请注意,我们需要记得将inplace参数设置为 True,才能真正覆盖现有值:schoolrecord = nls97[schoolrecordlist] schoolrecord.gpaoverall.agg(['mean','std','count'])mean 282 std 62 count 6,004 Name: gpaoverall, dtype: float64schoolrecord.fillna({"gpaoverall":\ schoolrecord.gpaoverall.mean()}, inplace=True) schoolrecord.gpaoverall.isnull().sum()0schoolrecord.gpaoverall.agg(['mean','std','count'])mean 282 std 50 count 8,984 Name: gpaoverall, dtype: float64
均值当然没有改变,但标准差有了显著减少,从 62 降到了 50。这是使用数据集均值来填补所有缺失值的一个缺点。
-
NLS 数据集中的
wageincome20也有相当多的缺失值。以下代码显示了 3,783 个观测值缺失。我们使用copy方法进行深拷贝,并将deep设置为 True。通常我们不会这样做,但在这种情况下,我们不想改变底层 DataFrame 中wageincome20的值。我们这样做是因为接下来的代码块中我们会尝试使用不同的填充方法:wageincome20 = nls97.wageincome20.copy(deep=True) wageincome20.isnull().sum()3783wageincome20.head().Tpersonid 135335 NaN 999406 115,000 151672 NaN 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64 -
与其将
wageincome的平均值分配给缺失值,我们可以使用另一种常见的填充技术。我们可以将前一个观测值中的最近非缺失值赋给缺失值。我们可以使用 Series 对象的ffill方法来实现这一点(注意,首次观测值不会填充,因为没有前一个值可用):wageincome20.ffill(inplace=True) wageincome20.head().Tpersonid 135335 NaN 999406 115,000 151672 115,000 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64wageincome20.isnull().sum()1
注意
如果你在 pandas 2.2.0 之前的版本中使用过ffill,你可能还记得以下语法:
wageincome.fillna(method="ffill", inplace=True)
这种语法在 pandas 2.2.0 版本中已被弃用。向后填充的语法也是如此,我们接下来将使用这种方法。
-
我们也可以使用
bfill方法进行向后填充。这会将缺失值填充为最近的后续值。这样会得到如下结果:wageincome20 = nls97.wageincome20.copy(deep=True) wageincome20.head().Tpersonid 135335 NaN 999406 115,000 151672 NaN 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64wageincome20.std()59616.290306039584wageincome20.bfill(inplace=True) wageincome20.head().Tpersonid 135335 115,000 999406 115,000 151672 45,000 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64wageincome20.std()58199.4895818016
如果缺失值是随机分布的,那么前向或后向填充相比使用平均值有一个优势。它更可能接近非缺失值的分布。注意,在后向填充后,标准差变化不大。
有时,根据相似观测值的平均值或中位数来填充缺失值是有意义的;例如,具有相同相关变量值的观测值。让我们在下一步中尝试这种方法。
-
在 NLS DataFrame 中,2020 年的工作周数与获得的最高学历有相关性。以下代码显示了不同学历水平下的工作周数平均值如何变化。工作周数的平均值是 38,但没有学位的人为 28,拥有职业学位的人为 48。在这种情况下,给没有学位的人的缺失工作周数分配 28 可能比分配 38 更合适:
nls97.weeksworked20.mean()38.35403815808349nls97.groupby(['highestdegree'])['weeksworked20'].mean()highestdegree 0\. None 28 1\. GED 34 2\. High School 37 3\. Associates 41 4\. Bachelors 42 5\. Masters 45 6\. PhD 47 7\. Professional 48 Name: weeksworked20, dtype: float64 -
以下代码为缺失
weeksworked20的观测值分配了相同学历水平组中的工作周数平均值。我们通过使用groupby创建一个分组 DataFrame,groupby(['highestdegree'])['weeksworked20']来实现这一点。然后,我们在transform内使用fillna方法,将缺失值填充为该学历组的平均值。注意,我们确保只对学历信息不缺失的观测值进行填充,nls97.highestdegree.notnull()。对于同时缺失学历和工作周数的观测值,仍然会存在缺失值:nls97.loc[nls97.highestdegree.notnull(), 'weeksworked20imp'] = \ nls97.loc[nls97.highestdegree.notnull()].\ groupby(['highestdegree'])['weeksworked20'].\ transform(lambda x: x.fillna(x.mean())) nls97[['weeksworked20imp','weeksworked20','highestdegree']].\ head(10)weeksworked20imp weeksworked20 highestdegree personid 135335 42 NaN 4\. Bachelors 999406 52 52 2\. High School 151672 0 0 4\. Bachelors 750699 52 52 2\. High School 781297 52 52 2\. High School 613800 52 52 2\. High School 403743 34 NaN 1\. GED 474817 51 51 5\. Masters 530234 52 52 5\. Masters 351406 52 52 4\. Bachelors
它的工作原理是...
当可用数据非常少时,删除某个观测值可能是合理的。我们在步骤 4中已经做过了。另一种常见的方法是我们在步骤 5中使用的,即将该变量的整体数据集均值分配给缺失值。在这个例子中,我们看到了这种方法的一个缺点。我们可能会导致变量方差显著减小。
在步骤 9中,我们基于数据子集的均值为变量赋值。如果我们为变量 X[1]填充缺失值,并且 X[1]与 X[2]相关联,我们可以使用 X[1]和 X[2]之间的关系来填充 X[1]的值,这比使用数据集的均值更有意义。当 X[2]是分类变量时,这通常非常直接。在这种情况下,我们可以填充 X[1]在 X[2]的关联值下的均值。
这些填充策略——删除缺失值观测、分配数据集的均值或中位数、使用前向或后向填充,或使用相关变量的组均值——适用于许多预测分析项目。当缺失值与目标变量或依赖变量没有相关性时,这些方法效果最佳。当这种情况成立时,填充缺失值能让我们保留这些观测中的其他信息,而不会偏倚估计结果。
然而,有时情况并非如此,需要更复杂的填充策略。接下来的几个教程将探讨用于清理缺失数据的多变量技术。
参见
如果你对我们在步骤 10中使用groupby和transform的理解仍然有些不清楚,不必担心。在第九章,聚合时清理杂乱数据中,我们将更深入地使用groupby、transform和apply。
使用回归法填充缺失值
我们在上一教程的结尾处,给缺失值分配了组均值,而不是整体样本均值。正如我们所讨论的,这在决定组的变量与缺失值变量相关时非常有用。使用回归法填充值在概念上与此类似,但通常是在填充基于两个或更多变量时使用。
回归填充通过回归模型预测的相关变量值来替代变量的缺失值。这种特定的填充方法被称为确定性回归填充,因为填充值都位于回归线上,并且不会引入误差或随机性。
这种方法的一个潜在缺点是,它可能会大幅度减少缺失值变量的方差。我们可以使用随机回归填充来解决这一缺点。在本教程中,我们将探讨这两种方法。
准备工作
我们将在本教程中使用statsmodels模块来运行线性回归模型。statsmodels通常包含在 Python 的科学发行版中,但如果你还没有安装,可以通过pip install statsmodels来安装它。
如何做到这一点...
NLS 数据集上的wageincome20列存在大量缺失值。我们可以使用线性回归来填补这些值。工资收入值是 2020 年的报告收入。
-
我们首先重新加载 NLS 数据,并检查
wageincome20以及可能与wageincome20相关的列的缺失值。同时加载statsmodels库:import pandas as pd import numpy as np import statsmodels.api as sm nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) nls97[['wageincome20','highestdegree','weeksworked20','parentincome']].info()<class 'pandas.core.frame.DataFrame'> Index: 8984 entries, 135335 to 713757 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 wageincome20 5201 non-null float64 1 highestdegree 8952 non-null object 2 weeksworked20 6971 non-null float64 3 parentincome 6588 non-null float64 dtypes: float64(3), object(1) memory usage: 350.9+ KB -
我们对超过 3,000 个观测值的
wageincome20缺失值。其他变量的缺失值较少。让我们将highestdegree列转换为数值,以便在回归模型中使用它:nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('float') nls97.groupby(['highestdegree','hdegnum']).size()highestdegree hdegnum 0\. None 0 877 1\. GED 1 1167 2\. High School 2 3531 3\. Associates 3 766 4\. Bachelors 4 1713 5\. Masters 5 704 6\. PhD 6 64 7\. Professional 7 130 dtype: int64 -
正如我们已经发现的那样,我们需要将
parentincome的逻辑缺失值替换为实际缺失值。之后,我们可以运行一些相关性分析。每个变量与wageincome20都有一定的正相关性,特别是hdegnum。nls97.parentincome.replace(list(range(-5,0)), np.nan, inplace=True) nls97[['wageincome20','hdegnum','weeksworked20','parentincome']].corr()wageincome20 hdegnum weeksworked20 parentincome wageincome20 1.00 0.38 0.22 0.27 hdegnum 0.38 1.00 0.22 0.32 weeksworked20 0.22 0.22 1.00 0.09 parentincome 0.27 0.32 0.09 1.00 -
我们应该检查一下,具有工资收入缺失值的观测对象在某些重要方面是否与那些没有缺失值的观测对象不同。以下代码显示,这些观测对象的学位获得水平、父母收入和工作周数显著较低。在这种情况下,使用整体均值来分配值显然不是最佳选择:
nls97weeksworked = nls97.loc[nls97.weeksworked20>0] nls97weeksworked.shape(5889, 111)nls97weeksworked['missingwageincome'] = \ np.where(nls97weeksworked.wageincome20.isnull(),1,0) nls97weeksworked.groupby(['missingwageincome'])[['hdegnum', 'parentincome','weeksworked20']].\ agg(['mean','count'])hdegnum parentincome weeksworked20 mean count mean count mean count missingwageincome 0 2.81 4997 48,270.85 3731 47.97 5012 1 2.31 875 40,436.23 611 30.70 877
注意,我们在这里仅处理具有正值工作周数的行。对于 2020 年未工作的人来说,在 2020 年有工资收入是没有意义的。
- 我们来试试回归插补。我们首先用平均值替换缺失的
parentincome值。我们将hdegnum折叠为达到以下三种学位水平的人群:少于本科、本科及以上。我们将它们设置为哑变量,当False或True时,值为0或1。这是处理回归分析中分类数据的一种经过验证的方法。它允许我们基于组成员身份估计不同的 y 截距。
(Scikit-learn具有预处理功能,可以帮助我们处理这些任务。我们将在下一章节中介绍其中一些。)
nls97weeksworked.parentincome. \
fillna(nls97weeksworked.parentincome.mean(), inplace=True)
nls97weeksworked['degltcol'] = \
np.where(nls97weeksworked.hdegnum<=2,1,0)
nls97weeksworked['degcol'] = \
np.where(nls97weeksworked.hdegnum.between(3,4),1,0)
nls97weeksworked['degadv'] = \
np.where(nls97weeksworked.hdegnum>4,1,0)
-
接下来,我们定义一个函数,
getlm,用于使用statsmodels模块运行线性模型。该函数具有目标变量或依赖变量名称ycolname以及特征或自变量名称xcolnames的参数。大部分工作由statsmodels的fit方法完成,即OLS(y, X).fit():def getlm(df, ycolname, xcolnames): df = df[[ycolname] + xcolnames].dropna() y = df[ycolname] X = df[xcolnames] X = sm.add_constant(X) lm = sm.OLS(y, X).fit() coefficients = pd.DataFrame(zip(['constant'] + xcolnames, lm.params, lm.pvalues), columns=['features','params', 'pvalues']) return coefficients, lm -
现在我们可以使用
getlm函数来获取参数估计和模型摘要。所有系数都是正的,并且在 95% 水平下显著,p-值小于0.05。正如我们预期的那样,工资收入随着工作周数和父母收入的增加而增加。拥有大学学位的收入比没有大学学位的人多 $18.5K。拥有研究生学位的人比那些学历较低的人多了近 $45.6K。(degcol和degadv的系数是相对于没有大学学位的人来解释的,因为这个变量被省略掉了。)xvars = ['weeksworked20','parentincome','degcol','degadv'] coefficients, lm = getlm(nls97weeksworked, 'wageincome20', xvars)coefficients features params pvalues 0 constant -22,868.00 0.00 1 weeksworked20 1,281.84 0.00 2 parentincome 0.26 0.00 3 degcol 18,549.57 0.00 4 degadv 45,595.94 0.00 -
我们使用这个模型来插补缺失的工资收入值。由于我们的模型包含了常数项,因此我们需要在预测中添加一个常数。我们可以将预测结果转换为 DataFrame,然后将其与其他 NLS 数据合并。让我们也来看一些预测值,看看它们是否合理。
pred = lm.predict(sm.add_constant(nls97weeksworked[xvars])).\ to_frame().rename(columns= {0: 'pred'}) nls97weeksworked = nls97weeksworked.join(pred) nls97weeksworked['wageincomeimp'] = \ np.where(nls97weeksworked.wageincome20.isnull(),\ nls97weeksworked.pred, nls97weeksworked.wageincome20) nls97weeksworked[['wageincomeimp','wageincome20'] + xvars].\ sample(10, random_state=7)wageincomeimp wageincome20 weeksworked20 parentincome \ personid 696721 380,288 380,288 52 81,300 928568 38,000 38,000 41 47,168 738731 38,000 38,000 51 17,000 274325 40,698 NaN 7 34,800 644266 63,954 NaN 52 78,000 438934 70,000 70,000 52 31,000 194288 1,500 1,500 13 39,000 882066 52,061 NaN 52 32,000 169452 110,000 110,000 52 48,600 284731 25,000 25,000 52 47,168 degcol degadv personid 696721 1 0 928568 0 0 738731 1 0 274325 0 1 644266 0 0 438934 1 0 194288 0 0 882066 0 0 169452 1 0 284731 0 0 -
我们应该查看一下我们的工资收入插补的汇总统计,并将其与实际的工资收入值进行比较。(记住,
wageincomeimp列包含了当wageincome20没有缺失时的实际值,其他情况下则是插补值。)wageincomeimp的均值略低于wageincome20,这是我们预期的结果,因为工资收入缺失的人群通常在相关变量上表现较低。但是标准差也较低。这可能是确定性回归插补的结果:nls97weeksworked[['wageincomeimp','wageincome20']].\ agg(['count','mean','std'])wageincomeimp wageincome20 count 5,889 5,012 mean 59,290 63,424 std 57,529 60,011 -
随机回归插补会在基于我们模型残差的预测中添加一个正态分布的误差。我们希望这个误差的均值为零,且标准差与我们的残差相同。我们可以使用 NumPy 的
normal函数来实现这一点,代码为np.random.normal(0, lm.resid.std(), nls97.shape[0])。其中,lm.resid.std()获取模型残差的标准差。最后一个参数nls97.shape[0]指示我们需要生成多少个值;在这个例子中,我们需要为每一行数据生成一个值。
我们可以将这些值与数据合并,然后将误差 randomadd 加到我们的预测值中。我们设置了一个种子,以便可以重现结果:
np.random.seed(0)
randomadd = np.random.normal(0, lm.resid.std(),
nls97weeksworked.shape[0])
randomadddf = pd.DataFrame(randomadd, columns=['randomadd'],
index=nls97weeksworked.index)
nls97weeksworked = nls97weeksworked.join(randomadddf)
nls97weeksworked['stochasticpred'] = \
nls97weeksworked.pred + nls97weeksworked.randomadd
-
这应该会增加方差,但不会对均值产生太大影响。让我们验证一下这一点。我们首先需要用随机预测值替换缺失的工资收入值:
nls97weeksworked['wageincomeimpstoc'] = \ np.where(nls97weeksworked.wageincome20.isnull(), nls97weeksworked.stochasticpred, nls97weeksworked.wageincome20) nls97weeksworked[['wageincomeimpstoc','wageincome20']].\ agg(['count','mean','std'])wageincomeimpstoc wageincome20 count 5,889 5,012 mean 59,485 63,424 std 60,773 60,011
这似乎起作用了。基于我们的随机预测插补的变量,标准差几乎与工资收入变量相同。
工作原理...
回归插补是一种有效的方式,可以利用我们拥有的所有数据来填补某一列的缺失值。它通常优于我们在上一篇文章中研究的插补方法,尤其是在缺失值不是随机时。然而,确定性回归插补有两个重要的局限性:它假设回归变量(我们的预测变量)与待插补变量之间存在线性关系,并且它可能会显著降低插补变量的方差,正如我们在步骤 8 和 9中看到的那样。
如果我们使用随机回归插补,就不会人为地减少方差。我们在步骤 10中就做了这个操作。这样,我们得到了更好的结果,尽管它并没有解决回归变量与插补变量之间可能存在的非线性关系问题。
在我们开始广泛使用机器学习之前,回归插补是我们常用的多变量插补方法。现在,我们可以选择使用像k-最近邻和随机森林等算法来执行此任务,这些方法在某些情况下比回归插补更具优势。与回归插补不同,KNN 插补不假设变量之间存在线性关系,也不假设这些变量是正态分布的。我们将在下一部分中探讨 KNN 插补。
使用 K 最近邻进行插补
k-最近邻(KNN)是一种流行的机器学习技术,因为它直观易懂,易于运行,并且在变量和观察值数量不大的情况下,能提供很好的结果。正因如此,它经常用于插补缺失值。正如其名字所示,KNN 识别出与每个观察值变量最相似的k个观察值。当用于插补缺失值时,KNN 使用最近邻来确定应该使用哪些填充值。
准备工作
我们将使用来自 scikit-learn 1.3.0 版本的 KNN 插补器。如果你还没有安装 scikit-learn,可以通过pip install scikit-learn进行安装。
如何操作…
我们可以使用 KNN 插补来执行与上一篇文章中回归插补相同的插补操作。
-
我们首先从
scikit-learn导入KNNImputer,并重新加载 NLS 数据:import pandas as pd import numpy as np from sklearn.impute import KNNImputer nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
接下来,我们准备变量。我们将学位获得情况合并为三个类别——低于大学、大学和大学以上学位——每个类别用不同的虚拟变量表示。我们还将家长收入的逻辑缺失值转换为实际的缺失值:
nls97['hdegnum'] = \ nls97.highestdegree.str[0:1].astype('float') nls97['parentincome'] = \ nls97.parentincome.\ replace(list(range(-5,0)), np.nan) -
让我们创建一个仅包含工资收入和一些相关变量的 DataFrame。我们还只选择那些有工作周数为正值的行:
wagedatalist = ['wageincome20','weeksworked20', 'parentincome','hdegnum'] wagedata = \ nls97.loc[nls97.weeksworked20>0, wagedatalist] wagedata.shape(5889, 6) -
现在,我们可以使用 KNN 填补器的
fit_transform方法,为传入的 DataFramewagedata中的所有缺失值生成填补值。fit_transform返回一个 NumPy 数组,包含了wagedata中所有非缺失值以及填补的值。我们将这个数组转换成一个使用wagedata相同索引的 DataFrame。这样在下一步中合并数据会更加方便。(对于一些有使用 scikit-learn 经验的人来说,这一步应该是熟悉的,我们将在下一章中详细讲解。)
我们需要指定用于最近邻数目的值,即k。我们使用一个通用的经验法则来确定k的值,即观察数量的平方根除以 2(sqrt(N)/2)。在这个例子中,k的值为 38。
impKNN = KNNImputer(n_neighbors=38)
newvalues = impKNN.fit_transform(wagedata)
wagedatalistimp = ['wageincomeimp','weeksworked20imp',
'parentincomeimp','hdegnumimp']
wagedataimp = pd.DataFrame(newvalues, columns=wagedatalistimp, index=wagedata.index)
-
我们将填补后的数据与原始的 NLS 工资数据进行合并,并查看一些观测值。请注意,在 KNN 填补过程中,我们不需要对相关变量的缺失值进行任何预处理填补。(在回归填补中,我们将父母收入设为数据集的均值。)
wagedata = wagedata.\ join(wagedataimp[['wageincomeimp','weeksworked20imp']]) wagedata[['wageincome20','wageincomeimp','weeksworked20', 'weeksworked20imp']].sample(10, random_state=7)wageincome20 wageincomeimp weeksworked20 weeksworked20imp personid 696721 380,288 380,288 52 52 928568 38,000 38,000 41 41 738731 38,000 38,000 51 51 274325 NaN 11,771 7 7 644266 NaN 59,250 52 52 438934 70,000 70,000 52 52 194288 1,500 1,500 13 13 882066 NaN 61,234 52 52 169452 110,000 110,000 52 52 284731 25,000 25,000 52 52 -
让我们看看原始变量和填补变量的汇总统计数据。毫不奇怪,填补后的工资收入均值低于原始均值。正如我们在前一个菜谱中发现的,缺失工资收入的观测值通常具有较低的学历、较少的工作周数和较低的父母收入。我们还失去了一些工资收入的方差。
wagedata[['wageincome20','wageincomeimp']].\ agg(['count','mean','std'])wageincome20 wageincomeimp count 5,012 5,889 mean 63,424 59,467 std 60,011 57,218
很简单!前面的步骤为工资收入以及其他缺失值的变量提供了合理的填补,并且我们几乎没有进行数据准备。
它是如何工作的...
这道菜谱的大部分工作是在第 4 步中完成的,我们将 DataFrame 传递给了 KNN 填补器的fit_transform方法。KNN 填补器返回了一个 NumPy 数组,为我们数据中的所有列填补了缺失值,包括工资收入。它基于k个最相似的观测值来进行填补。我们将这个 NumPy 数组转换为一个 DataFrame,并在第 5 步中与初始 DataFrame 合并。
KNN 在进行填补时并不假设基础数据的分布。而回归填补则假设线性回归的标准假设成立,即变量之间存在线性关系且数据服从正态分布。如果不是这种情况,KNN 可能是更好的填补方法。
我们确实需要对k的适当值做出初步假设,这就是所谓的超参数。模型构建者通常会进行超参数调优,以找到最佳的k值。KNN 的超参数调优超出了本书的范围,但我在我的书《数据清洗与机器学习探索》中详细讲解了这一过程。在第 4 步中,我们对k的合理假设做出了初步判断。
还有更多...
尽管有这些优点,KNN 插补也有其局限性。正如我们刚才讨论的,我们必须通过初步假设来调整模型,选择一个合适的k值,这个假设仅基于我们对数据集大小的了解。随着k值的增加,可能会存在过拟合的风险——即过度拟合目标变量的非缺失值数据,以至于我们对缺失值的估计不可靠。超参数调优可以帮助我们确定最佳的k值。
KNN 也在计算上比较昂贵,对于非常大的数据集可能不切实际。最后,当待插补的变量与预测变量之间的相关性较弱,或者这些变量之间高度相关时,KNN 插补可能表现不佳。与 KNN 插补相比,随机森林插补能够帮助我们避免 KNN 和回归插补的缺点。接下来我们将探讨随机森林插补。
另见
我在我的书《数据清洗与机器学习探索》中对 KNN 有更详细的讨论,并且有真实世界数据的示例。这些讨论将帮助您更好地理解算法的工作原理,并与其他非参数机器学习算法(如随机森林)进行对比。我们将在下一个配方中探讨随机森林用于插补值。
使用随机森林进行插补
随机森林是一种集成学习方法,使用自助聚合(也称为 bagging)来提高模型准确性。它通过重复计算多棵树的平均值来做出预测,从而逐步改进估计值。在这个配方中,我们将使用 MissForest 算法,它是将随机森林算法应用于缺失值插补的一种方法。
MissForest 通过填充缺失值的中位数或众数(分别适用于连续或分类变量)开始,然后使用随机森林来预测值。使用这个转换后的数据集,其中缺失值被初始预测替换,MissForest 会生成新的预测,可能会用更好的预测值替换初始预测。MissForest 通常会经历至少 4 次迭代。
做好准备
要运行这个配方中的代码,您需要安装MissForest和MiceForest模块。可以通过pip安装这两个模块。
如何做到……
运行 MissForest 比使用我们在前一个配方中使用的 KNN 插补器还要简单。我们将对之前处理过的工资收入数据进行插补。
-
让我们从导入
MissForest模块并加载 NLS 数据开始。我们导入missforest,并且还导入miceforest,我们将在后续步骤中讨论它:import pandas as pd import numpy as np from missforest.missforest import MissForest import miceforest as mf nls97 = pd.read_csv("data/nls97g.csv",low_memory=False) nls97.set_index("personid", inplace=True) -
我们应该做与前一个配方中相同的数据清洗:
nls97['hdegnum'] = \ nls97.highestdegree.str[0:1].astype('float') nls97['parentincome'] = \ nls97.parentincome.\ replace(list(range(-5,0)), np.nan) wagedatalist = ['wageincome20','weeksworked20','parentincome', 'hdegnum'] wagedata = \ nls97.loc[nls97.weeksworked20>0, wagedatalist] -
现在我们准备运行 MissForest。请注意,这个过程与我们使用 KNN 插补器的过程非常相似:
imputer = MissForest() wagedataimp = imputer.fit_transform(wagedata) wagedatalistimp = \ ['wageincomeimp','weeksworked20imp','parentincomeimp'] wagedataimp.rename(columns=\ {'wageincome20':'wageincome20imp', 'weeksworked20':'weeksworked20imp', 'parentincome':'parentincomeimp'}, inplace=True) wagedata = \ wagedata.join(wagedataimp[['wageincome20imp', 'weeksworked20imp']]) -
让我们看一下我们的一些插补值和一些汇总统计信息。插补后的值具有较低的均值。考虑到我们已经知道缺失值并非随机分布,且具有较低学位和工作周数的人更有可能缺失工资收入,这一点并不令人惊讶:
wagedata[['wageincome20','wageincome20imp', 'weeksworked20','weeksworked20imp']].\ sample(10, random_state=7)wageincome20 wageincome20imp weeksworked20 weeksworked20imp personid 696721 380,288 380,288 52 52 928568 38,000 38,000 41 41 738731 38,000 38,000 51 51 274325 NaN 6,143 7 7 644266 NaN 85,050 52 52 438934 70,000 70,000 52 52 194288 1,500 1,500 13 13 882066 NaN 74,498 52 52 169452 110,000 110,000 52 52 284731 25,000 25,000 52 52wagedata[['wageincome20','wageincome20imp', 'weeksworked20','weeksworked20imp']].\ agg(['count','mean','std'])wageincome20 wageincome20imp weeksworked20 weeksworked20imp count 5,012 5,889 5,889 5,889 mean 63,424 59,681 45 45 std 60,011 57,424 14 14
MissForest 使用随机森林算法生成高精度的预测。与 KNN 不同,它不需要为k选择初始值进行调优。它的计算成本也低于 KNN。或许最重要的是,随机森林插补对变量之间的低相关性或高度相关性不那么敏感,尽管在这个示例中这并不是问题。
它是如何工作的...
我们在这里基本上遵循与前一个食谱中 KNN 插补相同的过程。我们首先稍微清理数据,从最高阶的文本中提取数值变量,并将父母收入的逻辑缺失值替换为实际缺失值。
然后,我们将数据传递给MissForest插补器的fit_transform方法。该方法返回一个包含所有列插补值的数据框。
还有更多...
我们本可以使用链式方程多重插补(MICE),它可以通过随机森林实现,作为替代插补方法。该方法的一个优势是,MICE 为插补添加了一个随机成分,可能进一步减少了过拟合的可能性,甚至优于missforest。
miceforest的运行方式与missforest非常相似。
-
我们使用在步骤 1中创建的
miceforest实例创建一个kernel:kernel = mf.ImputationKernel( data=wagedata[wagedatalist], save_all_iterations=True, random_state=1 ) kernel.mice(3,verbose=True)Initialized logger with name mice 1-3 Dataset 0 1 | degltcol | degcol | degadv | weeksworked20 | parentincome | wageincome20 2 | degltcol | degcol | degadv | weeksworked20 | parentincome | wageincome20 3 | degltcol | degcol | degadv | weeksworked20 | parentincome | wageincome20wagedataimpmice = kernel.complete_data() -
然后,我们可以查看插补结果:
wagedataimpmice.rename(columns=\ {'wageincome20':'wageincome20impmice', 'weeksworked20':'weeksworked20impmice', 'parentincome':'parentincomeimpmice'}, inplace=True) wagedata = wagedata[wagedatalist].\ join(wagedataimpmice[['wageincome20impmice', 'weeksworked20impmice']]) wagedata[['wageincome20','wageincome20impmice', 'weeksworked20','weeksworked20impmice']].\ agg(['count','mean','std'])wageincome20 wageincome20impmice weeksworked20 \ count 5,012 5,889 5,889 mean 63,424 59,191 45 std 60,011 58,632 14 weeksworked20impmice count 5,889 mean 45 std 14
这产生了与missforest非常相似的结果。这两种方法都是缺失值插补的优秀选择。
使用 PandasAI 进行插补
本章中我们探讨的许多缺失值插补任务也可以通过 PandasAI 完成。正如我们在之前的章节中讨论的那样,AI 工具可以帮助我们检查使用传统工具所做的工作,并能建议我们没有想到的替代方法。然而,理解 PandasAI 或其他 AI 工具的工作原理始终是有意义的。
在这个食谱中,我们将使用 PandasAI 来识别缺失值,基于汇总统计插补缺失值,并根据机器学习算法分配缺失值。
准备工作
在这个食谱中,我们将使用 PandasAI。可以通过pip install pandasai进行安装。你还需要从openai.com获取一个令牌,以便向 OpenAI API 发送请求。
如何操作...
在这个食谱中,我们将使用 AI 工具来完成本章中之前执行过的许多任务。
-
我们首先导入
pandas和numpy库,以及OpenAI和pandasai。在这个食谱中,我们将与 PandasAI 的SmartDataFrame模块进行大量的工作。我们还将加载 NLS 数据:import pandas as pd import numpy as np from pandasai.llm.openai import OpenAI from pandasai import SmartDataframe llm = OpenAI(api_token="Your API Key") nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
我们对父母收入和最高学位变量进行与之前示例相同的数据清理:
nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('category') nls97['parentincome'] = \ nls97.parentincome.\ replace(list(range(-5,0)), np.nan) -
我们创建了一个仅包含工资和学位数据的 DataFrame,然后从 PandasAI 中创建一个
SmartDataframe:wagedatalist = ['wageincome20','weeksworked20', 'parentincome','hdegnum'] wagedata = nls97[wagedatalist] wagedatasdf = SmartDataframe(wagedata, config={"llm": llm}) -
显示所有变量的非缺失计数、平均值和标准差。我们向
SmartDataFrame对象的chat方法发送一个自然语言命令来执行此操作。由于hdegnum(最高学位)是一个分类变量,chat不会显示均值或标准差:wagedatasdf.chat("Show the counts, means, and standard deviations as table")count mean std wageincome20 5,201 62,888 59,616 weeksworked20 6,971 38 21 parentincome 6,588 46,362 42,144 -
我们将基于每个变量的均值填充缺失值。此时,
chat方法将返回一个 pandas DataFrame。收入和工作周数的缺失值不再存在,但 PandasAI 识别出学位类别变量不应根据均值填充:wagedatasdf = \ wagedatasdf.chat("Impute missing values based on average.") wagedatasdf.chat("Show the counts, means, and standard deviations as table")count mean std wageincome20 8,984 62,888 45,358 weeksworked20 8,984 38 18 parentincome 8,984 46,362 36,088 -
我们再来看一下最高学位的值。注意到最频繁的值是
2,你可能记得之前的内容中,2代表的是高中文凭。wagedatasdf.hdegnum.value_counts(dropna=False).sort_index()hdegnum 0 877 1 1167 2 3531 3 766 4 1713 5 704 6 64 7 130 NaN 32 Name: count, dtype: int64 -
我们可以将学位变量的缺失值设置为其最频繁的非缺失值,这是一种常见的处理分类变量缺失值的方法。现在,所有的缺失值都被填充为
2:wagedatasdf = \ wagedatasdf.chat("Impute missings based on most frequent value") wagedatasdf.hdegnum.value_counts(dropna=False).sort_index()hdegnum 0 877 1 1167 2 3563 3 766 4 1713 5 704 6 64 7 130 Name: count, dtype: int64 -
我们本可以使用内置的
SmartDataframe函数impute_missing_values。这个函数将使用前向填充来填补缺失值。对于最高学位变量hdegnum,没有填充任何值。wagedatasdf = SmartDataframe(wagedata, config={"llm": llm}) wagedatasdf = \ wagedatasdf.impute_missing_values() wagedatasdf.chat("Show the counts, means, and standard deviations as table")count mean std wageincome20 8,983 62,247 59,559 weeksworked20 8,983 39 21 parentincome 8,982 46,096 42,632 -
我们可以使用 KNN 方法填充收入和工作周数的缺失值。我们从一个未更改的 DataFrame 开始。在填充后,
wageincome20的均值比原来要低,如步骤 4所示。这并不奇怪,因为我们在其他示例中看到,缺失wageincome20的个体在与wageincome20相关的其他变量上也有较低的值。wageincome20和parentincome的标准差变化不大。weeksworked20的均值和标准差几乎没有变化,这很好。wagedatasdf = SmartDataframe(wagedata, config={"llm": llm}) wagedatasdf = wagedatasdf.chat("Impute missings for float variables based on knn with 47 neighbors") wagedatasdf.chat("Show the counts, means, and standard deviations as table")Counts Means Std Devs hdegnum 8952 NaN NaN parentincome 8984 44,805 36,344 wageincome20 8984 58,356 47,378 weeksworked20 8984 38 18
它是如何工作的……
每当我们将自然语言命令传递给SmartDataframe的chat方法时,Pandas 代码会被生成并执行该命令。有些代码用于生成非常熟悉的摘要统计数据。然而,它也能生成用于运行机器学习算法的代码,如 KNN 或随机森林。如前几章所述,执行chat后查看pandasai.log文件始终是个好主意,这样可以了解所生成的代码。
本示例展示了如何使用 PandasAI 来识别和填充缺失值。AI 工具,特别是大语言模型,使得通过自然语言命令生成代码变得容易,就像我们在本章早些时候创建的代码一样。
总结
在本章中,我们探讨了最流行的缺失值插补方法,并讨论了每种方法的优缺点。通常情况下,赋予一个整体样本均值并不是一个好方法,特别是当缺失值的观测值与其他观测值在重要方面存在差异时。我们也可以显著降低方差。前向或后向填充方法可以帮助我们保持数据的方差,但在观测值之间的接近性具有意义时,效果最佳,例如时间序列或纵向数据。在大多数非平凡的情况下,我们将需要使用多元技术,如回归、KNN 或随机森林插补。在本章中,我们已经探讨了所有这些方法,接下来的章节中,我们将学习特征编码、转换和标准化。
留下评价!
喜欢这本书吗?通过在亚马逊上留下评价帮助像你一样的读者。扫描下面的二维码,获取一本你选择的免费电子书。

第八章:编码、转换和缩放特征
我们的数据清理工作通常是为了准备数据,以便将其用于机器学习算法。机器学习算法通常需要对变量进行某种形式的编码。我们的模型通常也需要进行某种形式的缩放,以防止具有更高变异性的特征压倒优化过程。本章中将展示相关的例子,并说明标准化如何解决这个问题。
机器学习算法通常需要对变量进行某种形式的编码。我们几乎总是需要对特征进行编码,以便算法能够正确理解它们。例如,大多数算法无法理解 female 或 male 这些值,或者无法意识到不能将邮政编码当作有序数据处理。尽管通常不必要,但当我们的特征范围差异巨大时,缩放通常是一个非常好的选择。当我们使用假设特征服从高斯分布的算法时,可能需要对特征进行某种形式的转换,以使其符合该假设。
本章我们将探讨以下内容:
-
创建训练数据集并避免数据泄漏
-
识别并移除无关或冗余的观测值
-
对类别特征进行编码:独热编码
-
对类别特征进行编码:有序编码
-
对中等或高基数的特征进行编码
-
转换特征
-
分箱特征
-
k-均值分箱
-
缩放特征
技术要求
完成本章的配方,你需要使用 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码也能在 pandas 1.5.3 或更高版本上运行。
本章的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
创建训练数据集并避免数据泄漏
对我们模型性能的最大威胁之一就是数据泄漏。数据泄漏指的是当我们的模型被告知一些不在训练数据集中的数据时发生的情况。我们有时会无意中用一些训练数据本身无法提供的信息来帮助模型训练,结果导致我们对模型准确性的评估过于乐观。
数据科学家并不希望发生这种情况,因此才有了“泄漏”这一术语。这不是一个“不要这样做”的讨论。我们都知道不该这么做。这更像是一个“我该采取哪些步骤来避免这个问题?”的讨论。实际上,除非我们制定出防止泄漏的程序,否则很容易发生数据泄漏。
例如,如果我们在某个特征中有缺失值,我们可能会用整个数据集的均值来填充这些缺失值。然而,为了验证我们的模型,我们随后将数据拆分为训练集和测试集。这样,我们可能会不小心将数据泄露引入训练集,因为数据集的完整信息(全局均值)已被使用。
数据泄漏会严重影响我们的模型评估,使得预测结果看起来比实际更可靠。数据科学家为了避免这种情况,采取的一个做法是尽可能在分析开始时就建立独立的训练集和测试集。
注意
在本书中,我主要使用 变量 这个术语,指的是可以计数或衡量的某些统计属性,比如年龄或时间长度。我使用 列 这个术语时,指的是数据集中某一列数据的特定操作或属性。在本章中,我将频繁使用特征一词,指的是用于预测分析的变量。在机器学习中,我们通常称特征(也叫自变量或预测变量)和目标(也叫因变量或响应变量)。
准备工作
本章中,我们将广泛使用 scikit-learn 库。你可以通过 pip 安装 scikit-learn,命令是 pip install scikit-learn。本章的代码使用的是 sklearn 版本 0.24.2。
我们可以使用 scikit-learn 来创建 国家青少年纵向调查(NLS)数据的训练和测试 DataFrame。
数据说明
国家青少年纵向调查由美国劳工统计局进行。该调查始于 1997 年,调查对象为 1980 年至 1985 年间出生的一群人,每年进行一次跟踪调查,直到 2023 年。本案例中,我从数百个调查数据项中提取了 104 个与成绩、就业、收入以及对政府态度相关的变量。NLS 数据可以从 nlsinfo.org 下载,供公众使用。
如何操作...
我们将使用 scikit-learn 来创建训练和测试数据:
-
首先,我们从
sklearn导入train_test_split模块并加载 NLS 数据:import pandas as pd from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
然后,我们可以为特征(
X_train和X_test)以及目标(y_train和y_test)创建训练和测试 DataFrame。在这个例子中,wageincome20是目标变量。我们将test_size参数设置为 0.3,留出 30%的样本用于测试。我们将只使用来自 NLS 的 学术评估测试(SAT)和 绩点(GPA)数据。我们需要记住为random_state设置一个值,以确保如果以后需要重新运行train_test_split,我们可以得到相同的 DataFrame:feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall'] X_train, X_test, y_train, y_test = \ train_test_split(nls97[feature_cols],\ nls97[['wageincome20']], test_size=0.3, random_state=0) -
让我们看看使用
train_test_split创建的训练 DataFrame。我们得到了预期的观测值数量 6,288,占 NLS DataFrame 总数 8,984 的 70%:nls97.shape[0]8984X_train.info()<class 'pandas.core.frame.DataFrame'> Index: 6288 entries, 639330 to 166002 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 satverbal 1010 non-null float64 1 satmath 1010 non-null float64 2 gpascience 4022 non-null float64 3 gpaenglish 4086 non-null float64 4 gpamath 4076 non-null float64 5 gpaoverall 4237 non-null float64 dtypes: float64(6) memory usage: 343.9 KBy_train.info()<class 'pandas.core.frame.DataFrame'> Index: 6288 entries, 639330 to 166002 Data columns (total 1 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 wageincome20 3652 non-null float64 dtypes: float64(1) memory usage: 98.2 KB -
我们还来看一下测试 DataFrame。正如我们预期的那样,我们得到了 30% 的观测数据:
X_test.info()<class 'pandas.core.frame.DataFrame'> Index: 2696 entries, 624557 to 201518 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 satverbal 396 non-null float64 1 satmath 397 non-null float64 2 gpascience 1662 non-null float64 3 gpaenglish 1712 non-null float64 4 gpamath 1690 non-null float64 5 gpaoverall 1767 non-null float64 dtypes: float64(6) memory usage: 147.4 KBy_test.info()<class 'pandas.core.frame.DataFrame'> Index: 2696 entries, 624557 to 201518 Data columns (total 1 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 wageincome20 1549 non-null float64 dtypes: float64(1) memory usage: 42.1 KB
这些步骤展示了如何创建训练和测试的 DataFrame。
它是如何工作的...
对于使用 Python 的数据科学家,并且经常使用机器学习算法的人员来说,train_test_split是一个非常流行的选择,可以在准备数据进行分析时避免数据泄漏。
train_test_split将返回四个 DataFrame:一个包含训练数据的 DataFrame,其中包含我们打算在分析中使用的特征或自变量,一个包含这些变量测试数据的 DataFrame,一个包含目标变量(也称为响应或因变量)训练数据的 DataFrame,以及一个包含目标变量的测试 DataFrame。
test_train_split的第一个参数可以接受 DataFrame、NumPy 数组或其他二维数组结构。在这里,我们将包含特征的 pandas DataFrame 传递给第一个参数,然后将仅包含目标变量的另一个 pandas DataFrame 传递给第二个参数。我们还指定希望测试数据占数据集行的 30%。
行是由test_train_split随机选择的。如果我们想要重现这个拆分结果,我们需要为random_state提供一个值。
另请参见
当我们的项目涉及预测建模和评估这些模型时,我们的数据准备需要成为机器学习管道的一部分,通常从训练数据和测试数据的划分开始。Scikit-learn 提供了很好的工具来构建从数据准备到模型评估的机器学习管道。一本很好的资源是我的书《机器学习中的数据清洗与探索》。
在本章的其余部分,我们将使用sklearn的train_test_split来创建独立的训练和测试 DataFrame。接下来,我们通过移除显然无用的特征开始特征工程工作,因为这些特征与其他特征的数据相同,或者响应值没有变化。
移除冗余或无用的特征
在数据清洗和操作的过程中,我们常常会得到一些不再有意义的数据。也许我们根据单一特征值对数据进行了子集划分,并保留了该特征,尽管现在它对所有观测值都有相同的值。或者,在我们使用的数据子集中,两个特征的值相同。理想情况下,我们会在数据清洗过程中捕捉到这些冗余。然而,如果我们在该过程中没有捕捉到它们,我们可以使用开源的feature-engine包来帮助我们解决这个问题。
也可能存在一些特征之间高度相关,以至于我们几乎不可能构建出能够有效使用所有特征的模型。feature-engine提供了一个名为DropCorrelatedFeatures的方法,可以在特征与另一个特征高度相关时轻松删除该特征。
准备工作
在本章中,我们将大量使用feature-engine和category_encoders包。你可以通过 pip 安装这些包,命令为pip install feature-engine和pip install category_encoders。本章的代码使用的是feature-engine的 1.7.0 版本和category_encoders的 2.6.3 版本。注意,pip install feature-engine和pip install feature_engine都可以正常工作。
在本节中,我们将使用土地温度数据,除了 NLS 数据外,这里我们只加载波兰的温度数据。
数据说明
土地温度 DataFrame 包含了 2023 年来自全球超过 12,000 个气象站的平均温度数据(单位为^°C),尽管大部分气象站位于美国。原始数据来自全球历史气候网络(Global Historical Climatology Network)集成数据库。这些数据由美国国家海洋和大气管理局(NOAA)提供,公众可以在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly上访问。
如何操作...
-
让我们从
feature_engine和sklearn中导入所需的模块,然后加载 NLS 数据和波兰的温度数据。波兰的数据来自一个包含全球 12,000 个气象站的更大数据集。我们使用dropna方法删除含有任何缺失数据的观测值:import pandas as pd import feature_engine.selection as fesel from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97g.csv",low_memory=False) nls97.set_index("personid", inplace=True) ltpoland = pd.read_csv("data/ltpoland.csv") ltpoland.set_index("station", inplace=True) ltpoland.dropna(inplace=True) -
接下来,我们将创建训练和测试的 DataFrame,就像在上一节中所做的那样:
feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall'] X_train, X_test, y_train, y_test = \ train_test_split(nls97[feature_cols],\ nls97[['wageincome20']], test_size=0.3, random_state=0) -
我们可以使用 pandas 的
corr方法来查看这些特征之间的相关性:X_train.corr()satverbal satmath gpascience \ satverbal 1.00 0.74 0.42 satmath 0.74 1.00 0.47 gpascience 0.42 0.47 1.00 gpaenglish 0.44 0.44 0.67 gpamath 0.36 0.50 0.62 gpaoverall 0.40 0.48 0.79 gpaenglish gpamath gpaoverall satverbal 0.44 0.36 0.40 satmath 0.44 0.50 0.48 gpascience 0.67 0.62 0.79 gpaenglish 1.00 0.61 0.84 gpamath 0.61 1.00 0.76 gpaoverall 0.84 0.76 1.00
gpaoverall与gpascience、gpaenglish和gpamath高度相关。corr方法默认返回皮尔逊相关系数。当我们可以假设特征之间存在线性关系时,这种方式是合适的。但当这一假设不成立时,我们应该考虑请求斯皮尔曼相关系数。我们可以通过将spearman传递给corr方法的参数来实现这一点。
- 让我们删除与其他特征的相关性超过 0.75 的特征。我们将 0.75 传递给
DropCorrelatedFeatures的threshold参数,并且通过将变量设置为None来表示我们希望使用皮尔逊系数并评估所有特征。我们在训练数据上使用fit方法,然后对训练和测试数据进行转换。info方法显示,结果训练 DataFrame (X_train_tr) 中包含所有特征,除了gpaoverall,它与gpascience和gpaenglish的相关性分别为0.79和0.84(DropCorrelatedFeatures会从左到右进行评估,因此如果gpamath和gpaoverall高度相关,它会删除gpaoverall)。
如果 gpaoverall 在 gpamath 的左边,它将删除 gpamath:
tr = fesel.DropCorrelatedFeatures(variables=None, method='pearson', threshold=0.75)
tr.fit(X_train)
X_train_tr = tr.transform(X_train)
X_test_tr = tr.transform(X_test)
X_train_tr.info()
<class 'pandas.core.frame.DataFrame'>
Index: 6288 entries, 639330 to 166002
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 satverbal 1010 non-null float64
1 satmath 1010 non-null float64
2 gpascience 4022 non-null float64
3 gpaenglish 4086 non-null float64
4 gpamath 4076 non-null float64
dtypes: float64(5)
memory usage: 294.8 KB
我们通常会在决定删除某个特征之前仔细评估它。然而,有时特征选择是管道的一部分,我们需要自动化这个过程。这可以通过 DropCorrelatedFeatures 实现,因为所有 feature_engine 方法都可以被带入 scikit-learn 管道。
-
现在让我们从波兰的土地温度数据中创建训练和测试 DataFrame。
year的值对所有观测值都是相同的,country的值也是相同的。每个观测值的latabs值也与latitude相同:feature_cols = ['year','month','latabs','latitude','elevation', 'longitude','country'] X_train, X_test, y_train, y_test = \ train_test_split(ltpoland[feature_cols],\ ltpoland[['temperature']], test_size=0.3, random_state=0) X_train.sample(5, random_state=99)year month latabs latitude elevation longitude country station SIEDLCE 2023 11 52 52 152 22 Poland OKECIE 2023 6 52 52 110 21 Poland BALICE 2023 1 50 50 241 20 Poland BALICE 2023 7 50 50 241 20 Poland BIALYSTOK 2023 11 53 53 151 23 PolandX_train.year.value_counts()year 2023 84 Name: count, dtype: int64X_train.country.value_counts()country Poland 84 Name: count, dtype: int64(X_train.latitude!=X_train.latabs).sum()0 -
让我们删除在训练数据集中值相同的特征。注意,
year和country在转换后被删除:tr = fesel.DropConstantFeatures() tr.fit(X_train) X_train_tr = tr.transform(X_train) X_test_tr = tr.transform(X_test) X_train_tr.head()month latabs latitude elevation longitude station OKECIE 1 52 52 110 21 LAWICA 8 52 52 94 17 LEBA 11 55 55 2 18 SIEDLCE 10 52 52 152 22 BIALYSTOK 11 53 53 151 23 -
让我们删除那些与其他特征值相同的特征。在这种情况下,转换会删除
latitude,因为它与latabs的值相同:tr = fesel.DropDuplicateFeatures() tr.fit(X_train_tr) X_train_tr = tr.transform(X_train_tr) X_train_tr.head()month latabs elevation longitude station OKECIE 1 52 110 21 LAWICA 8 52 94 17 LEBA 11 55 2 18 SIEDLCE 10 52 152 22 BIALYSTOK 11 53 151 23
它是如何工作的……
这解决了我们在 NLS 数据和波兰温度数据中的一些显而易见的问题。我们从包含其他 GPA 特征的 DataFrame 中删除了 gpaoverall,因为它与其他特征高度相关。我们还移除了冗余数据,删除了整个 DataFrame 中值相同的特征和重复其他特征值的特征。
在 步骤 6 中,我们使用了 feature engine 中 selection 对象的 fit 方法。这收集了进行后续转换所需的信息。在这种情况下,转换是删除具有常量值的特征。我们通常只在训练数据上执行拟合。我们可以通过使用 fit_transform 将拟合和转换结合在一起,这将在本章的大部分内容中使用。
本章的其余部分探讨了一些较为复杂的特征工程挑战:编码、转换、分箱和缩放。
编码类别特征:独热编码
在大多数机器学习算法中,我们可能需要对特征进行编码,原因有几个。首先,这些算法通常要求数据为数值型。其次,当一个类别特征用数字表示时,例如,女性为 1,男性为 2,我们需要对这些值进行编码,使其被识别为类别数据。第三,该特征可能实际上是顺序的,具有离散的值,这些值表示某种有意义的排序。我们的模型需要捕捉到这种排序。最后,一个类别特征可能具有大量取值(称为高基数),我们可能希望通过编码来合并某些类别。
我们可以使用独热编码来处理具有有限取值的特征,假设取值为 15 或更少。我们将在本节中介绍独热编码,接下来讨论顺序编码。随后,我们将讨论如何处理高基数类别特征的策略。
独热编码对每个特征的取值创建一个二进制向量。所以,如果一个特征,称为 letter,有三个唯一的值,A、B 和 C,独热编码会创建三个二进制向量来表示这些值。第一个二进制向量,称为 letter_A,当 letter 的值为 A 时为 1,当它是 B 或 C 时为 0。letter_B 和 letter_C 也会按类似的方式编码。经过转换的特征 letter_A、letter_B 和 letter_C,通常被称为 虚拟变量。图 8.1 展示了独热编码的示意图。
| letter | letter_A | letter_B | letter_C |
|---|---|---|---|
| A | 1 | 0 | 0 |
| B | 0 | 1 | 0 |
| C | 0 | 0 | 1 |
图 8.1:类别特征的独热编码
准备工作
我们将在接下来的两个食谱中使用 feature_engine 和 scikit_learn 中的 OneHotEncoder 和 OrdinalEncoder 模块。我们将继续使用 NLS 数据。
如何做到这一点...
来自 NLS 数据的若干特征适合进行独热编码。我们在以下代码块中编码其中的一些特征:
-
让我们从导入
feature_engine中的OneHotEncoder模块和加载数据开始。我们还导入了scikit-learn中的OrdinalEncoder模块,因为我们稍后将使用它。import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.preprocessing import OrdinalEncoder from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
接下来,我们为 NLS 数据创建训练和测试 DataFrame。
在本节中,我们为了简化处理,会丢弃缺失数据的行:
feature_cols = ['gender','maritalstatus','colenroct99']
nls97demo = nls97[['wageincome20'] + feature_cols].dropna()
X_demo_train, X_demo_test, y_demo_train, y_demo_test = \
train_test_split(nls97demo[feature_cols],\
nls97demo[['wageincome20']], test_size=0.3, random_state=0)
-
我们可以选择的编码方式之一是 pandas 的
get_dummies方法。我们可以使用它来指示我们希望转换gender和maritalstatus特征。get_dummies为gender和maritalstatus的每个取值创建一个虚拟变量。例如,gender有 Female 和 Male 两个取值。get_dummies创建一个特征gender_Female,当gender为 Female 时其值为 1,当gender为 Male 时其值为 0。当gender为 Male 时,gender_Male为 1,gender_Female为 0。这是一种经过验证的方法,统计学家多年来一直使用它进行编码:pd.get_dummies(X_demo_train, columns=['gender','maritalstatus'], dtype=float).\ head(2).T606986 764231 colenroct99 3\. 4-year college 1\. Not enrolled gender_Female 1 0 gender_Male 0 1 maritalstatus_Divorced 0 0 maritalstatus_Married 0 1 maritalstatus_Never-married 1 0 maritalstatus_Separated 0 0 maritalstatus_Widowed 0 0
我们没有使用get_dummies创建一个新的 DataFrame,因为我们将在这个食谱的后续步骤中使用另一种技术来进行编码。
我们通常为特征的k个唯一值创建k-1 个虚拟变量。因此,如果gender在我们的数据中有两个值,我们只需要创建一个虚拟变量。如果我们知道gender_Female的值,也就知道gender_Male的值,因此后者是多余的。类似地,如果我们知道其他maritalstatus虚拟变量的值,我们就能知道maritalstatus_Divorced的值。以这种方式创建冗余被不优雅地称为虚拟变量陷阱。为了避免这个问题,我们从每个组中删除一个虚拟变量。
注意
对于某些机器学习算法,比如线性回归,删除一个虚拟变量实际上是必需的。在估计线性模型的参数时,矩阵需要进行求逆。如果我们的模型有截距,并且包括所有虚拟变量,那么矩阵就无法求逆。
-
我们可以将
get_dummies的drop_first参数设置为True,以从每个组中删除第一个虚拟变量:pd.get_dummies(X_demo_train, columns=['gender','maritalstatus'], dtype=float, drop_first=True).head(2).T606986 764231 colenroct99 3\. 4-year college 1\. Not enrolled gender_Male 0 1 maritalstatus_Married 0 1 maritalstatus_Never-married 1 0 maritalstatus_Separated 0 0 maritalstatus_Widowed 0 0
get_dummies的替代方法是sklearn或feature_engine中的独热编码器。这些独热编码器的优势在于它们可以轻松集成到机器学习管道中,并且能够将从训练数据集中获取的信息传递到测试数据集中。
-
让我们使用
feature_engine中的OneHotEncoder进行编码。我们将drop_last设置为True,以从每个组中删除一个虚拟变量。然后我们将编码拟合到训练数据上,并对训练数据和测试数据进行转换。ohe = OneHotEncoder(drop_last=True, variables=['gender','maritalstatus']) ohe.fit(X_demo_train) X_demo_train_ohe = ohe.transform(X_demo_train) X_demo_test_ohe = ohe.transform(X_demo_test) X_demo_train_ohe.filter(regex='gen|mar', axis="columns").head(2).T606986 764231 gender_Female 1 0 maritalstatus_Never-married 1 0 maritalstatus_Married 0 1 maritalstatus_Divorced 0 0 maritalstatus_Separated 0 0
这证明了独热编码是一种相当简单的方法,用于为机器学习算法准备名义数据。
它是如何工作的…
pandas 的get_dummies方法是创建虚拟变量或独热编码的便捷方式。我们在步骤 3中看到了这一点,当时我们只需将训练 DataFrame 和需要虚拟变量的列传递给get_dummies。请注意,我们为dtype使用了float。根据你的 pandas 版本,可能需要这样做,以返回 0 和 1 的值,而不是 true 和 false 的值。
我们通常需要从虚拟变量组中删除一个值,以避免虚拟变量陷阱。我们可以将drop_first设置为True,以从每个虚拟变量组中删除第一个虚拟变量。这就是我们在步骤 4中所做的。
我们在步骤 5中查看了另一个独热编码工具feature_engine。我们能够使用feature_engine的OneHotEncoder完成与get_dummies相同的任务。使用feature_engine的优势是它提供了多种工具,可在 scikit-learn 数据管道中使用,包括能够处理训练集或测试集中的类别,但不能同时处理两者。
还有更多
我没有在本教程中讨论 scikit-learn 自带的 one-hot 编码器。它的工作原理与 feature_engine 的 one-hot 编码器非常相似。虽然使用其中一个没有比使用另一个有太多优势,但我发现 feature_engine 的 transform 和 fit_transform 方法返回的是 DataFrame,而 scikit-learn 的这些方法返回的是 NumPy 数组,这一点挺方便的。
编码类别特征:顺序编码
类别特征可以是名义性的或顺序性的。性别和婚姻状况是名义性的,它们的值没有顺序。例如,未婚并不比离婚的值更高。
然而,当类别特征是顺序时,我们希望编码能捕捉到值的排序。例如,如果我们有一个特征,值为低、中和高,那么 one-hot 编码会丢失这个排序。相反,如果我们将低、中、高分别转化为 1、2、3 的值,这样会更好。我们可以通过顺序编码来实现这一点。
NLS 数据集中的大学入学特征可以视为顺序特征。其值从 1. 未入学 到 3. 4 年制大学。我们应该使用顺序编码将其准备好用于建模。接下来,我们就这么做。
准备工作
我们将在本教程中使用来自 scikit-learn 的 OrdinalEncoder 模块。
如何操作...
-
1999 年的大学入学情况可能是顺序编码的一个很好的候选。让我们先查看
colenroct99编码前的值。虽然这些值是字符串类型的,但它们有一个隐含的顺序:X_demo_train.colenroct99.\ sort_values().unique()array(['1\. Not enrolled', '2\. 2-year college ', '3\. 4-year college'], dtype=object)X_demo_train.head()gender maritalstatus colenroct99 606986 Female Never-married 3\. 4-year college 764231 Male Married 1\. Not enrolled 673419 Male Never-married 3\. 4-year college 185535 Male Married 1\. Not enrolled 903011 Male Never-married 1\. Not enrolled
我们需要小心线性假设。例如,如果我们尝试建模大学入学特征对某个目标变量的影响,我们不能假设从 1 到 2 的变化(从未入学到入学 2 年)与从 2 到 3 的变化(从 2 年制大学到 4 年制大学入学)具有相同的影响。
-
我们可以通过将上述数组传递给
categories参数,告诉OrdinalEncoder按照隐含的顺序对值进行排序。然后,我们可以使用fit_transform方法来转换大学入学字段colenroct99。(sklearn 的OrdinalEncoder的fit_transform方法返回一个 NumPy 数组,因此我们需要使用 pandas DataFrame 方法来创建 DataFrame。)最后,我们将编码后的特征与训练数据中的其他特征连接起来:oe = OrdinalEncoder(categories=\ [X_demo_train.colenroct99.sort_values().\ unique()]) colenr_enc = \ pd.DataFrame(oe.fit_transform(X_demo_train[['colenroct99']]), columns=['colenroct99'], index=X_demo_train.index) X_demo_train_enc = \ X_demo_train[['gender','maritalstatus']].\ join(colenr_enc) -
让我们查看从结果 DataFrame 中获得的一些观测值。我们还应将原始大学入学特征与转换后的特征进行比较:
X_demo_train_enc.head()gender maritalstatus colenroct99 606986 Female Never-married 2 764231 Male Married 0 673419 Male Never-married 2 185535 Male Married 0 903011 Male Never-married 0X_demo_train.colenroct99.value_counts().\ sort_index()colenroct99 1\. Not enrolled 2843 2\. 2-year college 137 3\. 4-year college 324 Name: count, dtype: int64X_demo_train_enc.colenroct99.value_counts().\ sort_index()colenroct99 0 2843 1 137 2 324 Name: count, dtype: int64
顺序编码将 colenroct99 的初始值替换为从 0 到 2 的数字。它现在的形式可以被许多机器学习模型使用,并且我们保留了有意义的排序信息。
它是如何工作的...
Scitkit-learn 的 OrdinalEncoder 使用起来非常简单。我们在 步骤 2 开始时实例化了一个 OrdinalEncoder 对象,并传递了一个按意义排序的值数组作为类别。然后,我们将仅包含 colenroct99 列的训练数据传递给 OrdinalEncoder 的 fit_transform 方法。最后,我们将 fit_transform 返回的 NumPy 数组转换为 DataFrame,并使用训练数据的索引,使用 join 方法将其余的训练数据附加到其中。
更多内容
序数编码适用于非线性模型,如决策树。在线性回归模型中可能没有意义,因为这会假定值之间的距离在整个分布中是均等有意义的。在本例中,这将假定从 0 到 1 的增加(从未注册到两年注册)与从 1 到 2 的增加(从两年注册到四年注册)是相同的。
One-hot 和序数编码是工程化分类特征的相对直接的方法。当有更多唯一值时,处理分类特征可能会更加复杂。我们将在下一节介绍处理这些特征的几种技术。
对具有中等或高基数的分类特征进行编码
当我们处理具有许多唯一值的分类特征时,比如 15 个或更多时,为每个值创建虚拟变量可能不切实际。当基数很高时,即唯一值的数量非常大时,某些值的观察次数可能太少,以至于无法为我们的模型提供足够的信息。极端情况下,对于 ID 变量,每个值只有一个观察结果。
处理中等或高基数的分类特征有几种方法。一种方法是为前 k 个类别创建虚拟变量,并将其余的值分组到 其他 类别中。另一种方法是使用特征哈希,也称为哈希技巧。我们将在本示例中探讨这两种策略。
准备工作
在本例中,我们将继续使用 feature_engine 中的 OneHotEncoder。我们还将使用 category_encoders 中的 HashingEncoder。在这个例子中,我们将使用 COVID-19 数据,该数据包括各国的总病例和死亡情况,以及人口统计数据。
数据注意
Our World in Data 在 ourworldindata.org/covid-cases 提供 COVID-19 的公共数据。本示例中使用的数据是在 2024 年 3 月 3 日下载的。
如何做...
-
让我们从 COVID-19 数据中创建训练和测试 DataFrame,然后导入
feature_engine和category_encoders库:import pandas as pd from feature_engine.encoding import OneHotEncoder from category_encoders.hashing import HashingEncoder from sklearn.model_selection import train_test_split covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','life_expectancy','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0)
区域特征有 16 个唯一值,其中前 5 个值的计数为 10 或更多:
X_train.region.value_counts()
region
Eastern Europe 15
Western Europe 15
West Asia 12
South America 11
Central Africa 10
East Asia 9
Caribbean 9
Oceania / Aus 9
West Africa 7
Southern Africa 7
Central Asia 6
South Asia 6
East Africa 5
Central America 5
North Africa 4
North America 1
Name: count, dtype: int64
-
我们可以再次使用
feature_engine中的OneHotEncoder来编码region特征。这一次,我们使用top_categories参数来指示我们只想为前六个类别值创建虚拟变量。所有不属于前六名的region值将为所有虚拟变量赋值为 0:ohe = OneHotEncoder(top_categories=6, variables=['region']) covidtotals_ohe = ohe.fit_transform(covidtotals) covidtotals_ohe.filter(regex='location|region', axis="columns").sample(5, random_state=2).T31 157 2 170 78 location Bulgaria Palestine Algeria Russia Ghana region_Eastern Europe 1 0 0 1 0 region_Western Europe 0 0 0 0 0 region_West Africa 0 0 0 0 1 region_West Asia 0 1 0 0 0 region_East Asia 0 0 0 0 0 region_Caribbean 0 0 0 0 0
当分类特征具有许多唯一值时,特征哈希是一种替代的独热编码方法。
特征哈希将大量唯一的特征值映射到较少的虚拟变量上。我们可以指定要创建的虚拟变量数量。每个特征值仅映射到一个虚拟变量组合。然而,冲突是可能的——也就是说,一些特征值可能映射到相同的虚拟变量组合。随着我们减少请求的虚拟变量数量,冲突的数量会增加。
-
我们可以使用
category_encoders中的HashingEncoder来进行特征哈希。我们使用n_components来指定我们想要 6 个虚拟变量(在转换之前,我们复制了region特征,以便可以将原始值与新的虚拟变量进行比较):X_train['region2'] = X_train.region he = HashingEncoder(cols=['region'], n_components=6) X_train_enc = he.fit_transform(X_train) X_train_enc.\ groupby(['col_0','col_1','col_2','col_3','col_4', 'col_5','region2']).\ size().reset_index(name="count")col_0 col_1 col_2 col_3 col_4 col_5 region2 count 0 0 0 0 0 0 1 Caribbean 9 1 0 0 0 0 0 1 Central Africa 10 2 0 0 0 0 0 1 East Africa 5 3 0 0 0 0 0 1 North Africa 4 4 0 0 0 0 1 0 Central America 5 5 0 0 0 0 1 0 Eastern Europe 15 6 0 0 0 0 1 0 North America 1 7 0 0 0 0 1 0 Oceania / Aus 9 8 0 0 0 0 1 0 Southern Africa 7 9 0 0 0 0 1 0 West Asia 12 10 0 0 0 0 1 0 Western Europe 15 11 0 0 0 1 0 0 Central Asia 6 12 0 0 0 1 0 0 East Asia 9 13 0 0 0 1 0 0 South Asia 6 14 0 0 1 0 0 0 West Africa 7 15 1 0 0 0 0 0 South America 11
不幸的是,这会导致大量的冲突。例如,加勒比地区、中非、东非和北非都获得相同的虚拟变量值。至少在这种情况下,使用独热编码并指定类别数量,或增加哈希编码器的组件数量,能为我们提供更好的结果。
它是如何工作的...
我们以与编码分类特征:独热编码配方相同的方式使用了feature_engine中的OneHotEncoder。这里的区别是,我们将虚拟变量限制为具有最多行数的六个区域(在这种情况下是国家)。所有不属于前六个区域的国家都会为所有虚拟变量赋值为零,例如步骤 2中的阿尔及利亚。
在步骤 3中,我们使用了category_encoders中的HashingEncoder。我们指定了要使用的列region,并且我们想要六个虚拟变量。我们使用了HashingEncoder的fit_transform方法来拟合和转换我们的数据,正如我们在feature_engine的OneHotEncoder和 scikit-learn 的OrdinalEncoder中所做的那样。
我们在最后三个配方中已经涵盖了常见的编码策略:独热编码、序数编码和特征哈希。在我们可以将几乎所有的分类特征应用到模型之前,几乎都会需要某种编码。但有时我们还需要以其他方式修改我们的特征,包括变换、分箱和缩放。我们将在接下来的三个配方中探讨修改特征的原因,并探索用于实现这些操作的工具。
使用数学变换
有时候,我们希望使用的特征不具备高斯分布,而机器学习算法假设我们的特征是以这种方式分布的。当出现这种情况时,我们要么需要改变使用的算法(例如选择 KNN 或随机森林而不是线性回归),要么转换我们的特征,使其近似于高斯分布。在本示例中,我们讨论了几种实现后者的策略。
准备工作
在本示例中,我们将使用来自 feature engine 的 transformation 模块。我们继续使用 COVID-19 数据,其中每个国家都有总病例和死亡数以及一些人口统计数据。
如何实现...
-
我们首先从 feature_engine 导入 transformation 模块,从 sklearn 导入 train_test_split,从 scipy 导入 stats。我们还使用 COVID-19 数据创建了训练和测试 DataFrame:
import pandas as pd from feature_engine import transformation as vt from sklearn.model_selection import train_test_split import matplotlib.pyplot as plt from scipy import stats covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','life_expectancy','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0) -
让我们看看各国总病例分布如何。我们还应计算偏度:
y_train.total_cases.skew()6.092053479609332plt.hist(y_train.total_cases) plt.title("Total COVID-19 Cases (in millions)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这产生了以下直方图:

图 8.1:总 COVID-19 病例的直方图
这说明了总病例的非常高的偏度。实际上,它看起来是对数正态分布,这并不奇怪,考虑到非常低的值和几个非常高的值的大量存在。
-
让我们尝试一个对数变换。要使 feature_engine 执行变换,我们只需调用
LogTransformer并传递要转换的特征:tf = vt.LogTransformer(variables = ['total_cases']) y_train_tf = tf.fit_transform(y_train) y_train_tf.total_cases.skew()0.09944093918837159plt.hist(y_train_tf.total_cases) plt.title("Total COVID-19 Cases (log transformation)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这产生了以下直方图:

图 8.2:经对数变换后的总 COVID-19 病例的直方图
实际上,对数变换增加了分布低端的变异性,并减少了高端的变异性。这产生了一个更对称的分布。这是因为对数函数的斜率对较小值比对较大值更陡。
-
这确实是一个很大的改进,但我们还是尝试一个 Box-Cox 变换,看看得到的结果:
tf = vt.BoxCoxTransformer(variables = ['total_cases']) y_train_tf = tf.fit_transform(y_train) y_train_tf.total_cases.skew()0.010531307863482307plt.hist(y_train_tf.total_cases) plt.title("Total COVID-19 Cases (Box Cox transformation)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这产生了以下直方图:

图 8.3:经 Box-Cox 变换后的总 COVID-19 病例的直方图
Box-Cox 变换可识别一个在 -5 到 5 之间的 lambda 值,生成最接近正态分布的分布。它使用以下方程进行变换:

或

其中
是我们的转换后特征。仅仅出于好奇,让我们看看用于转换 total_cases 的 lambda 值:
stats.boxcox(y_train.total_cases)[1]
-0.020442184436288167
Box-Cox 变换的 lambda 是 -0.02。作为比较,具有高斯分布特征的 lambda 值为 1.000,意味着不需要进行任何变换。
工作原理...
我们的许多研究或建模项目需要对特征或目标变量进行某些转换,以便获得良好的结果。像feature engine这样的工具使我们能够轻松地在数据准备过程中结合这些转换。我们在步骤 1中导入了transformation模块,然后在步骤 3中使用它进行了对数转换,在步骤 4中进行了 Box-Cox 转换。
经过对数和 Box-Cox 转换后,转换后的总病例特征看起来不错。这可能是一个更容易建模的目标。将此转换与其他预处理步骤集成到管道中也非常简单。Feature_engine还有许多其他转换,类似于对数和 Box-Cox 转换。
参见
你可能会想,我们是如何使用转换后的目标进行预测或评估模型的。实际上,设置我们的管道以在进行预测时恢复值为原始值是相当简单的。我在我的书《数据清理与机器学习探索》中详细介绍了这一点。
特征分箱:等宽和等频
我们有时需要将一个特征从连续型转换为类别型。创建* k *个等间距区间,覆盖从最小值到最大值的分布过程称为分箱,或者更不友好的说法是离散化。分箱可以解决特征的几个重要问题:偏斜、过度峰度以及异常值的存在。
准备工作
对于 COVID-19 总病例数据,分箱可能是一个不错的选择。它也可能对数据集中的其他变量有用,包括总死亡人数和人口,但我们目前只处理总病例数据。total_cases是以下代码中的目标变量,因此它是y_train数据框中的一列——唯一的一列。
让我们尝试使用 COVID-19 数据进行等宽和等频分箱。
如何操作...
-
我们首先需要从
feature_engine导入EqualFrequencyDiscretiser和EqualWidthDiscretiser。我们还需要从 COVID-19 数据中创建训练和测试数据框:import pandas as pd from feature_engine.discretisation import EqualFrequencyDiscretiser as efd from feature_engine.discretisation import EqualWidthDiscretiser as ewd from sklearn.preprocessing import KBinsDiscretizer from sklearn.model_selection import train_test_split covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','life_expectancy','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0) -
我们可以使用 pandas 的
qcut方法及其q参数来创建 10 个相对等频的分箱:y_train['total_cases_group'] = \ pd.qcut(y_train.total_cases, q=10, labels=[0,1,2,3,4,5,6,7,8,9]) y_train.total_cases_group.value_counts().\ sort_index()total_cases_group 0 14 1 13 2 13 3 13 4 13 5 13 6 13 7 13 8 13 9 13 Name: count, dtype: int64 -
我们可以通过
EqualFrequencyDiscretiser实现相同的效果。首先,我们定义一个函数来运行转换。该函数接受一个feature_engine转换器以及训练和测试数据框。它返回转换后的数据框(虽然定义函数不是必要的,但在这里定义有意义,因为我们稍后会在本食谱中重复这些步骤):def runtransform(bt, dftrain, dftest): bt.fit(dftrain) train_bins = bt.transform(dftrain) test_bins = bt.transform(dftest) return train_bins, test_bins -
接下来,我们创建一个
EqualFrequencyDiscretiser转换器,并调用我们刚刚创建的runtransform函数:y_train.drop(['total_cases_group'], axis=1, inplace=True) bintransformer = efd(q=10, variables=['total_cases']) y_train_bins, y_test_bins = runtransform(bintransformer, y_train, y_test) y_train_bins.total_cases.value_counts().sort_index()total_cases 0 14 1 13 2 13 3 13 4 13 5 13 6 13 7 13 8 13 9 13 Name: count, dtype: int64
这为我们提供了与qcut相同的结果,但它的优势在于,它更容易集成到机器学习管道中,因为我们使用feature_engine来生成它。等频分箱解决了偏斜和异常值问题。
-
EqualWidthDiscretiser的工作方式类似:bintransformer = ewd(bins=10, variables=['total_cases']) y_train_bins, y_test_bins = runtransform(bintransformer, y_train, y_test) y_train_bins.total_cases.value_counts().sort_index()total_cases 0 122 1 2 2 3 3 2 4 1 9 1 Name: count, dtype: int64
这是一个远未成功的转换。几乎所有的值都处于分布底部,所以平均宽度分箱会出现相同的问题是不奇怪的。尽管我们请求了 10 个箱,但结果只有 6 个。
-
让我们来看看每个箱的范围。我们可以看到,由于分布顶部观察值数量较少,等宽分箱器甚至不能构建等宽箱:
y_train_bins = y_train_bins.\ rename(columns={'total_cases':'total_cases_group'}).\ join(y_train) y_train_bins.groupby("total_cases_group")["total_cases"].\ agg(['min','max'])min max total_cases_group 0 5,085 8,633,769 1 11,624,000 13,980,340 2 23,774,451 26,699,442 3 37,519,960 38,437,756 4 45,026,139 45,026,139 9 99,329,249 99,329,249
虽然在这种情况下等宽分箱是一个糟糕的选择,但有时它是有意义的。当数据更均匀分布或等宽的时候,它可以很有用。
k-means 分箱
另一个选项是使用k-means 聚类来确定箱的位置。k-means 算法随机选择k个数据点作为聚类的中心,然后将其他数据点分配到最近的聚类中。计算每个聚类的均值,然后将数据点重新分配到最近的新聚类中。这个过程重复进行,直到找到最佳的中心。
当使用k-means 进行分箱时,同一聚类中的所有数据点将具有相同的序数值。
准备工作
这次我们将使用 scikit-learn 进行分箱。Scitkit-learn有一个很好的工具,可以基于k-means 创建箱,即KBinsDiscretizer。
如何做...
-
我们首先实例化一个
KBinsDiscretizer对象。我们将用它来创建 COVID-19 案例数据的箱:kbins = KBinsDiscretizer(n_bins=10, encode='ordinal', strategy='kmeans', subsample=None) y_train_bins = \ pd.DataFrame(kbins.fit_transform(y_train), columns=['total_cases'], index=y_train.index) y_train_bins.total_cases.value_counts().sort_index()total_cases 0 57 1 19 2 25 3 10 4 11 5 2 6 3 7 2 8 1 9 1 Name: count, dtype: int64 -
让我们比较原始总案例变量的偏斜和峰度与分箱变量的偏斜和峰度。回想一下,我们期望具有高斯分布的变量的偏斜为 0,峰度接近 3。分箱变量的分布更接近高斯分布:
y_train.total_cases.agg(['skew','kurtosis'])skew 6.092 kurtosis 45.407 Name: total_cases, dtype: float64y_train_bins.total_cases.agg(['skew','kurtosis'])skew 1.504 kurtosis 2.281 Name: total_cases, dtype: float64 -
让我们更仔细地查看每个箱中总案例值的范围。第一个箱的范围达到 272,010 个总案例,下一个箱的范围达到 834,470 个。大约在 860 万个总案例后,国家数量有相当大的减少。我们可以考虑将箱的数量减少到 5 或 6 个:
y_train_bins.rename(columns={'total_cases':'total_cases_bin'}, inplace=True) y_train.join(y_train_bins).\ groupby(['total_cases_bin'])['total_cases'].\ agg(['min','max','size'])min max size total_cases_bin 0 5,085 272,010 57 1 330,417 834,470 19 2 994,037 2,229,538 25 3 2,465,545 4,536,733 10 4 5,269,967 8,633,769 11 5 11,624,000 13,980,340 2 6 23,774,451 26,699,442 3 7 37,519,960 38,437,756 2 8 45,026,139 45,026,139 1 9 99,329,249 99,329,249 1
这些步骤展示了如何使用k-means 进行分箱。
工作原理...
运行k-means 分箱所需的全部是实例化一个KBinsDiscretizer对象。我们指定了我们想要的箱数10,并且我们希望箱是ordinal的。我们指定ordinal是因为我们希望较高的箱值反映出较高的总案例值。我们将从 scikit-learn 的fit_transform返回的 NumPy 数组转换为 DataFrame。在数据流水线中,这通常是不必要的,但我们在这里这样做是因为我们将在后续步骤中使用 DataFrame。
分箱可以帮助我们处理数据中的偏斜、峰度和异常值。然而,它确实掩盖了特征变化的大部分,并降低了其解释能力。通常情况下,一些形式的缩放,如最小-最大或 z 分数,是更好的选择。让我们在下一个示例中来看一下特征缩放。
特征缩放
通常,想要在模型中使用的特征处于不同的尺度上。换句话说,最小值和最大值之间的距离,或范围,在不同的特征中有很大差异。例如,在 COVID-19 数据中,total cases 特征的值从 5000 到近 1 亿,而 aged 65 or older 特征的值从 9 到 27(表示人口的百分比)。
特征的尺度差异会影响许多机器学习算法。例如,KNN 模型通常使用欧几里得距离,尺度更大的特征将对模型产生更大的影响。缩放可以解决这个问题。
本节将介绍两种常见的缩放方法:最小-最大缩放和标准(或z-score)缩放。最小-最大缩放将每个值替换为其在范围中的位置。更具体地说:

在这里,z[ij] 是最小-最大得分,x[ij] 是 i^(th) 观测值对应的 j^(th) 特征的值,min[j] 和 max[j] 分别是 j^(th) 特征的最小值和最大值。
标准缩放将特征值标准化为均值为 0 的数据。那些学过本科统计学的人会认识到这就是 z-score。具体来说:

在这里,x[ij] 是 i^(th) 观测值对应的 j^(th) 特征的值,u[j] 是 j 特征的均值,s[j] 是该特征的标准差。
准备就绪
我们将使用 scikit-learn 的预处理模块进行本食谱中的所有变换。我们将再次使用 COVID-19 数据。
如何进行...
我们可以使用 scikit-learn 的预处理模块来获取最小-最大和标准缩放器:
-
我们首先导入
preprocessing模块,并从 COVID-19 数据中创建训练和测试 DataFrame:import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler, StandardScaler, RobustScaler covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['population','total_deaths', 'aged_65_older','life_expectancy'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0) -
现在我们可以运行最小-最大缩放器。正如我们在之前的食谱中使用 scikit-learn 的
fit_transform时所做的那样,我们将 NumPy 数组转换为 DataFrame,以便使用训练 DataFrame 的列和索引返回。注意,现在所有特征的值都在 0 到 1 之间:scaler = MinMaxScaler() X_train_mms = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_mms.describe()population total_deaths aged_65_older life_expectancy count 131.00 131.00 131.00 131.00 mean 0.03 0.05 0.34 0.65 std 0.13 0.14 0.28 0.23 min 0.00 0.00 0.00 0.00 25% 0.00 0.00 0.11 0.54 50% 0.01 0.01 0.24 0.69 75% 0.02 0.03 0.60 0.81 max 1.00 1.00 1.00 1.00 -
我们以相同的方式运行标准缩放器:
scaler = StandardScaler() X_train_ss = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_ss.describe()population total_deaths aged_65_older life_expectancy count 131.00 131.00 131.00 131.00 mean -0.00 -0.00 -0.00 0.00 std 1.00 1.00 1.00 1.00 min -0.28 -0.39 -1.24 -2.79 25% -0.26 -0.38 -0.84 -0.48 50% -0.22 -0.34 -0.39 0.18 75% -0.09 -0.15 0.93 0.67 max 7.74 6.95 2.37 1.51
如果数据中有异常值,鲁棒缩放可能是一个不错的选择。鲁棒缩放从每个变量的值中减去中位数,并将该值除以四分位数间距。因此,每个值是:

其中
是 j^(th) 特征的值,median[j]、3^(rd) quantile[j] 和 1^(st) quantile[j] 分别是 j^(th) 特征的中位数、第三四分位数和第一四分位数。鲁棒缩放对极端值的敏感度较低,因为它不使用均值或方差。
-
我们可以使用 scikit-learn 的
RobustScaler模块来进行鲁棒缩放:scaler = RobustScaler() X_train_rs = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_rs.describe()population total_deaths aged_65_older life_expectancy count 131.00 131.00 131.00 131.00 mean 1.29 1.51 0.22 -0.16 std 5.81 4.44 0.57 0.87 min -0.30 -0.20 -0.48 -2.57 25% -0.22 -0.16 -0.26 -0.58 50% 0.00 0.00 0.00 0.00 75% 0.78 0.84 0.74 0.42 max 46.09 32.28 1.56 1.15
之前的步骤演示了三种常见的缩放变换:标准缩放、最小-最大缩放和鲁棒缩放。
它是如何工作的...
我们在大多数机器学习算法中都使用特征缩放。虽然它并非总是必要的,但它会带来明显更好的结果。最小-最大缩放和标准缩放是常见的缩放技术,但有时使用鲁棒缩放可能是更好的选择。
Scikit-learn的preprocessing模块使得使用各种缩放转换变得简单。我们只需实例化缩放器,然后运行fit、transform或fit_transform方法。
总结
本章中我们涵盖了广泛的特征工程技术。我们使用工具删除冗余或高度相关的特征。我们探讨了最常见的编码方法——独热编码、顺序编码和哈希编码。然后我们使用变换改善特征的分布。最后,我们使用常见的分箱和缩放方法来解决偏斜、峰度和异常值问题,并调整具有不同范围的特征。在下一章,我们将学习如何在汇总时修复杂乱的数据。
留下评论!
享受这本书吗?通过留下亚马逊评论来帮助像您这样的读者。扫描下面的二维码,免费获取您选择的电子书。

第九章:聚合时修复凌乱的数据
本书的前几章介绍了生成整个 DataFrame 汇总统计数据的技巧。我们使用了 describe、mean 和 quantile 等方法来实现这一点。本章讨论了更复杂的聚合任务:按类别变量聚合以及使用聚合来改变 DataFrame 的结构。
在数据清理的初始阶段之后,分析师会花费大量时间进行 Hadley Wickham 所说的 拆分-应用-合并——即我们按组对数据进行子集化,对这些子集应用某些操作,然后得出对整个数据集的结论。更具体一点来说,这涉及到通过关键类别变量生成描述性统计数据。对于 nls97 数据集,这可能是性别、婚姻状况以及最高学历。而对于 COVID-19 数据,我们可能会按国家或日期对数据进行分段。
通常,我们需要聚合数据以为后续分析做准备。有时,DataFrame 的行被细分得比所需的分析单位更细,这时必须先进行某些聚合操作,才能开始分析。例如,我们的 DataFrame 可能包含多年来按物种每天记录的鸟类观察数据。由于这些数据波动较大,我们可能决定通过只处理每月甚至每年按物种统计的总观测量来平滑这些波动。另一个例子是家庭和汽车修理支出,我们可能需要按年度总结这些支出。
使用 NumPy 和 pandas 有多种聚合数据的方法,每种方法都有其特定的优点。本章将探讨最有用的方法:从使用 itertuples 进行循环,到在 NumPy 数组上进行遍历,再到使用 DataFrame 的 groupby 方法和透视表的多种技巧。熟悉 pandas 和 NumPy 中可用的全套工具非常有帮助,因为几乎所有的数据分析项目都需要进行某种聚合,而聚合通常是我们数据清理过程中最重要的步骤之一,选择合适的工具往往取决于数据的特征,而不是我们的个人偏好。
本章中的具体实例包括:
-
使用
itertuples循环遍历数据(反模式) -
使用 NumPy 数组按组计算汇总
-
使用
groupby按组组织数据 -
使用更复杂的聚合函数与
groupby -
使用用户定义的函数和
groupby中的 apply -
使用
groupby改变 DataFrame 的分析单位 -
使用 pandas 的
pivot_table函数改变分析单位
技术要求
本章的实例需要 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码同样适用于 pandas 1.5.3 或更高版本。
本章的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
使用itertuples循环遍历数据(反模式)
在本食谱中,我们将遍历数据框的每一行,并为一个变量生成自己的总计。在本章后续的食谱中,我们将使用 NumPy 数组,然后是一些 pandas 特定技术,来完成相同的任务。
开始这一章时使用一个我们通常被警告不要使用的技术,可能看起来有些奇怪。但在 35 年前,我曾在 SAS 中做过类似的日常循环操作,甚至在 10 年前的 R 中偶尔也会使用。因此,即使我很少以这种方式实现代码,我仍然会从概念上考虑如何遍历数据行,有时会按组排序。我认为即使在使用其他对我们更有效的 pandas 方法时,保持这种概念化的思维是有益的。
我不想给人留下 pandas 特定技术总是明显更高效的印象。pandas 用户可能会发现自己比预期更多地使用apply,这种方法比循环稍微快一点。
准备工作
在本食谱中,我们将使用 COVID-19 每日病例数据。每行代表一天,每个国家一行,包含当天的新病例数和新死亡人数。它反映了截至 2024 年 3 月的总数。
我们还将使用来自巴西 87 个气象站 2023 年的陆地温度数据。大多数气象站每个月有一个温度读数。
数据说明
我们的数据来源于Our World in Data,提供 COVID-19 的公共数据。该数据集包括总病例数和死亡人数、施行的检测次数、医院床位,以及人口统计数据,如中位年龄、国内生产总值和糖尿病患病率。此食谱中使用的数据集是在 2024 年 3 月 3 日下载的。
陆地温度数据框包含了 2023 年来自全球超过 12,000 个站点的平均温度(以^°C 为单位),尽管大多数站点位于美国。原始数据是从全球历史气候网整合数据库中提取的。美国国家海洋和大气管理局将其公开提供,网址为www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly。
如何操作…
我们将使用itertuples数据框方法来遍历 COVID-19 每日数据和巴西的月度陆地温度数据。我们将添加逻辑来处理缺失数据和关键变量值在不同时间段之间的意外变化:
-
导入
pandas和numpy,并加载 COVID-19 和陆地温度数据:import pandas as pd coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) ltbrazil = pd.read_csv("data/ltbrazil.csv") -
按位置和日期对数据进行排序:
coviddaily = coviddaily.sort_values(['location','casedate']) -
使用
itertuples遍历行。
使用 itertuples,它允许我们将所有行作为命名元组进行遍历。对每个国家的所有日期求新增病例的总和。每当国家(location)发生变化时,将当前的累计值附加到 rowlist 中,然后将计数重置为 0(请注意,rowlist 是一个列表,每次国家发生变化时,我们都会向 rowlist 中添加一个字典。字典列表是暂时存储数据的一个好地方,数据最终可以转为 DataFrame)。
prevloc = 'ZZZ'
rowlist = []
casecnt = 0
for row in coviddaily.itertuples():
... if (prevloc!=row.location):
... if (prevloc!='ZZZ'):
... rowlist.append({'location':prevloc, 'casecnt':casecnt})
... casecnt = 0
... prevloc = row.location
... casecnt += row.new_cases
...
rowlist.append({'location':prevloc, 'casecnt':casecnt})
len(rowlist)
231
rowlist[0:4]
[{'location': 'Afghanistan', 'casecnt': 231539.0},
{'location': 'Albania', 'casecnt': 334863.0},
{'location': 'Algeria', 'casecnt': 272010.0},
{'location': 'American Samoa', 'casecnt': 8359.0}]
- 从汇总值列表
rowlist创建一个 DataFrame。
将我们在上一步创建的列表传递给 pandas 的 DataFrame 方法:
covidtotals = pd.DataFrame(rowlist)
covidtotals.head()
location casecnt
0 Afghanistan 231,539
1 Albania 334,863
2 Algeria 272,010
3 American Samoa 8,359
4 Andorra 48,015
- 现在,我们对陆地温度数据做同样的处理。我们首先按
station和month排序。
同时,删除温度缺失的行:
ltbrazil = ltbrazil.sort_values(['station','month'])
ltbrazil = ltbrazil.dropna(subset=['temperature'])
- 排除每一周期之间变化较大的行。
计算年度平均温度,排除比上个月的温度高出或低于 3°C 的值:
prevstation = 'ZZZ'
prevtemp = 0
rowlist = []
tempcnt = 0
stationcnt = 0
for row in ltbrazil.itertuples():
... if (prevstation!=row.station):
... if (prevstation!='ZZZ'):
... rowlist.append({'station':prevstation, 'avgtemp':tempcnt/stationcnt, 'stationcnt':stationcnt})
... tempcnt = 0
... stationcnt = 0
... prevstation = row.station
... # choose only rows that are within 3 degrees of the previous temperature
... if ((0 <= abs(row.temperature-prevtemp) <= 3) or (stationcnt==0)):
... tempcnt += row.temperature
... stationcnt += 1
... prevtemp = row.temperature
...
rowlist.append({'station':prevstation, 'avgtemp':tempcnt/stationcnt, 'stationcnt':stationcnt})
rowlist[0:5]
[{'station': 'ALTAMIRA', 'avgtemp': 27.729166666666668, 'stationcnt': 12},
{'station': 'ALTA_FLORESTA_AERO',
'avgtemp': 32.49333333333333,
'stationcnt': 9},
{'station': 'ARAXA', 'avgtemp': 21.52142857142857, 'stationcnt': 7},
{'station': 'BACABAL', 'avgtemp': 28.59166666666667, 'stationcnt': 6},
{'station': 'BAGE', 'avgtemp': 19.615000000000002, 'stationcnt': 10}]
- 根据汇总值创建一个 DataFrame。
将我们在上一步创建的列表传递给 pandas 的 DataFrame 方法:
ltbrazilavgs = pd.DataFrame(rowlist)
ltbrazilavgs.head()
station avgtemp stationcnt
0 ALTAMIRA 28 12
1 ALTA_FLORESTA_AERO 32 9
2 ARAXA 22 7
3 BACABAL 29 6
4 BAGE 20 10
这将为我们提供一个包含 2023 年平均温度和每个站点观测次数的 DataFrame。
它是如何工作的...
在 第 2 步 中通过 location 和 casedate 对 COVID-19 每日数据进行排序后,我们逐行遍历数据,并在 第 3 步 中对新增病例进行累计。每当遇到一个新国家时,我们将累计值重置为 0,然后继续计数。请注意,我们实际上并不会在遇到下一个国家之前就附加新增病例的总结。这是因为在我们遇到下一个国家之前,无法判断当前行是否是某个国家的最后一行。这不是问题,因为我们会在将累计值重置为 0 之前将总结附加到 rowlist 中。这也意味着我们需要采取特别的措施来输出最后一个国家的总数,因为没有下一个国家。我们通过在循环结束后执行最后一次附加操作来做到这一点。这是一种相当标准的数据遍历和按组输出总数的方法。
我们在 第 3 步 和 第 4 步 中创建的汇总 DataFrame 可以通过本章中介绍的其他 pandas 技巧更高效地创建,无论是在分析师的时间上,还是在计算机的工作负载上。但当我们需要进行更复杂的计算时,特别是那些涉及跨行比较值的计算,这个决策就变得更加困难。
第 6 步 和 第 7 步 提供了这个示例。我们想要计算每个站点一年的平均温度。大多数站点每月有一次读数。然而,我们担心可能存在一些异常值,这些异常值是指一个月与下个月之间温度变化超过 3°C。我们希望将这些读数排除在每个站点的均值计算之外。在遍历数据时,通过存储上一个温度值(prevtemp)并将其与当前值进行比较,可以相对简单地做到这一点。
还有更多...
我们本可以在第 3 步中使用iterrows,而不是itertuples,语法几乎完全相同。由于这里不需要iterrows的功能,我们使用了itertuples。与iterrows相比,itertuples方法对系统资源的消耗较少。因为使用itertuples时,你是遍历元组,而使用iterrows时是遍历 Series,并且涉及到类型检查。
处理表格数据时,最难完成的任务是跨行计算:在行之间求和、基于不同一行的值进行计算以及生成累计总和。无论使用何种语言,这些计算都很复杂且资源密集。然而,特别是在处理面板数据时,很难避免这些任务。某些变量在特定时期的值可能由前一时期的值决定。这通常比我们在本段中所做的累积总和更加复杂。
数十年来,数据分析师们一直试图通过遍历行、仔细检查分类和汇总变量中的数据问题,然后根据情况处理求和来解决这些数据清理挑战。尽管这种方法提供了最大的灵活性,但 pandas 提供了许多数据聚合工具,这些工具运行更高效,编码也更简单。挑战在于如何匹配循环解决方案的能力,以应对无效、不完整或不典型的数据。我们将在本章后面探讨这些工具。
使用 NumPy 数组按组计算汇总
我们可以使用 NumPy 数组完成在上一段中所做的大部分工作。我们还可以使用 NumPy 数组来获取数据子集的汇总值。
做好准备
我们将再次使用 COVID-19 每日数据和巴西土地温度数据。
如何做……
我们将 DataFrame 的值复制到 NumPy 数组中。然后,我们遍历该数组,按组计算总和并检查值的意外变化:
-
导入
pandas和numpy,并加载 COVID-19 和土地温度数据:import pandas as pd coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) ltbrazil = pd.read_csv("data/ltbrazil.csv") -
创建一个位置列表:
loclist = coviddaily.location.unique().tolist() -
使用 NumPy 数组按位置计算总和。
创建一个包含位置和新增病例数据的 NumPy 数组。接下来,我们可以遍历在上一步骤中创建的位置列表,并为每个位置选择所有新增病例值(casevalues[j][1])(根据位置(casevalues[j][0]))。然后,我们为该位置求和新增病例值:
rowlist = []
casevalues = coviddaily[['location','new_cases']].to_numpy()
for locitem in loclist:
... cases = [casevalues[j][1] for j in range(len(casevalues))\
... if casevalues[j][0]==locitem]
... rowlist.append(sum(cases))
...
len(rowlist)
231
len(loclist)
231
rowlist[0:5]
[231539.0, 334863.0, 272010.0, 8359.0, 48015.0]
casetotals = pd.DataFrame(zip(loclist,rowlist), columns=(['location','casetotals']))
casetotals.head()
location casetotals
0 Afghanistan 231,539
1 Albania 334,863
2 Algeria 272,010
3 American Samoa 8,359
4 Andorra 48,015
-
对陆地温度数据进行排序,并删除温度缺失值的行:
ltbrazil = ltbrazil.sort_values(['station','month']) ltbrazil = ltbrazil.dropna(subset=['temperature']) -
使用 NumPy 数组来计算年度平均温度。
排除两个时间段之间变化较大的行:
prevstation = 'ZZZ'
prevtemp = 0
rowlist = []
tempvalues = ltbrazil[['station','temperature']].to_numpy()
tempcnt = 0
stationcnt = 0
for j in range(len(tempvalues)):
... station = tempvalues[j][0]
... temperature = tempvalues[j][1]
... if (prevstation!=station):
... if (prevstation!='ZZZ'):
... rowlist.append({'station':prevstation, 'avgtemp':tempcnt/stationcnt, 'stationcnt':stationcnt})
... tempcnt = 0
... stationcnt = 0
... prevstation = station
... if ((0 <= abs(temperature-prevtemp) <= 3) or (stationcnt==0)):
... tempcnt += temperature
... stationcnt += 1
... prevtemp = temperature
...
rowlist.append({'station':prevstation, 'avgtemp':tempcnt/stationcnt, 'stationcnt':stationcnt})
rowlist[0:5]
[{'station': 'ALTAMIRA', 'avgtemp': 27.729166666666668, 'stationcnt': 12},
{'station': 'ALTA_FLORESTA_AERO',
'avgtemp': 32.49333333333333,
'stationcnt': 9},
{'station': 'ARAXA', 'avgtemp': 21.52142857142857, 'stationcnt': 7},
{'station': 'BACABAL', 'avgtemp': 28.59166666666667, 'stationcnt': 6},
{'station': 'BAGE', 'avgtemp': 19.615000000000002, 'stationcnt': 10}]
-
创建一个包含陆地温度平均值的 DataFrame:
ltbrazilavgs = pd.DataFrame(rowlist) ltbrazilavgs.head()station avgtemp stationcnt 0 ALTAMIRA 28 12 1 ALTA_FLORESTA_AERO 32 9 2 ARAXA 22 7 3 BACABAL 29 6 4 BAGE 20 10
这将给我们一个 DataFrame,其中包含每个站点的平均温度和观测次数。请注意,我们得到的结果与前一个示例的最后一步相同。
工作原理…
当我们处理表格数据,但需要在行间进行计算时,NumPy 数组非常有用。这是因为访问数组中的“行”的方式与访问“列”的方式没有太大区别。例如,casevalues[5][0](数组的第六“行”和第一“列”)与 casevalues[20][1] 的访问方式是相同的。遍历 NumPy 数组也比遍历 pandas DataFrame 更快。
我们在第 3 步中利用了这一点。我们通过列表推导式获取给定位置的所有数组行(if casevalues[j][0]==locitem)。由于我们还需要在将要创建的汇总值 DataFrame 中包含 location 列表,我们使用 zip 来组合这两个列表。
我们在第 4 步开始处理陆地温度数据,首先按 station 和 month 排序,然后删除温度缺失值的行。第 5 步中的逻辑与前一个示例中的第 6 步几乎相同。主要的区别是,我们需要引用数组中站点(tempvalues[j][0])和温度(tempvalues[j][1])的位置。
还有更多…
当你需要遍历数据时,NumPy 数组通常比通过 itertuples 或 iterrows 遍历 pandas DataFrame 更快。此外,如果你尝试使用 itertuples 来运行第 3 步中的列表推导式,虽然是可行的,但你将需要等待较长时间才能完成。通常,如果你想对某一数据段做快速汇总,使用 NumPy 数组是一个合理的选择。
另见
本章剩余的示例依赖于 pandas DataFrame 中强大的 groupby 方法来生成分组总数。
使用 groupby 按组组织数据
在大多数数据分析项目中,我们必须按组生成汇总统计信息。虽然可以使用前一个示例中的方法完成这项任务,但在大多数情况下,pandas DataFrame 的 groupby 方法是一个更好的选择。如果 groupby 能够处理聚合任务——而且通常可以——那么它很可能是完成该任务的最有效方式。我们将在接下来的几个示例中充分利用 groupby。我们将在本示例中介绍基础知识。
准备工作
我们将在本食谱中处理 COVID-19 每日数据。
如何做到…
我们将创建一个 pandas 的groupby DataFrame,并使用它生成按组的汇总统计:
-
导入
pandas和numpy,并加载 COVID-19 每日数据:import pandas as pd coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) -
创建一个 pandas 的
groupbyDataFrame:countrytots = coviddaily.groupby(['location']) type(countrytots)<class 'pandas.core.groupby.generic.DataFrameGroupBy'> -
为每个国家创建第一次出现的行的 DataFrame。
为了节省空间,我们只显示前五行和前五列:
countrytots.first().iloc[0:5, 0:5]
iso_code casedate continent new_cases \
location
Afghanistan AFG 2020-03-01 Asia 1
Albania ALB 2020-03-15 Europe 33
Algeria DZA 2020-03-01 Africa 1
American Samoa ASM 2021-09-19 Oceania 1
Andorra AND 2020-03-08 Europe 1
new_deaths
location
Afghanistan 0
Albania 1
Algeria 0
American Samoa 0
Andorra 0
-
为每个国家创建最后几行的 DataFrame:
countrytots.last().iloc[0:5, 0:5]iso_code casedate continent new_cases \ location Afghanistan AFG 2024-02-04 Asia 210 Albania ALB 2024-01-28 Europe 45 Algeria DZA 2023-12-03 Africa 19 American Samoa ASM 2023-09-17 Oceania 18 Andorra AND 2023-05-07 Europe 41 new_deaths location Afghanistan 0 Albania 0 Algeria 0 American Samoa 0 Andorra 0type(countrytots.last())<class 'pandas.core.frame.DataFrame'> -
获取某个国家的所有行:
countrytots.get_group(('Zimbabwe')).iloc[0:5, 0:5]iso_code casedate location continent new_cases 36305 ZWE 2020-03-22 Zimbabwe Africa 2 36306 ZWE 2020-03-29 Zimbabwe Africa 5 36307 ZWE 2020-04-05 Zimbabwe Africa 2 36308 ZWE 2020-04-12 Zimbabwe Africa 7 36309 ZWE 2020-04-19 Zimbabwe Africa 10 -
遍历各组。
仅显示马耳他和科威特的行:
for name, group in countrytots:
... if (name[0] in ['Malta','Kuwait']):
... print(group.iloc[0:5, 0:5])
...
iso_code casedate location continent new_cases
17818 KWT 2020-03-01 Kuwait Asia 45
17819 KWT 2020-03-08 Kuwait Asia 16
17820 KWT 2020-03-15 Kuwait Asia 43
17821 KWT 2020-03-22 Kuwait Asia 72
17822 KWT 2020-03-29 Kuwait Asia 59
iso_code casedate location continent new_cases
20621 MLT 2020-03-08 Malta Europe 3
20622 MLT 2020-03-15 Malta Europe 28
20623 MLT 2020-03-22 Malta Europe 78
20624 MLT 2020-03-29 Malta Europe 50
20625 MLT 2020-04-05 Malta Europe 79
-
显示每个国家的行数:
countrytots.size()location Afghanistan 205 Albania 175 Algeria 189 American Samoa 58 Andorra 158 Vietnam 192 Wallis and Futuna 23 Yemen 122 Zambia 173 Zimbabwe 196 Length: 231, dtype: int64 -
按国家显示汇总统计:
countrytots.new_cases.describe().head(3).Tlocation Afghanistan Albania Algeria count 205 175 189 mean 1,129 1,914 1,439 std 1,957 2,637 2,205 min 1 20 1 25% 242 113 30 50% 432 522 723 75% 1,106 3,280 1,754 max 12,314 15,405 14,774countrytots.new_cases.sum().head()location Afghanistan 231,539 Albania 334,863 Algeria 272,010 American Samoa 8,359 Andorra 48,015 Name: new_cases, dtype: float64
这些步骤展示了当我们希望按分类变量生成汇总统计时,groupby DataFrame 对象是多么有用。
它是如何工作的…
在步骤 2中,我们使用pandas的groupby方法创建一个groupby对象,传入一个列或多个列进行分组。一旦我们拥有了一个groupby的 DataFrame,我们可以使用与整个 DataFrame 生成汇总统计相同的工具来按组生成统计数据。describe、mean、sum等方法可以在groupby的 DataFrame 或由其创建的系列上按预期工作,区别在于汇总统计会针对每个组执行。
在步骤 3 和 4中,我们使用first和last来创建包含每个组的第一次和最后一次出现的 DataFrame。在步骤 5中,我们使用get_group来获取某个特定组的所有行。我们还可以遍历各组,并使用size来统计每个组的行数。
在步骤 8中,我们从 DataFrame 的groupby对象创建一个 Series 的groupby对象。使用结果对象的聚合方法,我们可以按组生成 Series 的汇总统计。从这个输出可以清楚地看到,new_cases的分布因国家而异。例如,我们可以立刻看到,即使是前三个国家,它们的四分位数间距也差异很大。
还有更多…
从步骤 8得到的输出非常有用。保存每个重要连续变量的输出是值得的,尤其是当按组的分布有显著不同的时候。
pandas 的groupby DataFrame 非常强大且易于使用。步骤 8展示了创建我们在本章前两篇食谱中按组生成的汇总统计有多么简单。除非我们处理的 DataFrame 很小,或者任务涉及非常复杂的跨行计算,否则groupby方法是优于循环的选择。
使用更复杂的聚合函数与groupby
在前一个示例中,我们创建了一个 groupby DataFrame 对象,并使用它来按组运行汇总统计数据。在这个示例中,我们通过链式操作一行代码创建分组、选择聚合变量和选择聚合函数。我们还利用了 groupby 对象的灵活性,允许我们以多种方式选择聚合列和函数。
准备工作
本示例将使用 国家青年纵向调查(National Longitudinal Survey of Youth,简称 NLS)数据。
数据说明
国家纵向调查(National Longitudinal Surveys),由美国劳工统计局管理,是针对 1997 年高中毕业生开展的纵向调查。参与者每年接受一次调查,直到 2023 年。这些调查数据可通过 nlsinfo.org 公开访问。
如何操作…
我们在这个示例中使用 groupby 做了比之前示例更复杂的聚合操作,利用了其灵活性:
-
导入
pandas并加载 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
查看数据的结构:
nls97.iloc[:,0:7].info()<class 'pandas.core.frame.DataFrame'> Index: 8984 entries, 135335 to 713757 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 gender 8984 non-null object 1 birthmonth 8984 non-null int64 2 birthyear 8984 non-null int64 3 sampletype 8984 non-null object 4 ethnicity 8984 non-null object 5 highestgradecompleted 6663 non-null float64 6 maritalstatus 6675 non-null object dtypes: float64(1), int64(2), object(4) memory usage: 561.5+ KB -
查看一些类别数据:
catvars = ['gender','maritalstatus','highestdegree'] for col in catvars: ... print(col, nls97[col].value_counts().\ ... sort_index(), sep="\n\n", end="\n\n\n") ...gender Female 4385 Male 4599 Name: count, dtype: int64 maritalstatus Divorced 669 Married 3068 Never-married 2767 Separated 148 Widowed 23 Name: count, dtype: int64 highestdegree 0\. None 877 1\. GED 1167 2\. High School 3531 3\. Associates 766 4\. Bachelors 1713 5\. Masters 704 6\. PhD 64 7\. Professional 130 Name: count, dtype: int64 -
查看一些描述性统计信息:
contvars = ['satmath','satverbal', ... 'weeksworked06','gpaoverall','childathome'] nls97[contvars].describe()satmath satverbal weeksworked06 gpaoverall childathome count 1,407 1,406 8,419 6,004 4,791 mean 501 500 38 282 2 std 115 112 19 62 1 min 7 14 0 10 0 25% 430 430 27 243 1 50% 500 500 51 286 2 75% 580 570 52 326 3 max 800 800 52 417 9 -
按性别查看 学术能力评估测试(SAT)数学成绩。
我们将列名传递给 groupby,根据该列进行分组:
nls97.groupby('gender')['satmath'].mean()
gender
Female 487
Male 517
Name: satmath, dtype: float64
- 按性别和最高学历查看 SAT 数学成绩。
我们可以将列名列表传递给 groupby,以便按多个列进行分组:
nls97.groupby(['gender','highestdegree'])['satmath'].\
mean()
gender highestdegree
Female 0\. None 414
1\. GED 405
2\. High School 426
3\. Associates 448
4\. Bachelors 503
5\. Masters 504
6\. PhD 569
7\. Professional 593
Male 0\. None 545
1\. GED 320
2\. High School 465
3\. Associates 490
4\. Bachelors 536
5\. Masters 568
6\. PhD 624
7\. Professional 594
Name: satmath, dtype: float64
- 按性别和最高学历查看 SAT 数学和语言成绩。
我们可以使用列表来汇总多个变量的值,在这种情况下是 satmath 和 satverbal:
nls97.groupby(['gender','highestdegree'])[['satmath','satverbal']].mean()
satmath satverbal
gender highestdegree
Female 0\. None 414 408
1\. GED 405 390
2\. High School 426 440
3\. Associates 448 453
4\. Bachelors 503 508
5\. Masters 504 529
6\. PhD 569 561
7\. Professional 593 584
Male 0\. None 545 515
1\. GED 320 360
2\. High School 465 455
3\. Associates 490 469
4\. Bachelors 536 521
5\. Masters 568 540
6\. PhD 624 627
7\. Professional 594 599
- 对一个变量做多个聚合函数。
使用 agg 函数返回多个汇总统计数据:
nls97.groupby(['gender','highestdegree'])\
['gpaoverall'].agg(['count','mean','max','std'])
count mean max std
gender highestdegree
Female 0\. None 134 243 400 66
1\. GED 231 230 391 66
2\. High School 1152 277 402 53
3\. Associates 294 291 400 50
4\. Bachelors 742 322 407 48
5\. Masters 364 329 417 43
6\. PhD 26 345 400 44
7\. Professional 55 353 411 41
Male 0\. None 180 222 400 65
1\. GED 346 223 380 63
2\. High School 1391 263 396 49
3\. Associates 243 272 383 49
4\. Bachelors 575 309 405 49
5\. Masters 199 324 404 50
6\. PhD 23 342 401 55
7\. Professional 41 345 410 35
-
使用字典进行更复杂的聚合:
pd.options.display.float_format = '{:,.1f}'.format aggdict = {'weeksworked06':['count', 'mean', ... 'max','std'], 'childathome':['count', 'mean', ... 'max', 'std']} nls97.groupby(['highestdegree']).agg(aggdict)weeksworked06 \ count mean max std highestdegree 0\. None 666 29.7 52.0 21.6 1\. GED 1129 32.9 52.0 20.7 2\. High School 3262 39.4 52.0 18.6 3\. Associates 755 40.2 52.0 18.0 4\. Bachelors 1683 42.3 52.0 16.2 5\. Masters 703 41.8 52.0 16.6 6\. PhD 63 38.5 52.0 18.4 7\. Professional 127 27.8 52.0 20.4 childathome count mean max std highestdegree 0\. None 408 1.8 8.0 1.6 1\. GED 702 1.7 9.0 1.5 2\. High School 1881 1.9 7.0 1.3 3\. Associates 448 1.9 6.0 1.1 4\. Bachelors 859 1.9 8.0 1.1 5\. Masters 379 1.9 6.0 0.9 6\. PhD 33 1.9 3.0 0.8 7\. Professional 60 1.8 4.0 0.8nls97.groupby(['maritalstatus']).agg(aggdict)weeksworked06 \ count mean max std maritalstatus Divorced 666 37.5 52.0 19.0 Married 3035 40.3 52.0 17.9 Never-married 2735 37.2 52.0 19.1 Separated 147 33.6 52.0 20.3 Widowed 23 37.1 52.0 19.3 childathome count mean max std maritalstatus Divorced 530 1.5 5.0 1.2 Married 2565 2.1 8.0 1.1 Never-married 1501 1.6 9.0 1.3 Separated 132 1.5 8.0 1.4 Widowed 18 1.8 5.0 1.4
我们为 weeksworked06 和 childathome 显示了相同的汇总统计数据,但我们也可以为每个变量指定不同的聚合函数,使用与 步骤 9 中相同的语法。
如何操作…
我们首先查看 DataFrame 中关键列的汇总统计信息。在 步骤 3 中,我们获得了类别变量的频率,在 步骤 4 中,我们得到了连续变量的一些描述性统计信息。生成按组统计数据之前,先查看整个 DataFrame 的汇总值是个不错的主意。
接下来,我们准备使用 groupby 创建汇总统计数据。这涉及三个步骤:
-
根据一个或多个类别变量创建
groupbyDataFrame。 -
选择用于汇总统计数据的列。
-
选择聚合函数。
在这个示例中,我们使用了链式操作,一行代码完成了三件事。因此,nls97.groupby('gender')['satmath'].mean() 在步骤 5中做了三件事情:nls97.groupby('gender') 创建了一个 groupby DataFrame 对象,['satmath'] 选择了聚合列,mean() 是聚合函数。
我们可以像在步骤 5中那样传递列名,或者像在步骤 6中那样传递列名列表,来通过一个或多个列进行分组。我们可以使用一个变量列表来选择多个变量进行聚合,正如在步骤 7中使用[['satmath','satverbal']]一样。
我们可以链式调用特定的汇总函数,例如mean、count或max。另外,我们也可以将一个列表传递给agg,选择多个聚合函数,像在步骤 8中使用agg(['count','mean','max','std'])。我们可以使用熟悉的 pandas 和 NumPy 聚合函数,或者使用用户定义的函数,后者我们将在下一个例子中探讨。
从步骤 8中可以得出的另一个重要结论是,agg将每个聚合列一次只发送给一个函数。每个聚合函数中的计算会对groupby DataFrame 中的每个组执行。另一种理解方式是,它允许我们一次对一个组执行通常在整个 DataFrame 上执行的相同函数,自动化地将每个组的数据传递给聚合函数。
更多内容…
我们首先了解 DataFrame 中类别变量和连续变量的分布情况。通常,我们会通过分组数据,查看连续变量(例如工作周数)如何因类别变量(例如婚姻状况)而有所不同。在此之前,了解这些变量在整个数据集中的分布情况非常有帮助。
nls97数据集仅对约 1,400 个受访者中的 8,984 人提供 SAT 分数,因此在根据不同群体查看 SAT 分数时需要小心。这意味着按性别和最高学位(特别是博士学位获得者)统计的某些计数值可能太小,无法可靠。在 SAT 数学和语言类分数上有异常值(如果我们定义异常值为高于第三四分位数或低于第一四分位数的 1.5 倍四分位距)。
对于所有的最高学位和婚姻状况(除了丧偶)值,我们都有可接受的工作周数和居住在家中的孩子数的计数值。获得专业学位的人的平均工作周数出乎意料,它低于任何其他群体。接下来的好步骤是查看这种现象在多年中的持续性。(我们这里只看的是 2006 年的工作周数数据,但有 20 年的工作周数数据可用。)
另请参见
nls97文件是伪装成个体级数据的面板数据。我们可以恢复面板数据结构,从而促进对就业和学校注册等领域的时间序列分析。我们在第十一章:数据整理与重塑的例子中会进行相关操作。
使用用户定义函数和 apply 与 groupby
尽管 pandas 和 NumPy 提供了众多聚合函数,但有时我们需要编写自己的函数来获得所需的结果。在某些情况下,这需要使用apply。
准备工作
本例中我们将使用 NLS 数据。
如何操作…
我们将创建自己的函数,定义我们按组需要的汇总统计量:
-
导入
pandas和 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index("personid", inplace=True) -
创建一个函数来定义四分位数范围:
def iqr(x): ... return x.quantile(0.75) - x.quantile(0.25) -
运行四分位数范围函数。
创建一个字典,指定每个分析变量运行的聚合函数:
aggdict = {'weeksworked06':['count', 'mean', iqr], 'childathome':['count', 'mean', iqr]}
nls97.groupby(['highestdegree']).agg(aggdict)
weeksworked06 childathome
count mean iqr count mean iqr
highestdegree
0\. None 666 29.7 47.0 408 1.8 3.0
1\. GED 1129 32.9 40.0 702 1.7 3.0
2\. High School 3262 39.4 21.0 1881 1.9 2.0
3\. Associates 755 40.2 19.0 448 1.9 2.0
4\. Bachelors 1683 42.3 13.5 859 1.9 1.0
5\. Masters 703 41.8 13.5 379 1.9 1.0
6\. PhD 63 38.5 22.0 33 1.9 2.0
7\. Professional 127 27.8 43.0 60 1.8 1.0
-
定义一个函数来返回选定的汇总统计量:
def gettots(x): ... out = {} ... out['qr1'] = x.quantile(0.25) ... out['med'] = x.median() ... out['qr3'] = x.quantile(0.75) ... out['count'] = x.count() ... return out -
使用
apply运行函数。
这将创建一个具有多重索引的 Series,基于 highestdegree 值和所需的汇总统计量:
nls97.groupby(['highestdegree'])['weeksworked06'].\
apply(gettots)
highestdegree
0\. None qr1 5
med 35
qr3 52
count 666
1\. GED qr1 12
med 42
qr3 52
count 1,129
2\. High School qr1 31
med 52
qr3 52
count 3,262
3\. Associates qr1 33
med 52
qr3 52
count 755
4\. Bachelors qr1 38
med 52
qr3 52
count 1,683
5\. Masters qr1 38
med 52
qr3 52
count 703
6\. PhD qr1 30
med 50
qr3 52
count 63
7\. Professional qr1 6
med 30
qr3 49
count 127
Name: weeksworked06, dtype: float64
-
使用
reset_index来使用默认索引,而不是由groupbyDataFrame 创建的索引:nls97.groupby(['highestdegree'])['weeksworked06'].\ apply(gettots).reset_index()highestdegree level_1 weeksworked06 0 0\. None qr1 5 1 0\. None med 35 2 0\. None qr3 52 3 0\. None count 666 4 1\. GED qr1 12 5 1\. GED med 42 6 1\. GED qr3 52 7 1\. GED count 1,129 8 2\. High School qr1 31 9 2\. High School med 52 10 2\. High School qr3 52 11 2\. High School count 3,262 12 3\. Associates qr1 33 13 3\. Associates med 52 14 3\. Associates qr3 52 15 3\. Associates count 755 16 4\. Bachelors qr1 38 17 4\. Bachelors med 52 18 4\. Bachelors qr3 52 19 4\. Bachelors count 1,683 20 5\. Masters qr1 38 21 5\. Masters med 52 22 5\. Masters qr3 52 23 5\. Masters count 703 24 6\. PhD qr1 30 25 6\. PhD med 50 26 6\. PhD qr3 52 27 6\. PhD count 63 28 7\. Professional qr1 6 29 7\. Professional med 30 30 7\. Professional qr3 49 31 7\. Professional count 127 -
反而用
unstack链接,以基于汇总变量创建列。
这将创建一个 DataFrame,highestdegree 值作为索引,聚合值作为列:
nlssums = nls97.groupby(['highestdegree'])\
['weeksworked06'].apply(gettots).unstack()
nlssums
qr1 med qr3 count
highestdegree
0\. None 5 35 52 666
1\. GED 12 42 52 1,129
2\. High School 31 52 52 3,262
3\. Associates 33 52 52 755
4\. Bachelors 38 52 52 1,683
5\. Masters 38 52 52 703
6\. PhD 30 50 52 63
7\. Professional 6 30 49 127
nlssums.info()
<class 'pandas.core.frame.DataFrame'>
Index: 8 entries, 0\. None to 7\. Professional
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 qr1 8 non-null float64
1 med 8 non-null float64
2 qr3 8 non-null float64
3 count 8 non-null float64
dtypes: float64(4)
memory usage: 320.0+ bytes
unstack 在我们希望将索引的某些部分旋转到列轴时非常有用。
它是如何工作的……
我们在 第 2 步 中定义了一个非常简单的函数,用于按组计算四分位数范围。然后,我们在 第 3 步 中将该函数调用包含在我们的聚合函数列表中。
第 4 步 和 第 5 步 稍微复杂一些。我们定义了一个计算第一和第三四分位数以及中位数并统计行数的函数。它返回一个包含这些值的 Series。通过将 groupby DataFrame 与 第 5 步 中的 apply 结合,我们可以让 gettots 函数返回每个组的该 Series。
第 5 步 给出了我们想要的数字,但可能不是最好的格式。例如,如果我们想将数据用于其他操作——比如可视化——我们需要链式调用一些额外的方法。一种可能性是使用 reset_index。这将用默认索引替换多重索引。另一种选择是使用 unstack。这将根据索引的第二级(具有 qr1、med、qr3 和 count 值)创建列。
还有更多……
有趣的是,随着教育程度的提高,工作周数和家中孩子数量的四分位数范围显著下降。那些教育程度较低的群体在这些变量上似乎有更大的变异性。这应该被更仔细地检查,并且对于统计检验有影响,因为统计检验假设各组之间的方差是相同的。
另见
在 第十一章《整理与重塑数据》中,我们对 stack 和 unstack 做了更多的操作。
使用 groupby 改变 DataFrame 的分析单位
在前一个步骤的最后,我们创建的 DataFrame 是我们努力按组生成多个汇总统计量时的一个意外副产品。有时我们确实需要聚合数据来改变分析的单位——例如,从每个家庭的月度公用事业费用到每个家庭的年度公用事业费用,或从学生按课程的成绩到学生的整体 平均绩点 (GPA)。
groupby 是一个很好的工具,特别适用于折叠分析单位,特别是在需要进行汇总操作时。当我们只需要选择未重复的行时——也许是每个个体在给定间隔内的第一行或最后一行——那么 sort_values 和 drop_duplicates 的组合就能胜任。但是,我们经常需要在折叠之前对每组的行进行一些计算。这时 groupby 就非常方便了。
准备工作
我们将再次处理每日病例数据,该数据每天每个国家有一行记录。我们还将处理巴西陆地温度数据,该数据每个气象站每个月有一行记录。
如何做…
我们将使用 groupby 创建一个按组的汇总值的 DataFrame:
-
导入
pandas并加载 COVID-19 和陆地温度数据:import pandas as pd coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) ltbrazil = pd.read_csv("data/ltbrazil.csv") -
让我们查看数据的样本,以便回顾其结构。每个国家(
location)每天有一行记录,包括当天的新病例数和死亡数(我们使用随机种子以便每次生成相同的值):coviddaily[['location','casedate', 'new_cases','new_deaths']]. \ set_index(['location','casedate']). \ sample(10, random_state=1)new_cases \ location casedate Andorra 2020-03-15 1 Portugal 2022-12-04 3,963 Eswatini 2022-08-07 22 Singapore 2020-08-30 451 Georgia 2020-08-02 46 British Virgin Islands 2020-08-30 14 Thailand 2023-01-29 472 Bolivia 2023-12-17 280 Montenegro 2021-08-15 2,560 Eswatini 2022-04-17 132 new_deaths location casedate Andorra 2020-03-15 0 Portugal 2022-12-04 69 Eswatini 2022-08-07 2 Singapore 2020-08-30 0 Georgia 2020-08-02 1 British Virgin Islands 2020-08-30 0 Thailand 2023-01-29 29 Bolivia 2023-12-17 0 Montenegro 2021-08-15 9 Eswatini 2022-04-17 0 -
现在,我们可以将 COVID-19 数据从每天每个国家转换为每天所有国家的汇总数据。为了限制要处理的数据量,我们仅包括 2023 年 2 月至 2024 年 1 月之间的日期。
coviddailytotals = coviddaily.loc[coviddaily.\ casedate.between('2023-02-01','2024-01-31')].\ groupby(['casedate'], as_index=False)\ [['new_cases','new_deaths']].\ sum() coviddailytotals.head(10)casedate new_cases new_deaths 0 2023-02-05 1,385,583 69,679 1 2023-02-12 1,247,389 10,105 2 2023-02-19 1,145,666 8,539 3 2023-02-26 1,072,712 7,771 4 2023-03-05 1,028,278 7,001 5 2023-03-12 894,678 6,340 6 2023-03-19 879,074 6,623 7 2023-03-26 833,043 6,711 8 2023-04-02 799,453 5,969 9 2023-04-09 701,000 5,538 -
让我们看一看巴西平均温度数据的一些行:
ltbrazil.head(2).T0 1 locationid BR000082400 BR000082704 year 2023 2023 month 1 1 temperature 27 27 latitude -4 -8 longitude -32 -73 elevation 59 194 station FERNANDO_DE_NORONHA CRUZEIRO_DO_SUL countryid BR BR country Brazil Brazil latabs 4 8 -
创建一个包含巴西每个气象站平均温度的 DataFrame。
首先删除具有缺失温度值的行:
ltbrazil = ltbrazil.dropna(subset=['temperature'])
ltbrazilavgs = ltbrazil.groupby(['station'],
... as_index=False).\
... agg({'latabs':'first','elevation':'first',
... 'temperature':'mean'})
ltbrazilavgs.head(10)
station latabs elevation temperature
0 ALTAMIRA 3 112 28
1 ALTA_FLORESTA_AERO 10 289 32
2 ARAXA 20 1,004 22
3 BACABAL 4 25 29
4 BAGE 31 242 20
5 BARRA_DO_CORDA 6 153 28
6 BARREIRAS 12 439 27
7 BARTOLOMEU_LISANDRO 22 17 26
8 BAURU 22 617 25
9 BELEM 1 10 28
让我们更详细地看一看这些示例中的聚合函数是如何工作的。
它是如何工作的…
在 步骤 3 中,首先选择我们需要的日期。我们基于 casedate 创建一个 DataFrame 的 groupby 对象,选择 new_cases 和 new_deaths 作为聚合变量,并选择 sum 作为聚合函数。这将为每个组(casedate)产生 new_cases 和 new_deaths 的总和。根据您的目的,您可能不希望 casedate 成为索引,如果没有将 as_index 设置为 False 将会发生这种情况。
我们经常需要在不同的聚合变量上使用不同的聚合函数。我们可能想要对一个变量取第一个(或最后一个)值,并对另一个变量的值按组取平均值。这就是我们在 步骤 5 中所做的。我们通过将一个字典传递给 agg 函数来实现这一点,字典的键是我们的聚合变量,值是要使用的聚合函数。
使用 pivot_table 改变 DataFrame 的分析单位
在前一个示例中,我们可以使用 pandas 的 pivot_table 函数而不是 groupby。pivot_table 可以用于根据分类变量的值生成汇总统计信息,就像我们用 groupby 做的那样。pivot_table 函数还可以返回一个 DataFrame,这在本示例中将会看到。
准备工作
我们将再次处理 COVID-19 每日病例数据和巴西陆地温度数据。温度数据每个气象站每个月有一行记录。
如何做…
让我们从 COVID-19 数据创建一个 DataFrame,显示每一天在所有国家中的总病例数和死亡人数:
-
我们首先重新加载 COVID-19 和温度数据:
import pandas as pd coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) ltbrazil = pd.read_csv("data/ltbrazil.csv") -
现在,我们可以调用
pivot_table函数了。我们将一个列表传递给values,以指示要进行汇总计算的变量。我们使用index参数来表示我们希望按casedate进行汇总,并通过将其传递给aggfunc来表示我们只希望求和。注意,我们得到的总数与之前使用groupby时的结果相同:coviddailytotals = \ pd.pivot_table(coviddaily.loc[coviddaily.casedate. \ between('2023-02-01','2024-01-31')], values=['new_cases','new_deaths'], index='casedate', aggfunc='sum') coviddailytotals.head(10)new_cases new_deaths casedate 2023-02-05 1,385,583 69,679 2023-02-12 1,247,389 10,105 2023-02-19 1,145,666 8,539 2023-02-26 1,072,712 7,771 2023-03-05 1,028,278 7,001 2023-03-12 894,678 6,340 2023-03-19 879,074 6,623 2023-03-26 833,043 6,711 2023-04-02 799,453 5,969 2023-04-09 701,000 5,538 -
让我们尝试使用
pivot_table处理土地温度数据,并进行更复杂的聚合。我们希望得到每个站点的纬度(latabs)和海拔高度的第一个值,以及平均温度。回想一下,纬度和海拔值对于一个站点来说是固定的。我们将所需的聚合操作作为字典传递给aggfunc。同样,我们得到的结果与前一个例子中的结果一致:ltbrazil = ltbrazil.dropna(subset=['temperature']) ltbrazilavgs = \ pd.pivot_table(ltbrazil, index=['station'], aggfunc={'latabs':'first','elevation':'first', 'temperature':'mean'}) ltbrazilavgs.head(10)elevation latabs temperature station ALTAMIRA 112 3 28 ALTA_FLORESTA_AERO 289 10 32 ARAXA 1,004 20 22 BACABAL 25 4 29 BAGE 242 31 20 BARRA_DO_CORDA 153 6 28 BARREIRAS 439 12 27 BARTOLOMEU_LISANDRO 17 22 26 BAURU 617 22 25 BELEM 10 1 28
工作原理……
如我们所见,无论是使用groupby还是pivot_table,我们得到的结果是相同的。分析师应该选择他们自己和团队成员觉得最直观的方法。由于我的工作流程更常使用groupby,所以在聚合数据以创建新的 DataFrame 时,我更倾向于使用这种方法。
概述
在本章中,我们探讨了使用 NumPy 和 pandas 进行数据聚合的多种策略。我们还讨论了每种技术的优缺点,包括如何根据数据和聚合任务选择最有效、最直观的方法。由于大多数数据清理和处理项目都会涉及某种分割-应用-合并的操作,因此熟悉每种方法是很有必要的。在下一章中,我们将学习如何合并 DataFrame 并处理后续的数据问题。
加入我们社区的 Discord
加入我们社区的 Discord 空间,与作者和其他读者讨论:

第十章:解决合并 DataFrame 时的数据问题
在大多数数据清洗项目的某个阶段,分析师必须将来自不同数据表的数据进行合并。这包括将具有相同结构的数据追加到现有的数据行中,或执行合并以从另一张数据表中提取列。前者有时称为垂直合并数据,或连接数据,而后者则称为水平合并数据,或合并数据。
合并可以根据合并依据列值的重复量进行分类。在一对一合并中,合并依据列的值在每张数据表中各出现一次。在一对多合并中,合并依据列的值在一方没有重复,而在另一方则存在重复。在多对多合并中,合并依据列的值在两方中都有重复。合并过程进一步复杂化,因为数据表中的合并依据值通常没有完全匹配的关系;每个数据表的合并依据列可能包含在另一张数据表中没有的值。
在合并数据时,可能会引入新的数据问题。当数据被追加时,即使列的名称和数据类型相同,也可能与原始数据具有不同的逻辑值。对于合并操作,每当合并依据的某一方缺少值时,该方的其他列也将缺少值。对于一对一或一对多的合并,合并依据值可能会出现意外的重复,导致其他列的值被不小心重复。
本章将介绍如何垂直和水平合并 DataFrame,并考虑如何处理合并时常见的数据问题。具体来说,本章的内容将涵盖以下主题:
-
垂直合并 DataFrame
-
一对一合并
-
通过多列进行一对一合并
-
一对多合并
-
多对多合并
-
开发合并例程
技术要求
为了完成本章中的所有内容,您需要使用 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码也可以在 pandas 1.5.3 或更高版本上运行。
本章的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
垂直合并 DataFrame
在某些情况下,我们需要将一张数据表中的行追加到另一张数据表中。这通常是将几乎相同列和数据类型的表格数据进行合并。例如,我们每个月可能会获得一份包含医院病人结果的新的 CSV 文件,并需要将其添加到现有的数据中。或者,我们可能会在某个学区的办公室工作,接收来自不同学校的数据。在这种情况下,我们可能会想要在进行分析之前先将这些数据合并。
即使跨月份和跨学校(在这些示例中)的数据结构在理论上是相同的,实际情况中可能并非如此。商业实践可能会从一个时期到另一个时期发生变化。这可能是有意为之,也可能是由于人员流动或其他外部因素而无意发生的。一个机构或部门的实践可能与另一个有所不同,并且某些数据值可能在某些机构中有所不同,甚至完全缺失。
当我们放松警惕时,通常会遇到看似相似的数据变化,通常是在我们开始假设新数据将与旧数据相似时。我每次合并数据时都会提醒自己这一点。在本章的其余部分,我将使用“纵向合并”或“附加”来指代将数据纵向合并。
在本食谱中,我们将使用 pandas 的concat函数将一组 pandas DataFrame 的行附加到另一个 DataFrame 中。我们还将对concat操作进行一些常见检查,以确认最终的 DataFrame 是否符合我们的预期。
准备工作
在本食谱中,我们将使用来自多个国家的陆地温度数据。这些数据包括 2023 年期间每个国家多个气象站的月平均温度、纬度、经度和海拔。每个国家的数据都保存在一个 CSV 文件中。
数据备注
陆地温度 DataFrame 包含 2023 年来自全球 12,000 多个气象站的平均温度(以°C 为单位),尽管大多数气象站位于美国。原始数据是从全球历史气候学网络集成数据库获取的。美国国家海洋和大气管理局在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly上提供公开使用。
如何做……
在本食谱中,我们将纵向合并结构相似的 DataFrame,检查合并后数据的值,并修复缺失值。让我们开始吧:
-
导入
pandas和numpy,以及os模块:import pandas as pd import numpy as np import os -
从喀麦隆和阿曼加载数据并检查行数和列数:
ltcameroon = pd.read_csv("data/ltcountry/ltcameroon.csv") ltoman = pd.read_csv("data/ltcountry/ltoman.csv") ltcameroon.shape(48, 11)ltoman.shape(288, 10)
比较喀麦隆和阿曼 DataFrame 中的列。通过查看列,我们可以看到喀麦隆的 DataFrame 中有latabs列,而阿曼的 DataFrame 中没有。我们可以使用symetric_difference来确认这一点,并确保没有其他列只出现在一个 DataFrame 中而不在另一个中。它显示latabs是唯一只在一个 DataFrame 中出现的列:
ltcameroon.columns
Index(['locationid', 'year', 'month', 'temperature',
'latitude', 'longitude', 'elevation', 'station',
'countryid', 'country', 'latabs'],
dtype='object')
ltoman.columns
Index(['locationid', 'year', 'month', 'temperature',
'latitude', 'longitude', 'elevation', 'station',
'countryid', 'country'],
dtype='object')
ltcameroon.columns.\
symmetric_difference(ltoman.columns)
Index(['latabs'], dtype='object')
-
我们仍然可以合并这两个 DataFrame。唯一的问题是,现在我们有一个名为
latabs的列,该列在喀麦隆的所有行中都有非缺失值,而在阿曼的所有行中都是缺失值。我们将在本食谱的最后一步解决这个问题:ltall = pd.concat([ltcameroon, ltoman]) ltall.country.value_counts()country Oman 288 Cameroon 48 Name: count, dtype: int64ltall[['country','station','temperature', 'latitude','latabs']].\ sample(5, random_state=3)country station temperature latitude latabs 276 Oman BAHLA 21.44 23.000 NaN 26 Oman DIBA 21.85 25.617 NaN 281 Oman RAS_AL_HADD 23.74 22.300 NaN 15 Cameroon GAROUA 33.91 9.336 9.336 220 Oman SOHAR_MAJIS 30.85 24.467 NaNltall.groupby(['country'])['latabs'].count()country Cameroon 48 Oman 0 Name: latabs, dtype: int64 -
创建一个函数来进行合并,并结合我们已做的数据检查。该函数接受一个文件名列表,遍历列表,读取与每个文件名相关联的 CSV 文件到一个 DataFrame 中,然后合并该 DataFrame。我们可以得到预期的计数。我们没有检查列名,接下来我们将在下一步进行。
def concatfiles(filelist): directory = "data/ltcountry/" ltall = pd.DataFrame() for filename in filelist: ltnew = pd.read_csv(directory + filename + ".csv") print(filename + " has " + str(ltnew.shape[0]) + " rows.") ltall = pd.concat([ltall, ltnew]) return ltall ltall = concatfiles(['ltcameroon','ltoman'])ltcameroon has 48 rows. ltoman has 288 rows.ltall.country.value_counts()country Oman 288 Cameroon 48 Name: count, dtype: int64
如果我们有很多文件需要合并,创建一个文件名列表可能会很麻烦。我们可以通过加载文件夹中所有具有 CSV 文件扩展名的文件,利用 Python 的os模块来帮助我们。接下来我们来做这件事,同时还要加入一些代码来检查列。我们将基于前一步的代码进行构建。
- 合并文件夹中所有国家的数据文件。
遍历包含每个国家 CSV 文件的文件夹中的所有文件名。使用endswith方法检查文件名是否具有 CSV 文件扩展名。使用read_csv创建一个新的 DataFrame 并打印行数。使用concat将新 DataFrame 的行附加到已附加的行上。最后,显示最近的 DataFrame 中缺失的列,或者是最近的 DataFrame 中有而之前的 DataFrame 中没有的列:
def concatallfiles():
directory = "data/ltcountry"
ltall = pd.DataFrame()
for filename in os.listdir(directory):
if filename.endswith(".csv"):
fileloc = os.path.join(directory, filename)
# open the next file
with open(fileloc):
ltnew = pd.read_csv(fileloc)
print(filename + " has " +
str(ltnew.shape[0]) + " rows.")
ltall = pd.concat([ltall, ltnew])
# check for differences in columns
columndiff = ltall.columns.\
symmetric_difference(ltnew.columns)
if (not columndiff.empty):
print("", "Different column names for:",
filename, columndiff, "", sep="\n")
return ltall
-
使用我们刚刚创建的函数来读取子文件夹中所有的国家 CSV 文件,显示行数并检查列名。我们再次看到
ltomanDataFrame 缺少latabs列:ltall = concatallfiles()ltpoland.csv has 120 rows. ltcameroon.csv has 48 rows. ltmexico.csv has 852 rows. ltjapan.csv has 1800 rows. ltindia.csv has 1116 rows. ltoman.csv has 288 rows. Different column names for: ltoman.csv Index(['latabs'], dtype='object') ltbrazil.csv has 1008 rows. -
显示一些合并后的数据:
ltall[['country','station','month', 'temperature','latitude']].\ sample(5, random_state=1)country station month temperature latitude 583 Japan TOKUSHIMA 4 16 34 635 India NEW_DELHI_SAFDARJUN 7 31 29 627 Mexico COATZACOALCOSVER 9 30 18 28 Poland WLODAWA 3 5 52 775 Mexico ARRIAGACHIS 11 28 16 -
检查合并数据中的值。
注意到阿曼的latabs值全部缺失。这是因为阿曼的 DataFrame 中缺少latabs列(latabs是每个站点纬度的绝对值):
ltall.country.value_counts().sort_index()
country
Brazil 1008
Cameroon 48
India 1116
Japan 1800
Mexico 852
Oman 288
Poland 120
Name: count, dtype: int64
ltall.groupby(['country']).\
agg({'temperature':['mean','max','count'],
'latabs':['mean','max','count']})
temperature latabs
mean max count mean max count
country
Brazil 25 34 900 14 34 1008
Cameroon 27 35 39 8 10 48
India 26 35 1096 21 34 1116
Japan 14 31 1345 36 45 1800
Mexico 23 36 685 22 32 852
Oman 28 38 264 NaN NaN 0
Poland 10 21 120 52 55 120
- 修复缺失的值。
将阿曼的latabs值设置为latitude值。(阿曼的所有latitude值都位于赤道以北且为正数。在全球历史气候学网络集成数据库中,赤道以北的纬度值为正,而赤道以南的纬度值为负)。具体操作如下:
ltall['latabs'] = np.where(ltall.country=="Oman", ltall.latitude, ltall.latabs)
ltall.groupby(['country']).\
... agg({'temperature':['mean','max','count'],
... 'latabs':['mean','max','count']})
temperature latabs
mean max count mean max count
country
Brazil 25 34 900 14 34 1008
Cameroon 27 35 39 8 10 48
India 26 35 1096 21 34 1116
Japan 14 31 1345 36 45 1800
Mexico 23 36 685 22 32 852
Oman 28 38 264 22 26 288
Poland 10 21 120 52 55 120
到此为止,我们已经将选定文件夹中找到的七个 CSV 文件的数据合并了。我们还确认了已附加正确数量的行,找出了某些文件中缺失的列,并修复了缺失的值。
它是如何工作的...
我们在第 3 步中将一个 pandas DataFrame 的列表传递给了 pandas 的concat函数。第二个 DataFrame 的行被附加到了第一个 DataFrame 的底部。如果我们列出了第三个 DataFrame,那么这些行就会附加到前两个 DataFrame 合并后的行上。在合并之前,我们在第 2 步使用了shape属性来检查行数,并检查了列名。在第 3 步合并后,我们确认了结果 DataFrame 包含了每个国家的预期行数。
我们有时需要连接两个或三个以上的文件。第 4 步到第 6 步指导我们通过定义一个函数来重复代码,从而处理多个文件。在第 4 步中,我们将文件名列表传递给了这个函数。
在第 5 步和第 6 步中,我们查找了指定文件夹中的所有 CSV 文件,将找到的每个文件加载到内存中,然后将每个文件的行追加到一个 DataFrame 中。我们打印了每个加载的数据文件的行数,以便稍后将这些数字与拼接数据中的总数进行比较。我们还标识了任何与其他文件列不同的 DataFrame。在第 8 步中,我们使用 value_counts 确认了每个国家的行数是否正确。
pandas 的 groupby 方法可以用来检查每个原始 DataFrame 中的列值。我们按国家分组,因为它能标识每个原始 DataFrame 的行——每个 DataFrame 中的所有行对于国家都有相同的值。(即使该信息不用于后续分析,始终在拼接后的 DataFrame 中保留标识原始 DataFrame 的列也是很有帮助的。)在第 8 步中,这帮助我们注意到阿曼的 latabs 列没有值。在第 9 步中,我们替换了阿曼 latabs 列的缺失值。
还有更多内容...
根据你正在附加的 DataFrame 的大小和工作站的可用内存,合并 DataFrame 可能会消耗机器的资源,甚至在内存使用超过一定的资源量时导致代码提前终止。确保数据文件尽可能高效地存储数据始终是一个好主意。例如,将数字值进行降位处理,或者在适当时将字符数据转换为分类数据,都是良好的做法。
另请参见
我们在第九章《修复聚合时的脏数据》中详细讨论了强大的 pandas groupby 方法。
我们在第六章《使用 Series 操作清理和探索数据》中讨论了 NumPy 的 where 函数。
进行一对一合并
本章的其余部分将探讨横向合并数据;即,将一个数据表的列与另一个数据表的列合并。借用 SQL 开发中的术语,我们通常将这种操作称为连接操作:左连接、右连接、内连接和外连接。本节将研究一对一合并,其中合并依据的值在两个文件中都没有重复。后续的章节将展示一对多合并,其中合并依据的值在右数据表中有重复,以及多对多合并,其中合并依据的值在左和右数据表中都有重复。
我们常常说合并的左侧和右侧,在本章中我们将遵循这一约定。但这并没有实际的意义,除了为了更清晰的阐述。如果 A 是左侧数据表,B 是右侧数据表,我们可以通过合并完成完全相同的操作,反之亦然。
在本章中,我使用了“合并列”和“合并值”这些表达方式,而不是“关键列”或“索引列”。这样可以避免与 pandas 索引对齐产生可能的混淆。索引可以作为合并列使用,但也可以使用其他列。我还希望避免在讨论中依赖于关系数据库中的概念,如主键或外键。当我们从关系型系统中提取数据时,了解哪些数据列作为主键或外键是有帮助的,且我们在设置 pandas 索引时应考虑这一点。但是,对于大多数数据清洗项目中的合并,往往超出了这些键的范畴。
在简单的 1 对 1 合并情况下,左侧数据表中的每一行都会根据合并值与右侧数据表中的一行(且仅一行)匹配。当合并值出现在一张数据表中,但另一张数据表中没有时,合并结果的处理方式取决于指定的连接类型。下图展示了四种不同的连接类型:

图 10.1:展示四种不同类型连接的图示
当两个数据表通过内连接合并时,只有当合并值同时出现在左侧和右侧数据表中时,相关行才会保留。这是左侧和右侧数据表的交集,如前图中的B所示。外连接会返回所有行;也就是说,返回在两个数据表中都出现合并值的行、在左侧数据表中出现但在右侧数据表中未出现的行,以及在右侧数据表中出现但在左侧数据表中未出现的行——分别是B、A和C。这被称为并集。左连接返回合并值在左侧数据表中出现的行,无论它们是否出现在右侧数据表中。这是A和B。右连接返回合并值在右侧数据表中出现的行,无论它们是否出现在左侧数据表中。
缺失值可能由外连接、左连接或右连接产生。这是因为返回的合并数据表会在合并条件未找到的列中出现缺失值。例如,在执行左连接时,左侧数据集可能包含一些在右侧数据集中没有出现的合并条件值。在这种情况下,右侧数据集的所有列都会缺失。(这里我说可能是因为可以执行外连接、左连接或右连接,得到与内连接相同的结果,因为相同的合并条件值出现在两边。有时,我们做左连接是为了确保返回的所有行都来自左侧数据集,并且仅返回这些行。)
在本教程中,我们将查看四种连接类型。
准备工作
我们将处理来自全国纵向调查(NLS)的两个文件。这两个文件每个包含一行数据。一个包含就业、教育程度和收入数据,另一个文件包含受访者父母的收入和教育程度数据。
数据说明
全国纵向调查(NLS),由美国劳工统计局管理,是对 1997 年开始进行调查时在高中就读的个人进行的纵向调查。参与者每年都会接受调查,直到 2023 年。调查结果可以在nlsinfo.org上公开获取。
如何操作...
在本教程中,我们将对两个数据框执行左连接、右连接、内连接和外连接,每个合并条件值有一行数据。让我们开始吧:
-
导入
pandas并加载两个 NLS 数据框:import pandas as pd nls97 = pd.read_csv("data/nls97f.csv", low_memory=False) nls97.set_index("personid", inplace=True) nls97add = pd.read_csv("data/nls97add.csv") -
查看一些 NLS 数据:
nls97.head()gender birthmonth birthyear ... \ personid ... 135335 Female 9 1981 ... 999406 Male 7 1982 ... 151672 Female 9 1983 ... 750699 Female 2 1981 ... 781297 Male 10 1982 ... colenrfeb22 colenroct22 originalid personid 135335 NaN NaN 1 999406 NaN NaN 2 151672 1\. Not enrolled NaN 3 750699 NaN NaN 4 781297 NaN NaN 5 [5 rows x 106 columns]nls97.shape(8984, 106)nls97add.head()originalid motherage parentincome \ 0 1 26 -3 1 2 19 -4 2 3 26 63000 3 4 33 11700 4 5 34 -3 fatherhighgrade motherhighgrade 0 16 8 1 17 15 2 -3 12 3 12 12 4 12 12nls97add.shape(8984, 5) -
检查
originalid的唯一值数量是否等于行数。
我们将稍后使用originalid作为我们的合并列:
nls97.originalid.nunique()==nls97.shape[0]
True
nls97add.originalid.nunique()==nls97add.shape[0]
True
- 创建一些不匹配的 ID。
不幸的是,NLS 数据对于我们的目的来说有点过于干净。因此,我们将会故意修改一些originalid的值:
nls97 = nls97.sort_values('originalid')
nls97add = nls97add.sort_values('originalid')
nls97.loc[[135335,999406], "originalid"] = \
nls97.originalid+10000
nls97.originalid.head(2)
personid
135335 10001
999406 10002
Name: originalid, dtype: int64
nls97add.loc[[0,1], "originalid"] = \
nls97add.originalid+20000
nls97add.originalid.head(2)
0 20001
1 20002
Name: originalid, dtype: int64
- 使用
join执行左连接。
nls97是左数据框,nls97add是右数据框,当我们以这种方式使用join时。显示不匹配 ID 的值。注意,在右数据框没有匹配 ID 时,来自右数据框的列值都会缺失(originalid值 10001 和 10002 出现在左数据框中,但右数据框中没有这些 ID):
nls97.set_index("originalid", inplace=True)
nls97add.set_index("originalid", inplace=True)
nlsnew = nls97.join(nls97add)
nlsnew.loc[nlsnew.index>9999, ['originalid','gender','birthyear','motherage','parentincome']]
gender birthyear motherage parentincome
originalid
10001 Female 1981 NaN NaN
10002 Male 1982 NaN NaN
- 使用
merge执行左连接。
第一个数据框是左数据框,第二个数据框是右数据框。使用on参数指定合并的列。将how参数的值设置为left以执行左连接。我们得到的结果与使用join时相同:
nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="left")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']]
gender birthyear motherage parentincome
originalid
10001 Female 1981 NaN NaN
10002 Male 1982 NaN NaN
- 执行右连接。
使用右连接时,当左数据框中没有匹配 ID 时,左数据框的值会缺失:
nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="right")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']]
gender birthyear motherage parentincome
originalid
20001 NaN NaN 26 -3
20002 NaN NaN 19 -4
- 执行内连接。
在内连接后,所有不匹配的 ID(值大于9999)都不会出现。这是因为它们在两个数据框中都没有出现:
nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="inner")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']]
Empty DataFrame
Columns: [gender, birthyear, motherage, parentincome]
Index: []
- 执行外连接。
这会保留所有的行,因此左侧 DataFrame 中有合并列值但右侧没有的行将被保留(originalid 值为 10001 和 10002),而右侧 DataFrame 中有合并列值但左侧没有的行也会被保留(originalid 值为 20001 和 20002):
nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="outer")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']]
gender birthyear motherage parentincome
originalid
10001 Female 1,981 NaN NaN
10002 Male 1,982 NaN NaN
20001 NaN NaN 26 -3
20002 NaN NaN 19 -4
- 创建一个函数来检查 ID 不匹配。
该函数接受一个左侧和一个右侧的 DataFrame,以及一个合并列。它执行外连接,因为我们想查看在任一 DataFrame 中,或者两者中都存在的合并列值:
def checkmerge(dfleft, dfright, idvar):
... dfleft['inleft'] = "Y"
... dfright['inright'] = "Y"
... dfboth = pd.merge(dfleft[[idvar,'inleft']],\
... dfright[[idvar,'inright']], on=[idvar], how="outer")
... dfboth.fillna('N', inplace=True)
... print(pd.crosstab(dfboth.inleft, dfboth.inright))
...
checkmerge(nls97.reset_index(),nls97add.reset_index(), "originalid")
inright N Y
inleft
N 0 2
Y 2 8982
到此为止,我们展示了如何在一对一合并中执行四种类型的连接。
它是如何工作的...
一对一的合并相对简单。合并列值在左侧和右侧的 DataFrame 中仅出现一次。然而,某些合并列值可能只出现在其中一个 DataFrame 中。这使得连接的类型变得重要。如果所有的合并列值都出现在两个 DataFrame 中,那么左连接、右连接、内连接或外连接将返回相同的结果。我们在前几步中查看了这两个 DataFrame。
在第 3 步中,我们确认了按 originalid 列合并时,唯一值的数量等于两个 DataFrame 中的行数。这告诉我们将进行一对一的合并。
如果合并列是索引,则执行左连接的最简单方法是使用 join DataFrame 方法。我们在第 5 步中做了这个操作。我们将右侧 DataFrame 传递给左侧 DataFrame 的 join 方法。当我们在第 6 步中使用 pandas 的 merge 函数执行左连接时,返回了相同的结果。我们使用 how 参数指定左连接,并通过 on 来指示合并列。
在第 7 步到第 9 步中,我们分别执行了右连接、内连接和外连接。这是通过 how 参数指定的,这是这些步骤中唯一不同的部分。
我们在第 10 步中创建的简单 checkmerge 函数统计了在一个 DataFrame 中存在但在另一个 DataFrame 中不存在的合并列值的行数,以及两个 DataFrame 中都存在的值的数量。将这两个 DataFrame 的副本传递给此函数告诉我们,左侧 DataFrame 中有两行不在右侧,右侧 DataFrame 中有两行不在左侧,且有 8,982 行在两者中都存在。
还有更多...
在进行任何非平凡的合并之前,你应该运行一个类似于我们在第 10 步中创建的 checkmerge 函数——在我看来,几乎所有的合并都算是非平凡合并。
merge 函数比我在本例中使用的示例更灵活。例如,在第 6 步中,我们不需要将左侧的 DataFrame 作为第一个参数。我本可以像这样显式地指定左侧和右侧的 DataFrame:
nlsnew = pd.merge(right=nls97add, left=nls97, on=['originalid'], how="left")
我们还可以通过使用 left_on 和 right_on 来指定左侧和右侧 DataFrame 的不同合并列,而不是使用 on:
nlsnew = pd.merge(nls97, nls97add, left_on=['originalid'], right_on=['originalid'], how="left")
merge函数的灵活性使它成为每当我们需要水平合并数据时的一个极好的工具。
使用多个列进行一对一合并
我们用来执行一对一合并的逻辑同样适用于多个合并列的合并。无论是两个还是更多的合并列,内连接、外连接、左连接和右连接的工作方式都是一样的。我们将在本食谱中演示这一点。
准备工作
我们将在本食谱中使用 NLS 数据,特别是 2017 年至 2021 年的工作周和大学注册数据。工作周和大学注册文件每个文件每年包含一行数据。
如何操作...
我们将使用多个合并列对两个数据框进行一对一合并。让我们开始:
-
导入
pandas并加载 NLS 工作周和大学注册数据:import pandas as pd nls97weeksworked = pd.read_csv("data/nls97weeksworked.csv") nls97colenr = pd.read_csv("data/nls97colenr.csv") -
看一下部分 NLS 工作周数据:
nls97weeksworked.loc[nls97weeksworked.\ originalid.isin([2,3])]originalid year weeksworked 5 2 2017 52 6 2 2018 52 7 2 2019 52 8 2 2020 52 9 2 2021 46 10 3 2017 52 11 3 2018 52 12 3 2019 9 13 3 2020 0 14 3 2021 0nls97weeksworked.shape(44920, 3)nls97weeksworked.originalid.nunique()8984 -
看一下部分 NLS 大学注册数据:
nls97colenr.loc[nls97colenr.\ originalid.isin([2,3])]originalid year colenr 1 2 2017 1\. Not enrolled 2 3 2017 1\. Not enrolled 8985 2 2018 1\. Not enrolled 8986 3 2018 1\. Not enrolled 17969 2 2019 1\. Not enrolled 17970 3 2019 1\. Not enrolled 26953 2 2020 1\. Not enrolled 26954 3 2020 1\. Not enrolled 35937 2 2021 1\. Not enrolled 35938 3 2021 1\. Not enrollednls97colenr.shape(44920, 3)nls97colenr.originalid.nunique()8984 -
检查合并列中的唯一值。
我们获得的按列值组合的合并数量(44,920)与两个数据框中的行数相同:
nls97weeksworked.groupby(['originalid','year'])\
... ['originalid'].count().shape
(44920,)
nls97colenr.groupby(['originalid','year'])\
... ['originalid'].count().shape
(44920,)
-
检查合并列中的不匹配情况。所有
originalid和year的组合在两个文件中都有出现:def checkmerge(dfleft, dfright, idvar): ... dfleft['inleft'] = "Y" ... dfright['inright'] = "Y" ... dfboth = pd.merge(dfleft[idvar + ['inleft']],\ ... dfright[idvar + ['inright']], on=idvar, how="outer") ... dfboth.fillna('N', inplace=True) ... print(pd.crosstab(dfboth.inleft, dfboth.inright)) ... checkmerge(nls97weeksworked.copy(),nls97colenr.copy(), ['originalid','year'])inright Y inleft Y 44920 -
使用多个合并列执行合并:
nls97workschool = \ pd.merge(nls97weeksworked, nls97colenr, on=['originalid','year'], how="inner") nls97workschool.shape(44920, 4)nls97workschool.loc[nls97workschool.\ originalid.isin([2,3])]originalid year weeksworked colenr 5 2 2017 52 1\. Not enrolled 6 2 2018 52 1\. Not enrolled 7 2 2019 52 1\. Not enrolled 8 2 2020 52 1\. Not enrolled 9 2 2021 46 1\. Not enrolled 10 3 2017 52 1\. Not enrolled 11 3 2018 52 1\. Not enrolled 12 3 2019 9 1\. Not enrolled 13 3 2020 0 1\. Not enrolled 14 3 2021 0 1\. Not enrolled
这些步骤表明,当有多个合并列时,运行合并的语法几乎没有变化。
工作原理...
NLS 数据中的每个人在工作周和大学注册数据框中都有五行数据,每年从 2017 年到 2021 年都有一行。两个文件都包含 44,920 行数据,涉及 8,984 个独特的个体(由originalid表示)。这一切都是有道理的(8,984*5=44,920)。
第 4 步 确认了我们将用于合并的列组合即使在个人数据重复的情况下也不会重复。每个人每年只有一行数据。这意味着工作周和大学注册数据的合并将是一次一对一的合并。在第 5 步中,我们检查了是否有在一个数据框中存在但另一个数据框中不存在的个人和年份组合。没有发现。
最后,在第 6 步中,我们准备好了进行合并。我们将on参数设置为一个包含两个列名的列表(['originalid','year']),以告诉合并函数使用这两列进行合并。我们指定了内连接,尽管使用任何连接都会得到相同的结果。这是因为相同的合并列值在两个文件中都存在。
还有更多...
我们在前一个食谱中讨论的所有合并数据的逻辑和潜在问题都适用,无论我们是用一个合并列还是多个合并列。内连接、外连接、右连接和左连接的工作方式相同。我们仍然可以在执行合并之前计算出返回的行数。我们还应该检查合并列的唯一值数量以及两个数据框之间的匹配情况。
如果你在之前的章节中使用过涉及 NLS 工作周和大学入学数据的示例,你可能注意到这里的结构不同。在之前的示例中,每个人有一行数据,包含多个关于工作周数和大学入学的列,表示多年的工作周数和大学入学情况。例如,weeksworked21表示 2021 年工作的周数。我们在本示例中使用的工作周数和大学入学数据框架结构比我们在早期示例中使用的 NLS 数据框架更为整洁。我们将在第十一章《整理和重塑数据》中学习如何整理数据。
执行一对多合并
在一对多合并中,左侧数据表的合并列(或列组合)具有不重复的值,而右侧数据表中的这些列则具有重复的值。对于这种合并,我们通常会使用内连接或左连接。当合并列的值在右侧数据表中缺失时,选择使用哪种连接方式是非常重要的。在进行左连接时,所有从内连接中返回的行都会返回,此外,对于左侧数据集中存在但右侧数据集缺失的每个合并列值,还会多返回一行。对于这些额外的行,右侧数据表中所有列的值将在结果合并数据中缺失。这个相对简单的事实实际上非常重要,在编写一对多合并代码之前,应该仔细考虑。
这是我开始感到紧张的地方,我认为在这里感到紧张是有道理的。在进行数据清洗工作坊时,我会在开始这个话题之前停顿一下,告诉大家,“在你能带上朋友之前,不要开始一对多合并。”
当然,我是在开玩笑……大部分时间是这样。我想表达的重点是,在进行非平凡的合并之前,应该有所停顿,而一对多的合并从来都不简单。我们的数据结构可能发生很多变化。
具体来说,在开始之前,我们需要了解将要合并的两个数据框架的一些信息。首先,我们应该知道每个数据框架上哪些列可以作为合并列。一对多合并通常用于从企业数据库系统中重新捕捉关系,并且需要与使用的主键和外键一致。(在关系型数据库中,左侧数据表上的主键通常与右侧数据表上的外键关联。)其次,我们应该知道将使用哪种连接方式以及原因。
第三,我们应该知道两个数据表各有多少行。第四,我们应该大致了解根据连接类型、每个数据集的行数以及对合并值的初步检查,将保留多少行。如果所有的合并值在两个数据集中都有,或者如果我们正在做内连接,那么行数将等于右侧数据集的行数,适用于一对多合并。但通常情况并不像这样简单。我们经常进行左连接的一对多合并。在左连接的情况下,保留的行数将等于右数据集中具有匹配合并值的行数,加上左数据集中没有匹配合并值的行数。
一旦我们通过本教程中的示例进行操作,这应该会更加清晰。
准备工作
本教程将使用来自全球历史气候网络(Global Historical Climatology Network)集成数据库的气象站数据。一个数据框包含每个国家的一行数据,另一个包含每个气象站的一行数据。每个国家通常有多个气象站。
如何实现…
在本教程中,我们将进行一对多的合并,将每个国家一行的数据与气象站数据合并,气象站数据包含每个国家的多个站点。让我们开始吧:
-
导入
pandas并加载气象站和国家数据:import pandas as pd countries = pd.read_csv("data/ltcountries.csv") locations = pd.read_csv("data/ltlocations.csv") -
为气象站(
locations)和国家数据设置索引。
确认countries数据框中的合并列值是唯一的:
countries.set_index(['countryid'], inplace=True)
locations.set_index(['countryid'], inplace=True)
countries.head()
country
countryid
AC Antigua and Barbuda
AE United Arab Emirates
AF Afghanistan
AG Algeria
AJ Azerbaijan
countries.index.nunique()==countries.shape[0]
True
locations[['locationid','latitude','stnelev']].head(10)
locationid latitude stnelev
countryid
AC ACW00011604 58 18
AE AE000041196 25 34
AE AEM00041184 26 31
AE AEM00041194 25 10
AE AEM00041216 24 3
AE AEM00041217 24 27
AE AEM00041218 24 265
AF AF000040930 35 3,366
AF AFM00040911 37 378
AF AFM00040938 34 977
-
使用
join对国家和地点进行左连接:stations = countries.join(locations) stations[['locationid','latitude', ... 'stnelev','country']].head(10)locationid latitude stnelev \ countryid AC ACW00011604 58 18 AE AE000041196 25 34 AE AEM00041184 26 31 AE AEM00041194 25 10 AE AEM00041216 24 3 AE AEM00041217 24 27 AE AEM00041218 24 265 AF AF000040930 35 3,366 AF AFM00040911 37 378 AF AFM00040938 34 977 country countryid AC Antigua and Barbuda AE United Arab Emirates AE United Arab Emirates AE United Arab Emirates AE United Arab Emirates AE United Arab Emirates AE United Arab Emirates AF Afghanistan AF Afghanistan AF Afghanistan
连接似乎正常工作。但我们试着改用 merge 方法。
- 在进行合并之前,检查合并列是否匹配。
首先,重新加载数据框,因为我们做了一些更改。checkmerge函数显示,在两个数据框中有countryid合并值的行数为27,472,在countries(左侧数据框)中存在但在locations中不存在的行数为 2。这意味着内连接将返回27,472行,左连接将返回27,474行。该函数的最后语句标识出在一个数据框中有但在另一个数据框中没有的countryid值:
countries = pd.read_csv("data/ltcountries.csv")
locations = pd.read_csv("data/ltlocations.csv")
def checkmerge(dfleft, dfright, idvar):
... dfleft['inleft'] = "Y"
... dfright['inright'] = "Y"
... dfboth = pd.merge(dfleft[[idvar,'inleft']],\
... dfright[[idvar,'inright']], on=[idvar], how="outer")
... dfboth.fillna('N', inplace=True)
... print(pd.crosstab(dfboth.inleft, dfboth.inright))
... print(dfboth.loc[(dfboth.inleft=='N') | (dfboth.inright=='N')])
...
checkmerge(countries.copy(), locations.copy(), "countryid")
inright N Y
inleft
N 0 1
Y 2 27472
countryid inleft inright
9715 LQ Y N
13103 ST Y N
27474 FO N Y
- 显示一个文件中有而另一个文件中没有的行。
上一步的最后语句显示了在countries中有但在locations中没有的两个countryid值,以及在locations中有但在countries中没有的一个值:
countries.loc[countries.countryid.isin(["LQ","ST"])]
countryid country
124 LQ Palmyra Atoll [United States]
195 ST Saint Lucia
locations.loc[locations.countryid=="FO"]
locationid latitude longitude stnelev station countryid
7363 FOM00006009 61 -7 102 AKRABERG FO
- 合并
locations和countries数据框。
执行左连接。同时,统计在国家数据中存在但在气象站数据中缺失的每一列的缺失值数量:
stations = pd.merge(countries, locations, on=["countryid"], how="left")
stations[['locationid','latitude',
... 'stnelev','country']].head(10)
locationid latitude stnelev country
0 ACW00011604 58 18 Antigua and Barbuda
1 AE000041196 25 34 United Arab Emirates
2 AEM00041184 26 31 United Arab Emirates
3 AEM00041194 25 10 United Arab Emirates
4 AEM00041216 24 3 United Arab Emirates
5 AEM00041217 24 27 United Arab Emirates
6 AEM00041218 24 265 United Arab Emirates
7 AF000040930 35 3,366 Afghanistan
8 AFM00040911 37 378 Afghanistan
9 AFM00040938 34 977 Afghanistan
stations.shape
(27474, 7)
stations.loc[stations.countryid.isin(["LQ","ST"])].isnull().sum()
countryid 0
country 0
locationid 2
latitude 2
longitude 2
stnelev 2
station 2
dtype: int64
一对多合并返回预期的行数以及新的缺失值。
它是如何工作的…
在第 3 步中,我们使用了join DataFrame 方法,执行了countries和locations DataFrame 的左连接。这是执行连接的最简单方法。由于join方法使用 DataFrame 的索引进行连接,我们需要首先设置索引。然后,我们将右侧 DataFrame 传递给左侧 DataFrame 的join方法。
尽管join比这个例子中所示的稍微灵活(例如,你可以指定连接类型),但对于所有但最简单的连接,我更喜欢使用较为冗长的 pandas merge函数。我可以确信,使用merge函数时,所有我需要的选项都可以使用。在我们使用merge函数之前,我们在第 4 步中做了一些检查。这告诉我们如果执行内连接或左连接,合并后的 DataFrame 预期行数分别为 27,472 或 27,474。
我们还展示了一个 DataFrame 中有 merge-by 值的行,而另一个没有。如果我们要做左连接,我们需要决定如何处理右侧 DataFrame 中缺失的值。在这个例子中,有两个 merge-by 值在右侧 DataFrame 中未找到,导致这些列出现了缺失值。
还有更多…
你可能已经注意到,在我们调用checkmerge时,我们传递了countries和locations DataFrame 的副本:
checkmerge(countries.copy(), locations.copy(), "countryid")
我们在这里使用copy,因为我们不希望checkmerge函数对原始 DataFrame 做任何修改。
另见
我们在本章的进行一对一连接部分详细讨论了连接类型。
进行多对多连接
多对多连接会在左侧和右侧 DataFrame 中都产生重复的 merge-by 值。我们应该只在少数情况下才需要进行多对多的连接。即使数据以这种形式出现,通常也是因为我们缺少在多个一对多关系中的核心文件。例如,有捐赠者、捐赠者贡献和捐赠者联系信息的数据表,后两个文件每个捐赠者有多行。但是,在这个案例中,我们无法访问捐赠者文件,它与捐赠和联系信息文件有一对多关系。这种情况比你想象的更常见。人们有时会给我们提供数据,而不了解数据的底层结构。当我做多对多连接时,通常是因为我缺少一些关键信息,而不是因为数据库就是这么设计的。
多对多连接返回 merge-by 列值的笛卡尔积。因此,如果一个捐赠者 ID 在捐赠者联系信息文件中出现两次,在捐赠者贡献文件中出现五次,那么连接将返回 10 行数据。这在分析时通常会引发一些问题。在这个例子中,多对多连接将重复捐赠者的贡献,每个地址一次。
当面对潜在的多对多合并情况时,解决方案往往不是进行合并。相反,我们可以恢复隐含的单对多关系。以捐赠者为例,我们可以删除所有行,保留最新的联系信息,从而确保每个捐赠者只有一行数据。然后我们可以与捐赠者贡献文件进行单对多合并。但我们并不总能避免进行多对多合并。有时,我们必须生成一个分析文件或平面文件,保持所有数据,不考虑重复。这份指南展示了在需要时如何进行这些合并。
准备工作
我们将使用克利夫兰艺术博物馆(Cleveland Museum of Art)的数据。我们将使用两个 CSV 文件:一个包含集合中每个项目的媒体引用,另一个包含每个项目的创作者。
数据说明
克利夫兰艺术博物馆提供了一个公共 API,用于访问这些数据:openaccess-api.clevelandart.org/。通过该 API 可以获取比引用和创作者数据更多的数据。本指南中的数据是 2024 年 4 月下载的。
如何做...
按照以下步骤完成这份指南:
-
加载
pandas并加载克利夫兰艺术博物馆(CMA)的收藏数据:import pandas as pd cmacitations = pd.read_csv("data/cmacitations.csv") cmacreators = pd.read_csv("data/cmacreators.csv") -
看看
citations数据。itemid是集合项的标识符。前 10 个引用都是针对集合项94979的:cmacitations['citation'] = cmacitations.citation.str[0:15] cmacitations.head(10)itemid citation 0 94979 Perkins, August 1 94979 Bayley, Frank W 2 94979 W. H. D. "The F 3 94979 <em>The America 4 94979 "Clevel'd Gets 5 94979 "The Inaugurati 6 94979 Bell, Hamilton. 7 94979 Cleveland Museu 8 94979 "Special Exhibi 9 94979 Dunlap, Williamcmacitations.shape(16053, 2)cmacitations.itemid.nunique()974 -
看看
creators数据。creatorid是创作者的标识符:cmacreators['creator'] = cmacreators.creator.str[0:15] cmacreators.loc[:,['itemid','creator','birth_year', 'creatorid']].head(10)itemid creator birth_year creatorid 0 94979 John Singleton 1738 2409 1 102578 William Merritt 1849 3071 2 92937 George Bellows 1882 3005 3 151904 Thomas Eakins ( 1844 4037 4 141639 Frederic Edwin 1826 2697 5 110180 Albert Pinkham 1847 3267 6 149112 Charles Sheeler 1883 889 7 126769 Henri Rousseau 1844 1804 8 149410 Paul Gauguin (F 1848 1776 9 135299 Vincent van Gog 1853 1779cmacreators.shape(694, 8)cmacreators.itemid.nunique()618cmacreators.creatorid.nunique()486 -
显示
citations数据中重复的合并值。
集合项 148758 有 182 条媒体引用:
cmacitations.itemid.value_counts().head(10)
itemid
148758 182
113164 127
122351 125
155783 119
151904 112
124245 108
92937 104
123168 98
94979 98
149112 97
Name: count, dtype: int64
-
显示
creators数据中重复的合并值:cmacreators.itemid.value_counts().head(10)itemid 149386 4 142753 3 112932 3 149042 3 114538 3 140001 3 146797 3 149041 3 140427 3 109147 2 Name: count, dtype: int64 -
检查合并结果。
使用我们在进行单对多合并中使用的checkmerge函数:
def checkmerge(dfleft, dfright, idvar):
... dfleft['inleft'] = "Y"
... dfright['inright'] = "Y"
... dfboth = pd.merge(dfleft[[idvar,'inleft']],\
... dfright[[idvar,'inright']], on=[idvar], how="outer")
... dfboth.fillna('N', inplace=True)
... print(pd.crosstab(dfboth.inleft, dfboth.inright))
...
checkmerge(cmacitations.copy(), cmacreators.copy(), "itemid")
inright N Y
inleft
N 0 14
Y 4277 12710
-
显示在两个数据框中都重复的合并值:
cmacitations.loc[cmacitations.itemid==124733]itemid citation 14533 124733 Weigel, J. A. G 14534 124733 Winkler, Friedr 14535 124733 Francis, Henry 14536 124733 Kurz, Otto. <em 14537 124733 Minneapolis Ins 14538 124733 Pilz, Kurt. "Ha 14539 124733 Koschatzky, Wal 14540 124733 Johnson, Mark M 14541 124733 Kaufmann, Thoma 14542 124733 Koreny, Fritz. 14543 124733 Achilles-Syndra 14544 124733 Schoch, Rainer, 14545 124733 DeGrazia, Diane 14546 124733 Dunbar, Burtoncmacreators.loc[cmacreators.itemid==124733, ... ['itemid','creator','birth_year','title']]itemid creator birth_year title 591 124733 Hans Hoffmann ( 1545 Dead Blue Roller 592 124733 Albrecht Dürer 1471 Dead Blue Roller -
进行多对多合并:
cma = pd.merge(cmacitations, cmacreators, on=['itemid'], how="outer") cma.set_index("itemid", inplace=True) cma.loc[124733, ['citation','creator','birth_year']]citation creator birth_year itemid 124733 Weigel, J. A. G Hans Hoffmann ( 1545 124733 Weigel, J. A. G Albrecht Dürer 1471 124733 Winkler, Friedr Hans Hoffmann ( 1545 124733 Winkler, Friedr Albrecht Dürer 1471 124733 Francis, Henry Hans Hoffmann ( 1545 124733 Francis, Henry Albrecht Dürer 1471 124733 Kurz, Otto. <em Hans Hoffmann ( 1545 124733 Kurz, Otto. <em Albrecht Dürer 1471 124733 Minneapolis Ins Hans Hoffmann ( 1545 124733 Minneapolis Ins Albrecht Dürer 1471 124733 Pilz, Kurt. "Ha Hans Hoffmann ( 1545 124733 Pilz, Kurt. "Ha Albrecht Dürer 1471 124733 Koschatzky, Wal Hans Hoffmann ( 1545 124733 Koschatzky, Wal Albrecht Dürer 1471 ... last 14 rows removed to save space
现在,既然我已经带你了解了多对多合并的复杂性,我将再多说一点它是如何工作的。
它是如何工作的...
步骤 2告诉我们有 16,053 条引用,涉及 974 个独特项目。每个项目都有一个唯一的 ID,itemid,表示博物馆中的每个项目。平均而言,每个项目有 16 条媒体引用(16,053/974)。步骤 3告诉我们,博物馆有 694 位创作者,涉及 618 个有创作者的项目,因此绝大多数作品只有一个创作者。但citations和creators数据框中都存在重复的itemid(我们用于合并的值),这意味着我们的合并将是多对多合并。
步骤 4让我们了解了哪些itemid在citations DataFrame 中重复。博物馆中的一些项目有超过 100 个引用。值得仔细查看这些项目的引用,以判断它们是否合理。步骤 5显示,即使有多个创作者,通常也不会超过三个。在步骤 6中,我们看到大多数itemid出现在citations文件和creators文件中,但也有相当一部分有citations行但没有creators行。如果我们进行内连接或右连接,我们将丢失那 4,277 行数据,但进行左连接或外连接则不会丢失。(假设citations DataFrame 是左侧 DataFrame,creators DataFrame 是右侧 DataFrame。)
我们查看了步骤 7中在两个 DataFrame 中重复的itemid值。该集合项在citations DataFrame 中有 14 行,在creators DataFrame 中有 2 行。这将导致在合并的 DataFrame 中生成 28 行(2 * 14)。citations数据将会为creators中的每一行重复。
当我们查看在步骤 8中合并的结果时,这一点得到了确认。我们使用itemid作为合并列进行了外连接。当我们展示合并文件中相同 ID 的行时,使用的是在步骤 7中使用的 ID,我们得到了预期的 28 行(为了节省空间,我删除了最后 14 行输出)。
还有更多…
理解何时进行多对多合并非常重要,因为有时这是无法避免的。但即便在这种情况下,我们可以知道,多对多关系其实只是两个一对多关系,只是数据文件在其中一侧缺失。很可能会有一个数据表,其中每一行代表一个集合项,并且与citations数据和creators数据都有一对多关系。当我们无法访问类似的文件时,最好尝试重新生成一个具有该结构的文件。利用这些数据,我们可以创建一个包含itemid,也许还包括title的文件,然后分别与citations和creators数据进行一对多合并。
然而,有时我们必须生成一个平面文件以供后续分析。当我们,或者从我们这里获取清洗数据的同事,使用无法很好处理关系数据的软件时,可能需要这么做。例如,其他部门的某个人可能会使用 Excel 做很多数据可视化工作。只要那个人知道哪些分析需要去除重复的行,那么像我们在步骤 8中生成的结构可能会很好用。
开发合并程序
我发现将数据合并看作数据清理过程中的停车场很有帮助。数据合并和停车似乎是例行公事,但它们是意外发生的高发地带。避免在停车场发生事故的一种方法是,每次进入某个特定停车场时,都使用类似的策略。也许你总是去一个相对交通较少的区域,并且大部分时间你都是通过相同的方式到达那里。
我认为类似的方法可以应用于在数据合并时相对不受损害地进出。如果我们选择一种对我们有效的通用方法,能在 80%到 90%的情况下起作用,我们可以专注于最重要的内容——数据,而不是操控数据的技术。
在这个方法中,我将展示对我有效的一般方法,但我使用的具体技术并不重要。我认为拥有一种你了解并且能够熟练使用的方法是很有帮助的。
准备工作
我们将回到本章的进行一对多合并方法中所关注的目标。我们想要对countries数据和全球历史气候网络集成数据库中的locations数据进行左连接。
如何实现……
在这个方法中,我们将在检查合并依据值的不匹配后,对countries和locations数据进行左连接。让我们开始吧:
-
导入
pandas并加载气象站和国家数据:import pandas as pd countries = pd.read_csv("data/ltcountries.csv") locations = pd.read_csv("data/ltlocations.csv") -
检查合并依据的列是否匹配:
def checkmerge(dfleft, dfright, mergebyleft, mergebyright): ... dfleft['inleft'] = "Y" ... dfright['inright'] = "Y" ... dfboth = \ ... pd.merge(dfleft[[mergebyleft,'inleft']],\ ... dfright[[mergebyright,'inright']],\ ... left_on=[mergebyleft],\ ... right_on=[mergebyright], how="outer") ... dfboth.fillna('N', inplace=True) ... print(pd.crosstab(dfboth.inleft, ... dfboth.inright)) ... print(dfboth.loc[(dfboth.inleft=='N') | \ ... (dfboth.inright=='N')].head(20)) checkmerge(countries.copy(), locations.copy(), "countryid", "countryid")inright N Y inleft N 0 1 Y 2 27472 countryid inleft inright 7363 FO N Y 9716 LQ Y N 13104 ST Y N -
合并国家和位置数据:
stations = pd.merge(countries, locations, left_on=["countryid"], right_on=["countryid"], how="left") stations[['locationid','latitude', ... 'stnelev','country']].head(10)locationid latitude stnelev country 0 ACW00011604 57.7667 18.0 Antigua and Barbuda 1 AE000041196 25.3330 34.0 United Arab Emirates 2 AEM00041184 25.6170 31.0 United Arab Emirates 3 AEM00041194 25.2550 10.4 United Arab Emirates 4 AEM00041216 24.4300 3.0 United Arab Emirates 5 AEM00041217 24.4330 26.8 United Arab Emirates 6 AEM00041218 24.2620 264.9 United Arab Emirates 7 AF000040930 35.3170 3366.0 Afghanistan 8 AFM00040911 36.7000 378.0 Afghanistan 9 AFM00040938 34.2100 977.2 Afghanistanstations.shape(27474, 7)
在这里,我们从左连接中得到了预期的行数:27,472行数据在两个 DataFrame 中都有合并依据值,另外有两行数据在左侧 DataFrame 中有合并依据值,但右侧没有。
它是如何工作的……
对于我进行的大多数合并,类似于步骤 2和步骤 3中使用的逻辑效果很好。我们在前一个方法中使用的checkmerge函数中添加了一个第四个参数。这使我们能够为左右两个 DataFrame 指定不同的合并依据列。每次进行合并时我们不需要重新创建这个函数。我们只需要将它包含在一个模块中并导入即可。(在本书的最后一章,我们将讨论如何将辅助函数添加到模块中。)
在进行合并之前调用checkmerge函数,能给我们足够的信息,让我们知道在使用不同的连接类型进行合并时会得到什么结果。我们将知道通过内连接、外连接、左连接或右连接返回多少行数据。我们还会知道在实际合并之前,哪些地方会生成新的缺失值。当然,这个操作相当耗费资源,因为每次我们都需要运行两次合并——一次诊断性的外连接,接着再进行我们选择的任何连接类型。但我认为这通常是值得的,至少可以帮助我们停下来思考我们在做什么。
最后,我们在步骤 3中进行了合并。这是我偏好的语法。我总是将左侧的 DataFrame 用作第一个参数,右侧的 DataFrame 用作第二个参数,尽管merge允许我们以不同的方式指定左右 DataFrame。我还为left_on和right_on设置了值,即使合并列相同,我也可以使用on(就像我们在前面的例子中所做的那样)。这样做是为了避免在合并列不同的情况下需要更改语法,我也喜欢它能让两个 DataFrame 的合并列变得显式。
一个稍微有争议的做法是,我默认使用左连接,将how参数初始设置为左连接。我将其作为起始假设,然后问自己是否有理由使用其他连接类型。左侧 DataFrame 中的行通常代表我的分析单元(如学生、病人、客户等),而我正在从右侧 DataFrame 中添加补充数据(如 GPA、血压、邮政编码等)。如果合并列在右侧 DataFrame 中不存在(例如执行内连接时),删除分析单元中的行可能会导致问题。例如,在本章的一对一合并食谱中,如果删除主 NLS 数据中没有出现在我们为父母提供的补充数据中的行,可能就没有意义。
另请参见
我们将在第十二章,自动化数据清理与用户自定义函数、类和管道中创建包含有用数据清理功能的模块。
我们在本章的一对一合并食谱中讨论了连接的类型。
总结
我们在本章仔细研究了纵向合并数据(也称为拼接)和横向合并数据(也称为合并)。我们讨论了拼接数据时的关键数据问题,包括文件之间的不同列。我们还考虑了合并数据时的关键问题,如合并列的缺失值和数据的意外重复。我们还探讨了不同连接类型对这些问题的影响。在下一章中,我们将学习如何整理和重塑混乱的数据。
留下评论!
享受本书吗?通过在亚马逊上留下评论,帮助像您一样的读者。扫描下面的二维码获取您选择的免费电子书。

第十一章:整理和重塑数据
引用列夫·托尔斯泰的智慧(“幸福的家庭都是相似的;每个不幸的家庭都有其不幸的方式。”),哈德利·威克姆告诉我们,所有整洁的数据本质上是相似的,但所有不整洁的数据都有其独特的混乱方式。我们多少次盯着某些数据行,心里想,“这…怎么回事…为什么他们这么做?” 这有点夸张。尽管数据结构不良的方式有很多,但在人类创造力方面是有限的。我们可以将数据集偏离标准化或整洁形式的最常见方式进行分类。
这是哈德利·威克姆在他关于整洁数据的开创性著作中的观察。我们可以依赖这项工作,以及我们自己在处理结构奇特的数据时的经验,为我们需要进行的重塑做好准备。不整洁的数据通常具有以下一种或多种特征:缺乏明确的按列合并关系;一对多关系中的一方数据冗余;多对多关系中的数据冗余;列名中存储值;将多个值存储在一个变量值中;数据未按分析单位进行结构化。(尽管最后一种情况不一定是数据不整洁的表现,但我们将在接下来的几个菜谱中回顾的某些技术也适用于常见的分析单位问题。)
在本章中,我们使用强大的工具来应对像前面那样的数据清理挑战。具体而言,我们将讨论以下内容:
-
移除重复行
-
修复多对多关系
-
使用
stack和melt将数据从宽格式重塑为长格式 -
多组列的合并
-
使用
unstack和pivot将数据从长格式重塑为宽格式
技术要求
完成本章的任务,您将需要 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但该代码可以在 pandas 1.5.3 或更高版本上运行。
本章中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
移除重复行
数据在分析单位上的重复有几种原因:
-
当前的 DataFrame 可能是一次一对多合并的结果,其中一方是分析单位。
-
该 DataFrame 是反复测量或面板数据被压缩为平面文件,这只是第一种情况的特殊情况。
-
我们可能正在处理一个分析文件,其中多个一对多关系已被展平,形成多对多关系。
当单一侧面是分析单元时,多重侧面的数据可能需要以某种方式进行合并。例如,如果我们在分析一组大学生的结果,学生是分析单元;但我们也可能拥有每个学生的课程注册数据。为了准备数据进行分析,我们可能需要首先统计每个学生的课程数量、总学分或计算 GPA,最后得到每个学生的一行数据。通过这个例子,我们可以概括出,在去除重复数据之前,我们通常需要对多重侧面的信息进行聚合。
在这个示例中,我们查看了 pandas 处理去除重复行的技巧,并考虑了在这个过程中什么时候需要进行聚合,什么时候不需要。在下一示例中,我们将解决多对多关系中的重复问题。
准备工作
在本示例中,我们将处理 COVID-19 每日病例数据。该数据集为每个国家提供每一天的一行数据,每行记录当天的新病例和新死亡人数。每个国家还有人口统计数据和病例死亡的累计总数,因此每个国家的最后一行提供了总病例数和总死亡人数。
数据说明
Our World in Data 提供了 COVID-19 公共使用数据,网址是ourworldindata.org/covid-cases。该数据集包括总病例和死亡人数、已进行的测试、医院床位以及诸如中位年龄、国内生产总值和糖尿病患病率等人口统计数据。本示例中使用的数据集是 2024 年 3 月 3 日下载的。
如何操作…
我们使用drop_duplicates去除每个国家在 COVID-19 每日数据中的重复人口统计数据。当我们需要先进行一些聚合再去除重复数据时,我们也可以探索groupby作为drop_duplicates的替代方法:
-
导入
pandas和 COVID-19 每日病例数据:import pandas as pd covidcases = pd.read_csv("data/covidcases.csv") -
为每日病例和死亡列、病例总数列以及人口统计列创建列表(
total_cases和total_deaths列分别是该国家的病例和死亡的累计总数):dailyvars = ['casedate','new_cases','new_deaths'] totvars = ['location','total_cases','total_deaths'] demovars = ['population','population_density', ... 'median_age','gdp_per_capita', ... 'hospital_beds_per_thousand','region'] covidcases[dailyvars + totvars + demovars].head(2).T0 1 casedate 2020-03-01 2020-03-15 new_cases 1 6 new_deaths 0 0 location Afghanistan Afghanistan total_cases 1 7 total_deaths 0 0 population 41128772 41128772 population_density 54 54 median_age 19 19 gdp_per_capita 1,804 1,804 hospital_beds_per_thousand 0 0 region South Asia South Asia -
创建一个仅包含每日数据的 DataFrame:
coviddaily = covidcases[['location'] + dailyvars] coviddaily.shape(36501, 4)coviddaily.head()location casedate new_cases new_deaths 0 Afghanistan 2020-03-01 1 0 1 Afghanistan 2020-03-15 6 0 2 Afghanistan 2020-03-22 17 0 3 Afghanistan 2020-03-29 67 2 4 Afghanistan 2020-04-05 183 3 -
为每个国家选择一行。
检查预计有多少个国家(位置),方法是获取唯一位置的数量。按位置和病例日期排序。然后使用drop_duplicates选择每个位置的一个行,并使用 keep 参数指示我们希望为每个国家选择最后一行:
covidcases.location.nunique()
231
coviddemo = \
covidcases[['casedate'] + totvars + demovars].\
sort_values(['location','casedate']).\
drop_duplicates(['location'], keep='last').\
rename(columns={'casedate':'lastdate'})
coviddemo.shape
(231, 10)
coviddemo.head(2).T
204 379
lastdate 2024-02-04 2024-01-28
location Afghanistan Albania
total_cases 231,539 334,863
total_deaths 7,982 3,605
population 41128772 2842318
population_density 54 105
median_age 19 38
gdp_per_capita 1,804 11,803
hospital_beds_per_thousand 0 3
region South Asia Eastern Europe
- 对每个组的值进行求和。
使用 pandas 的 DataFrame groupby 方法来计算每个国家的病例和死亡总数。(我们在这里计算病例和死亡的总和,而不是使用 DataFrame 中已存在的病例和死亡的累计总数。)同时,获取一些在每个国家的所有行中都重复的列的最后一个值:median_age、gdp_per_capita、region和casedate。(我们只选择 DataFrame 中的少数几列。)请注意,数字与第 4 步中的一致:
covidtotals = covidcases.groupby(['location'],
... as_index=False).\
... agg({'new_cases':'sum','new_deaths':'sum',
... 'median_age':'last','gdp_per_capita':'last',
... 'region':'last','casedate':'last',
... 'population':'last'}).\
... rename(columns={'new_cases':'total_cases',
... 'new_deaths':'total_deaths',
... 'casedate':'lastdate'})
covidtotals.head(2).T
0 1
location Afghanistan Albania
total_cases 231,539 334,863
total_deaths 7,982 3,605
median_age 19 38
gdp_per_capita 1,804 11,803
region South Asia Eastern Europe
lastdate 2024-02-04 2024-01-28
population 41128772 2842318
选择使用drop_duplicates还是groupby来消除数据冗余,取决于我们是否需要在压缩多方之前进行任何聚合。
它是如何工作的……
COVID-19 数据每个国家每天有一行,但实际上很少有数据是每日数据。只有casedate、new_cases和new_deaths可以视为每日数据。其他列则显示累计病例和死亡人数,或是人口统计数据。累计数据是冗余的,因为我们已有new_cases和new_deaths的实际值。人口统计数据在所有日期中对于每个国家来说值是相同的。
国家(及其相关人口统计数据)与每日数据之间有一个隐含的一对多关系,其中一方是国家,多方是每日数据。我们可以通过创建一个包含每日数据的 DataFrame 和另一个包含人口统计数据的 DataFrame 来恢复这种结构。我们在步骤 3和4中做到了这一点。当我们需要跨国家的总数时,我们可以自己生成,而不是存储冗余数据。
然而,运行总计变量并非完全没有用处。我们可以使用它们来检查我们关于病例总数和死亡总数的计算。步骤 5展示了如何在需要执行的不仅仅是去重时,使用groupby来重构数据。在这种情况下,我们希望对每个国家的new_cases和new_deaths进行汇总。
还有更多……
我有时会忘记一个小细节。在改变数据结构时,某些列的含义可能会发生变化。在这个例子中,casedate变成了每个国家最后一行的日期。我们将该列重命名为lastdate。
另请参见
我们在第九章《聚合时修复混乱数据》中更详细地探讨了groupby。
Hadley Wickham 的整洁数据论文可以在vita.had.co.nz/papers/tidy-data.pdf找到。
修复多对多关系
有时我们必须处理从多对多合并创建的数据表。这是一种在左侧和右侧的合并列值都被重复的合并。正如我们在前一章中讨论的那样,数据文件中的多对多关系通常代表多个一对多关系,其中一方被移除。数据集 A 和数据集 B 之间有一对多关系,数据集 A 和数据集 C 之间也有一对多关系。我们有时面临的问题是,收到的数据文件将 B 和 C 合并在一起,而将 A 排除在外。
处理这种结构的数据的最佳方式是重新创建隐含的一对多关系,若可能的话。我们通过首先创建一个类似 A 的数据集来实现;也就是说,假设有一个多对多关系,我们可以推测 A 的结构是怎样的。能够做到这一点的关键是为数据两边的多对多关系识别出一个合适的合并列。这个列或这些列将在 B 和 C 数据集中重复,但在理论上的 A 数据集中不会重复。
本教程中使用的数据就是一个很好的例子。我们使用克利夫兰艺术博物馆的收藏数据。每个博物馆收藏品都有多行数据。这些数据包括收藏品的信息(如标题和创作日期);创作者的信息(如出生和死亡年份);以及该作品在媒体中的引文。当有多个创作者和多个引文时(这通常发生),行数会重复。更精确地说,每个收藏品的行数是引文和创作者数量的笛卡尔积。所以,如果有 5 条引文和 2 个创作者,我们将看到该项目有 10 行数据。
我们想要的是一个收藏品文件,每个收藏品一行(并且有一个唯一标识符),一个创作者文件,每个创作者对应一行,和一个引文文件,每个引文对应一行。在本教程中,我们将创建这些文件。
你们中的一些人可能已经注意到,这里还有更多的整理工作要做。我们最终需要一个单独的创作者文件,每个创作者一行,另一个文件只包含创作者 ID 和收藏品 ID。我们需要这种结构,因为一个创作者可能会为多个项目创作。我们在这个教程中忽略了这个复杂性。
我应该补充的是,这种情况并不是克利夫兰艺术博物馆的错,该博物馆慷慨地提供了一个 API,可以返回作为 JSON 文件的收藏数据。使用 API 的个人有责任创建最适合自己研究目的的数据文件。直接从更灵活的 JSON 文件结构中工作也是可能的,而且通常是一个不错的选择。我们将在 第十二章,使用用户定义的函数、类和管道自动清理数据 中演示如何操作。
准备工作
我们将使用克利夫兰艺术博物馆收藏的数据。CSV 文件包含有关创作者和引文的数据,这些数据通过 itemid 列合并,itemid 用来标识收藏品。每个项目可能有一行或多行关于引文和创作者的数据。
数据说明
克利夫兰艺术博物馆提供了一个公共访问数据的 API:openaccess-api.clevelandart.org/。API 提供的数据远不止引文和创作者的数据。本文中的数据是 2024 年 4 月下载的。
如何实现…
我们通过恢复数据中隐含的多个一对多关系来处理 DataFrame 之间的多对多关系:
-
导入
pandas和博物馆的collections数据。为了更方便显示,我们还将限制collection和title列中值的长度:import pandas as pd cma = pd.read_csv("data/cmacollections.csv") cma['category'] = cma.category.str.strip().str[0:15] cma['title'] = cma.title.str.strip().str[0:30] -
显示博物馆的一些
collections数据。注意,几乎所有的数据值都是冗余的,除了citation。
同时,显示唯一的 itemid、citation 和 creator 值的数量。有 986 个独特的集合项,12,941 个引用,以及 1,062 对 item/creator 组合:
cma.shape
(17001, 9)
cma.head(4).T
0 1 \
itemid 75551 75551
citation Purrmann, Hans. <em>Henri Matis
creatorid 2,130 2,130
creator Henri Matisse ( Henri Matisse (
title Tulips Tulips
birth_year 1869 1869
death_year 1954 1954
category Mod Euro - Pain Mod Euro - Pain
creation_date 1914 1914
2 3
itemid 75551 75551
citation Flam, Jack D. < <em>Masters of
creatorid 2,130 2,130
creator Henri Matisse ( Henri Matisse (
title Tulips Tulips
birth_year 1869 1869
death_year 1954 1954
category Mod Euro - Pain Mod Euro - Pain
creation_date 1914 1914
cma.itemid.nunique()
986
cma.drop_duplicates(['itemid','citation']).\
itemid.count()
12941
cma.drop_duplicates(['itemid','creatorid']).\
itemid.count()
1062
- 显示一个包含重复引用和创作者的集合项。
只显示前 6 行(实际上共有 28 行)。注意,引用数据对于每个创作者都是重复的:
cma.set_index(['itemid'], inplace=True)
cma.loc[124733, ['title','citation',
'creation_date','creator','birth_year']].head(6)
title citation \
itemid
124733 Dead Blue Roller Weigel, J. A. G
124733 Dead Blue Roller Weigel, J. A. G
124733 Dead Blue Roller Winkler, Friedr
124733 Dead Blue Roller Winkler, Friedr
124733 Dead Blue Roller Francis, Henry
124733 Dead Blue Roller Francis, Henry
creation_date creator birth_year
itemid
124733 1583 Hans Hoffmann ( 1545
124733 1583 Albrecht Dürer 1471
124733 1583 Hans Hoffmann ( 1545
124733 1583 Albrecht Dürer 1471
124733 1583 Hans Hoffmann ( 1545
124733 1583 Albrecht Dürer 1471
-
创建一个集合 DataFrame。
title、category和creation_date应该是每个集合项唯一的,因此我们创建一个仅包含这些列的 DataFrame,并带有itemid索引。我们得到预期的行数986:collectionsvars = \ ['title','category','creation_date'] cmacollections = cma[collectionsvars].\ reset_index().\ drop_duplicates(['itemid']).\ set_index(['itemid']) cmacollections.shape(986, 3)cmacollections.head()title \ itemid 75551 Tulips 75763 Procession or Pardon at Perros 78982 The Resurrection of Christ 84662 The Orange Christ 86110 Sunset Glow over a Fishing Vil category creation_date itemid 75551 Mod Euro - Pain 1914 75763 Mod Euro - Pain 1891 78982 P - German befo 1622 84662 Mod Euro - Pain 1889 86110 ASIAN - Hanging 1460s–1550s -
让我们看看新 DataFrame
cmacollections中的同一项,该项在 步骤 3 中已经展示过:cmacollections.loc[124733]title Dead Blue Roller category DR - German creation_date 1583 Name: 124733, dtype: object -
创建一个引用(citations)DataFrame。
这将只包含 itemid 和 citation:
cmacitations = cma[['citation']].\
reset_index().\
drop_duplicates(['itemid','citation']).\
set_index(['itemid'])
cmacitations.loc[124733]
citation
itemid
124733 Weigel, J. A. G
124733 Winkler, Friedr
124733 Francis, Henry
124733 Kurz, Otto. <em
124733 Minneapolis Ins
124733 Pilz, Kurt. "Ha
124733 Koschatzky, Wal
124733 Johnson, Mark M
124733 Kaufmann, Thoma
124733 Koreny, Fritz.
124733 Achilles-Syndra
124733 Schoch, Rainer,
124733 DeGrazia, Diane
124733 Dunbar, Burton
-
创建一个创作者 DataFrame:
creatorsvars = \ ['creator','birth_year','death_year'] cmacreators = cma[creatorsvars].\ reset_index().\ drop_duplicates(['itemid','creator']).\ set_index(['itemid']) cmacreators.loc[124733]creator birth_year death_year itemid 124733 Hans Hoffmann ( 1545 1592 124733 Albrecht Dürer 1471 1528 -
统计出生在 1950 年后创作者的集合项数量。
首先,将 birth_year 值从字符串转换为数字。然后,创建一个只包含年轻艺术家的 DataFrame:
cmacreators['birth_year'] = \
cmacreators.birth_year.str.\
findall("\d+").str[0].astype(float)
youngartists = \
cmacreators.loc[cmacreators.birth_year>1950,
['creator']].assign(creatorbornafter1950='Y')
youngartists.shape[0]==youngartists.index.nunique()
True
youngartists
creator creatorbornafter1950
itemid
168529 Richard Barnes Y
369885 Simone Leigh (A Y
371392 Belkis Ayón (Cu Y
378931 Teresa Margolle Y
-
现在,我们可以将
youngartistsDataFrame 与集合 DataFrame 合并,创建一个标记,用于标识至少有一个创作者出生在 1950 年后 的集合项:cmacollections = \ pd.merge(cmacollections, youngartists, left_on=['itemid'], right_on=['itemid'], how='left') cmacollections.fillna({'creatorbornafter1950':'N'}, inplace=True) cmacollections.shape(986, 9)cmacollections.creatorbornafter1950.value_counts()creatorbornafter1950 N 982 Y 4 Name: count, dtype: int64
现在我们有了三个 DataFrame——集合项(cmacollections)、引用(cmacitations)和创作者(cmacreators)——而不是一个。cmacollections 与 cmacitations 和 cmacreators 都存在一对多关系。
它是如何工作的……
如果你主要直接处理企业数据,你可能很少会看到这种结构的文件,但许多人并没有这么幸运。如果我们从博物馆请求关于其收藏的媒体引用和创作者的数据,得到类似这样的数据文件并不完全令人惊讶,其中引用和创作者的数据是重复的。但看起来像是集合项唯一标识符的存在,让我们有希望恢复集合项与其引用之间、一对多的关系,以及集合项与创作者之间、一对多的关系。
步骤 2 显示有 986 个独特的 itemid 值。这表明在 17,001 行的 DataFrame 中,可能只包含 986 个集合项。共有 12,941 对独特的 itemid 和 citation,即每个集合项平均有约 13 条引用。共有 1,062 对 itemid 和 creator。
步骤 3 展示了集合项目值(如 title)的重复情况。返回的行数等于左右合并条件的笛卡尔积。对于 Dead Blue Roller 项目,共有 28 行(我们在步骤 3中只展示了其中 6 行),因为它有 14 个引用和 2 个创作者。每个创作者的行会被重复 14 次;每个引用重复一次,针对每个创作者。每个引用会出现两次;一次针对每个创作者。对于非常少的用例,保留这种状态的数据是有意义的。
我们的“北极星”是 itemid 列,它帮助我们将数据转化为更好的结构。在步骤 4中,我们利用它来创建集合 DataFrame。我们仅保留每个 itemid 值的一行,并获取与集合项目相关的其他列,而非引用或创作者——title、category 和 creation_date(因为 itemid 是索引,我们需要先重置索引,然后再删除重复项)。
我们按照相同的程序,在步骤 6 和 步骤 7 中分别创建 citations 和 creators DataFrame。我们使用 drop_duplicates 保留 itemid 和 citation 的唯一组合,和 itemid 和 creator 的唯一组合。这让我们得到了预期的行数:14 行 citations 数据和 2 行 creators 数据。
步骤 8 展示了我们如何使用这些 DataFrame 来构建新列并进行分析。我们想要计算至少有一个创作者出生在 1950 年之后的集合项目数量。分析的单位是集合项目,但我们需要从创作者 DataFrame 中获取信息来进行计算。由于 cmacollections 和 cmacreators 之间是多对一的关系,我们确保在创作者 DataFrame 中每个 itemid 只获取一行数据,即使某个项目有多个创作者出生在 1950 年之后:
youngartists.shape[0]==youngartists.index.nunique()
还有更多...
当我们处理定量数据时,多对多合并所产生的重复数据最为棘手。如果原始文件中包含了每个集合项目的评估价值,这些值将像 title 一样被重复。如果我们对评估价值生成描述性统计信息,结果会偏差很大。例如,如果 Dead Blue Roller 项目的评估价值为 1,000,000 美元,在汇总评估价值时,我们将得到 28,000,000 美元,因为有 28 个重复值。
这突显了标准化和整洁数据的重要性。如果有评估价值列,我们会将其包含在步骤 4中创建的 cmacollections DataFrame 中。这个值将不会被重复,并且我们能够为集合生成汇总统计数据。
我发现始终回到分析单位是非常有帮助的,它与整洁数据的概念有重叠,但在某些方面有所不同。如果我们只关心 1950 年后出生的创作者的数量,而不是 1950 年后出生的创作者所对应的收藏项数量,第 8 步中的方法会完全不同。在这种情况下,分析单位将是创作者,我们只会使用创作者数据框。
另请参见
我们在第十章的处理合并数据框时的数据问题部分中讨论了多对多合并。
我们在第十二章的使用用户定义函数、类和管道自动化数据清理部分中的处理非表格数据结构的类示例中,展示了处理这种结构数据的完全不同方式。
使用 stack 和 melt 将数据从宽格式转换为长格式
Wickham 确定的一种不整洁数据类型是将变量值嵌入列名中。虽然在企业或关系型数据中这种情况很少发生,但在分析数据或调查数据中却相当常见。变量名可能会有后缀,指示时间段,如月份或年份。或者,调查中相似的变量可能有类似的名称,比如 familymember1age 和 familymember2age,因为这样便于使用,并且与调查设计者对变量的理解一致。
调查数据中这种混乱相对频繁发生的一个原因是,一个调查工具上可能有多个分析单位。一个例子是美国的十年一次人口普查,它既询问家庭问题,也询问个人问题。调查数据有时还包括重复测量或面板数据,但通常每个受访者只有一行数据。在这种情况下,新测量值或新回答会存储在新列中,而不是新行中,列名将与早期时期的响应列名相似,唯一的区别是后缀的变化。
美国青年纵向调查(NLS)是一个很好的例子。它是面板数据,每个个体每年都进行调查。然而,分析文件中每个受访者只有一行数据。类似“在某一年工作了多少周”这样的问题的回答会放入新的列中。整理 NLS 数据意味着将如 weeksworked17 到 weeksworked21(即 2017 年到 2021 年间的工作周数)等列,转换成仅有一列表示工作周数,另一列表示年份,且每个人有五行数据(每年一行),而不是一行数据。这有时被称为将数据从宽格式转换为长格式。
令人惊讶的是,pandas 有几个函数使得像这样的转换相对容易:stack、melt 和 wide_to_long。我们在这个示例中使用 stack 和 melt,并在接下来的部分探讨 wide_to_long。
准备工作
我们将处理 NLS 中每年工作周数和大学入学状态的数据。DataFrame 中每行对应一位调查参与者。
数据说明
国家纵向调查(NLS),由美国劳工统计局管理,是对 1997 年开始时在高中的个体进行的纵向调查。参与者每年接受一次调查,直到 2023 年。调查数据可供公众使用,网址为nlsinfo.org。
如何操作…
我们将使用stack和melt将 NLS 工作周数据从宽格式转换为长格式,同时提取列名中的年份值:
-
导入
pandas和 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) -
查看一些工作周数的值。
首先,设置索引:
nls97.set_index(['originalid'], inplace=True)
weeksworkedcols = ['weeksworked17','weeksworked18',
'weeksworked19','weeksworked20','weeksworked21']
nls97.loc[[2,3],weeksworkedcols].T
originalid 2 3
weeksworked17 52 52
weeksworked18 52 52
weeksworked19 52 9
weeksworked20 52 0
weeksworked21 46 0
nls97.shape
(8984, 110)
- 使用
stack将数据从宽格式转换为长格式。
首先,仅选择weeksworked##列。使用 stack 将原始 DataFrame 中的每个列名移入索引,并将weeksworked##的值移入相应的行。重置index,使得weeksworked##列名成为level_1列(我们将其重命名为year)的值,weeksworked##的值成为 0 列(我们将其重命名为weeksworked)的值:
数据说明
对于未来升级到pandas 3.0,我们需要在stack函数中提到关键字参数(future_stack=True)。
weeksworked = nls97[weeksworkedcols].\
stack().\
reset_index().\
rename(columns={'level_1':'year',0:'weeksworked'})
weeksworked.loc[weeksworked.originalid.isin([2,3])]
originalid year weeksworked
5 2 weeksworked17 52
6 2 weeksworked18 52
7 2 weeksworked19 52
8 2 weeksworked20 52
9 2 weeksworked21 46
10 3 weeksworked17 52
11 3 weeksworked18 52
12 3 weeksworked19 9
13 3 weeksworked20 0
14 3 weeksworked21 0
- 修正
year值。
获取年份值的最后几位数字,将其转换为整数,并加上 2,000:
weeksworked['year'] = \
weeksworked.year.str[-2:].astype(int)+2000
weeksworked.loc[weeksworked.originalid.isin([2,3])]
originalid year weeksworked
5 2 2017 52
6 2 2018 52
7 2 2019 52
8 2 2020 52
9 2 2021 46
10 3 2017 52
11 3 2018 52
12 3 2019 9
13 3 2020 0
14 3 2021 0
weeksworked.shape
(44920, 3)
- 或者,使用
melt将数据从宽格式转换为长格式。
首先,重置index并选择originalid和weeksworked##列。使用melt的id_vars和value_vars参数,指定originalid作为ID变量,并将weeksworked##列作为要旋转或熔化的列。使用var_name和value_name参数将列名重命名为year和weeksworked。value_vars中的列名成为新year列的值(我们使用原始后缀将其转换为整数)。value_vars列的值被分配到新weeksworked列中的相应行:
weeksworked = nls97.reset_index().\
loc[:,['originalid'] + weeksworkedcols].\
melt(id_vars=['originalid'],
value_vars=weeksworkedcols,
var_name='year', value_name='weeksworked')
weeksworked['year'] = \
weeksworked.year.str[-2:].astype(int)+2000
weeksworked.set_index(['originalid'], inplace=True)
weeksworked.loc[[2,3]]
year weeksworked
originalid
2 2017 52
2 2018 52
2 2019 52
2 2020 52
2 2021 46
3 2017 52
3 2018 52
3 2019 9
3 2020 0
3 2021 0
- 使用
melt重塑大学入学列。
这与melt函数在处理工作周数列时的作用相同:
colenrcols = \
['colenroct17','colenroct18','colenroct19',
'colenroct20','colenroct21']
colenr = nls97.reset_index().\
loc[:,['originalid'] + colenrcols].\
melt(id_vars=['originalid'], value_vars=colenrcols,
var_name='year', value_name='colenr')
colenr['year'] = colenr.year.str[-2:].astype(int)+2000
colenr.set_index(['originalid'], inplace=True)
colenr.loc[[2,3]]
year colenr
originalid
2 2017 1\. Not enrolled
2 2018 1\. Not enrolled
2 2019 1\. Not enrolled
2 2020 1\. Not enrolled
2 2021 1\. Not enrolled
3 2017 1\. Not enrolled
3 2018 1\. Not enrolled
3 2019 1\. Not enrolled
3 2020 1\. Not enrolled
3 2021 1\. Not enrolled
-
合并工作周数和大学入学数据:
workschool = \ pd.merge(weeksworked, colenr, on=['originalid','year'], how="inner") workschool.shape(44920, 3)workschool.loc[[2,3]]year weeksworked colenr originalid 2 2017 52 1\. Not enrolled 2 2018 52 1\. Not enrolled 2 2019 52 1\. Not enrolled 2 2020 52 1\. Not enrolled 2 2021 46 1\. Not enrolled 3 2017 52 1\. Not enrolled 3 2018 52 1\. Not enrolled 3 2019 9 1\. Not enrolled 3 2020 0 1\. Not enrolled 3 2021 0 1\. Not enrolled
这将通过熔化工作周数和大学入学列,生成一个 DataFrame。
它是如何工作的…
我们可以使用stack或melt将数据从宽格式重塑为长格式,但melt提供了更多的灵活性。stack会将所有列名移动到索引中。我们在第 4 步中看到,堆叠后得到了预期的行数44920,这等于 5*8984,即初始数据中的行数。
使用melt,我们可以根据不同于索引的ID变量旋转列名和值。我们通过id_vars参数来实现这一点。我们使用value_vars参数指定要旋转的变量。
在 步骤 6 中,我们还重新塑造了大学入学的列。为了将重新塑造后的工作周和大学入学数据合并为一个 DataFrame,我们合并了 步骤 5 和 步骤 6 中创建的两个 DataFrame。我们将在下一个配方中看到如何一步完成 步骤 5 到 步骤 7 的工作。
融合多个列组
在前一个配方中,当我们需要融合多个列组时,我们使用了两次 melt 然后合并了结果的 DataFrame。那样也可以,但我们可以用 wide_to_long 函数在一步内完成相同的任务。wide_to_long 的功能比 melt 强大,但使用起来稍微复杂一些。
准备工作
我们将在本配方中使用 NLS 的工作周和大学入学数据。
如何操作...
我们将使用 wide_to_long 一次性转换多个列组:
-
导入
pandas并加载 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index('personid', inplace=True) -
查看一些工作周和大学入学的数据:
weeksworkedcols = ['weeksworked17','weeksworked18', 'weeksworked19','weeksworked20','weeksworked21'] colenrcols = ['colenroct17','colenroct18', 'colenroct19','colenroct20','colenroct21'] nls97.loc[nls97.originalid.isin([2,3]), ['originalid'] + weeksworkedcols + colenrcols].Tpersonid 999406 151672 originalid 2 3 weeksworked17 52 52 weeksworked18 52 52 weeksworked19 52 9 weeksworked20 52 0 weeksworked21 46 0 colenroct17 1\. Not enrolled 1\. Not enrolled colenroct18 1\. Not enrolled 1\. Not enrolled colenroct19 1\. Not enrolled 1\. Not enrolled colenroct20 1\. Not enrolled 1\. Not enrolled colenroct21 1\. Not enrolled 1\. Not enrolled -
运行
wide_to_long函数。
将一个列表传递给 stubnames 以指示所需的列组。(所有列名以列表中每一项的相同字符开头的列都会被选中进行转换。)使用 i 参数指示 ID 变量(originalid),并使用 j 参数指定基于列后缀(如 17、18 等)命名的列(year):
workschool = pd.wide_to_long(nls97[['originalid']
... + weeksworkedcols + colenrcols],
... stubnames=['weeksworked','colenroct'],
... i=['originalid'], j='year').reset_index()
workschool['year'] = workschool.year+2000
workschool = workschool.\
... sort_values(['originalid','year'])
workschool.set_index(['originalid'], inplace=True)
workschool.loc[[2,3]]
year weeksworked colenroct
originalid
2 2017 52 1\. Not enrolled
2 2018 52 1\. Not enrolled
2 2019 52 1\. Not enrolled
2 2020 52 1\. Not enrolled
2 2021 46 1\. Not enrolled
3 2017 52 1\. Not enrolled
3 2018 52 1\. Not enrolled
3 2019 9 1\. Not enrolled
3 2020 0 1\. Not enrolled
3 2021 0 1\. Not enrolled
wide_to_long 一步完成了我们在前一个配方中使用 melt 需要多个步骤才能完成的工作。
工作原理...
wide_to_long 函数几乎为我们完成了所有工作,尽管它的设置比 stack 或 melt 要复杂一些。我们需要向函数提供列组的字符(在这个例子中是 weeksworked 和 colenroct)。由于我们的变量名称带有表示年份的后缀,wide_to_long 会将这些后缀转换为有意义的值,并将它们融合到用 j 参数命名的列中。这几乎就像魔法一样!
还有更多...
本配方中 stubnames 列的后缀是相同的:17 到 21。但这不一定是必然的。当某个列组有后缀,而另一个没有时,后者列组对应后缀的值将会缺失。通过排除 DataFrame 中的 weeksworked17 并添加 weeksworked16,我们可以看到这一点:
weeksworkedcols = ['weeksworked16','weeksworked18',
'weeksworked19','weeksworked20','weeksworked21']
workschool = pd.wide_to_long(nls97[['originalid']
... + weeksworkedcols + colenrcols],
... stubnames=['weeksworked','colenroct'],
... i=['originalid'], j='year').reset_index()
workschool['year'] = workschool.year+2000
workschool = workschool.sort_values(['originalid','year'])
workschool.set_index(['originalid'], inplace=True)
workschool.loc[[2,3]]
year weeksworked colenroct
originalid
2 2016 53 NaN
2 2017 NaN 1\. Not enrolled
2 2018 52 1\. Not enrolled
2 2019 52 1\. Not enrolled
2 2020 52 1\. Not enrolled
2 2021 46 1\. Not enrolled
3 2016 53 NaN
3 2017 NaN 1\. Not enrolled
3 2018 52 1\. Not enrolled
3 2019 9 1\. Not enrolled
3 2020 0 1\. Not enrolled
3 2021 0 1\. Not enrolled
现在,2017 年的 weeksworked 值缺失了,2016 年的 colenroct 值也缺失了。
使用 unstack 和 pivot 将数据从长格式转换为宽格式
有时候,我们实际上需要将数据从整洁格式转换为杂乱格式。这通常是因为我们需要将数据准备为某些不擅长处理关系型数据的软件包分析,或者因为我们需要提交数据给某个外部机构,而对方要求以杂乱格式提供数据。unstack 和 pivot 在需要将数据从长格式转换为宽格式时非常有用。unstack 做的是与我们使用 stack 的操作相反的事,而 pivot 做的则是与 melt 相反的操作。
准备工作
我们在本食谱中继续处理关于工作周数和大学入学的 NLS 数据。
如何操作……
我们使用unstack和pivot将融化的 NLS 数据框恢复到其原始状态:
-
导入
pandas并加载堆叠和融化后的 NLS 数据:import pandas as pd nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) nls97.set_index(['originalid'], inplace=True) -
再次堆叠数据。
这重复了本章早期食谱中的堆叠操作:
weeksworkedcols = ['weeksworked17','weeksworked18',
'weeksworked19','weeksworked20','weeksworked21']
weeksworkedstacked = nls97[weeksworkedcols].\
stack()
weeksworkedstacked.loc[[2,3]]
originalid
2 weeksworked17 52
weeksworked18 52
weeksworked19 52
weeksworked20 52
weeksworked21 46
3 weeksworked17 52
weeksworked18 52
weeksworked19 9
weeksworked20 0
weeksworked21 0
dtype: float64
- 再次融化数据。
这重复了本章早期食谱中的melt操作:
weeksworkedmelted = nls97.reset_index().\
... loc[:,['originalid'] + weeksworkedcols].\
... melt(id_vars=['originalid'],
... value_vars=weeksworkedcols,
... var_name='year', value_name='weeksworked')
weeksworkedmelted.loc[weeksworkedmelted.\
originalid.isin([2,3])].\
sort_values(['originalid','year'])
originalid year weeksworked
1 2 weeksworked17 52
8985 2 weeksworked18 52
17969 2 weeksworked19 52
26953 2 weeksworked20 52
35937 2 weeksworked21 46
2 3 weeksworked17 52
8986 3 weeksworked18 52
17970 3 weeksworked19 9
26954 3 weeksworked20 0
35938 3 weeksworked21 0
-
使用
unstack将堆叠的数据从长格式转换为宽格式:weeksworked = weeksworkedstacked.unstack() weeksworked.loc[[2,3]].Toriginalid 2 3 weeksworked17 52 52 weeksworked18 52 52 weeksworked19 52 9 weeksworked20 52 0 weeksworked21 46 0 -
使用
pivot将融化的数据从长格式转换为宽格式。
pivot比unstack稍微复杂一点。我们需要传递参数来执行 melt 的反向操作,告诉 pivot 使用哪个列作为列名后缀(year),并从哪里获取要取消融化的值(在本例中来自weeksworked列):
weeksworked = weeksworkedmelted.\
... pivot(index='originalid',
... columns='year', values=['weeksworked']).\
... reset_index()
weeksworked.columns = ['originalid'] + \
... [col[1] for col in weeksworked.columns[1:]]
weeksworked.loc[weeksworked.originalid.isin([2,3])].T
1 2
originalid 2 3
weeksworked17 52 52
weeksworked18 52 52
weeksworked19 52 9
weeksworked20 52 0
weeksworked21 46 0
这将 NLS 数据返回到其原始的无序形式。
它是如何工作的……
我们首先在步骤 2和步骤 3分别执行stack和melt。这将数据框从宽格式转换为长格式。然后我们使用unstack(步骤 4)和pivot(步骤 5)将数据框从长格式转换回宽格式。
unstack使用由stack创建的多重索引来确定如何旋转数据。
pivot函数需要我们指定索引列(originalid),将附加到列名中的列(year),以及包含要取消融化值的列名称(weeksworked)。pivot将返回多级列名。我们通过从第二级提取[col[1] for col in weeksworked.columns[1:]]来修复这个问题。
总结
本章中我们探讨了关键的 tidy 数据主题。这些主题包括处理重复数据,可以通过删除冗余数据的行或按组聚合来处理。我们还将以多对多格式存储的数据重构为 tidy 格式。最后,我们介绍了将数据从宽格式转换为长格式的几种方法,并在必要时将其转换回宽格式。接下来是本书的最后一章,我们将学习如何使用用户定义的函数、类和管道来自动化数据清理。
加入我们的社区,参与 Discord 讨论
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十二章:使用用户定义的函数、类和管道来自动化数据清理
编写可重用的代码有很多很好的理由。当我们从当前的数据清理问题中退一步,考虑它与非常相似的问题的关系时,实际上能够帮助我们加深对关键问题的理解。当我们将目光投向长期解决方案而非短期解决方案时,也更有可能以系统化的方式处理任务。这还带来了一个额外的好处,即帮助我们将数据处理的实质问题与操作数据的机制分开。
在本章中,我们将创建多个模块来完成常规的数据清理任务。这些模块中的函数和类是可以跨 DataFrame 或针对一个 DataFrame 在较长时间内重用的代码示例。这些函数处理了我们在前十一章中讨论的许多任务,但方式是允许我们重用代码的。
本章中的教程具体包括以下内容:
-
获取数据初步信息的函数
-
显示汇总统计信息和频率的函数
-
识别异常值和意外值的函数
-
聚合或合并数据的函数
-
包含更新 Series 值逻辑的类
-
处理非表格数据结构的类
-
检查整体数据质量的函数
-
使用管道进行数据预处理:一个简单的例子
-
使用管道进行数据预处理:一个更复杂的例子
技术要求
完成本章中的教程,你将需要 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码也能在 pandas 1.5.3 或更高版本上运行。
本章中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition。
获取数据初步信息的函数
我们在将数据导入 pandas DataFrame 后,所进行的最初几步操作基本上是相同的,不论数据的特点如何。我们几乎总是想知道列数和行数、列的数据类型,并查看前几行数据。我们也可能想查看索引,并检查是否存在唯一标识符来区分 DataFrame 的行。这些离散且容易重复的任务是很好的候选项,可以将其组织成一个模块中的函数。
在这个教程中,我们将创建一个包含函数的模块,这些函数可以让我们快速了解任何 pandas DataFrame 的基本情况。模块只是一个 Python 代码的集合,我们可以将其导入到其他 Python 程序中。模块便于重用,因为任何能够访问存储模块文件夹的程序都可以引用它。
准备工作
在这个示例中,我们创建两个文件:一个包含我们将用来查看数据的函数,另一个用来调用该函数。让我们将包含我们将使用的函数的文件命名为 basicdesciptives.py,并将其放在名为 helperfunctions 的子文件夹中。
在这个示例中,我们使用国家纵向调查(NLS)数据。
数据注释
NLS 由美国劳工统计局管理,是一个关于在 1997 年高中毕业的个人的纵向调查集合。参与者每年接受调查直到 2023 年。这些调查可供公众在 nlsinfo.org 上使用。
如何做...
我们将创建一个函数来初步查看 DataFrame。
- 创建包含我们想要的函数的
basicdescriptives.py文件。
getfirstlook 函数将返回一个包含 DataFrame 摘要信息的字典。将文件保存在 helperfunctions 子文件夹中,文件名为 basicdescriptives.py。(你也可以直接从 GitHub 仓库下载代码。)另外,创建一个函数(displaydict)来美化字典的显示:
import pandas as pd
def getfirstlook(df, nrows=5, uniqueids=None):
... out = {}
... out['head'] = df.head(nrows)
... out['dtypes'] = df.dtypes
... out['nrows'] = df.shape[0]
... out['ncols'] = df.shape[1]
... out['index'] = df.index
... if (uniqueids is not None):
... out['uniqueids'] = df[uniqueids].nunique()
... return out
def displaydict(dicttodisplay):
... print(*(': '.join(map(str, x)) \
... for x in dicttodisplay.items()), sep='\n\n')
- 创建一个单独的文件
firstlook.py,用来调用getfirstlook函数。
导入 pandas、os 和 sys 库,并加载 NLS 数据:
import pandas as pd
import os
import sys
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True)
- 导入
basicdescriptives模块。
首先将 helperfunctions 子文件夹添加到 Python 路径中。然后我们可以导入 basicdescriptives。我们使用与要导入的模块文件名相同的名称。我们创建一个别名 bd,以便稍后更容易访问模块中的函数。(如果我们需要重新加载 basicdescriptives,可以使用 importlib,这里被注释掉,因为我们在该模块中进行了一些更改。)
sys.path.append(os.getcwd() + "/helperfunctions")
import basicdescriptives as bd
# import importlib
# importlib.reload(bd)
- 首先查看 NLS 数据。
我们可以将 DataFrame 直接传递给 basicdescriptives 模块中的 getfirstlook 函数,以快速摘要 NLS 数据。displaydict 函数为我们提供了字典的更漂亮打印:
dfinfo = bd.getfirstlook(nls97)
bd.displaydict(dfinfo)
head: gender birthmonth birthyear ... parentincome \
personid ...
135335 Female 9 1981 ... -3
999406 Male 7 1982 ... -4
151672 Female 9 1983 ... 63000
750699 Female 2 1981 ... 11700
781297 Male 10 1982 ... -3
fatherhighgrade motherhighgrade
personid
135335 16 8
999406 17 15
151672 -3 12
750699 12 12
781297 12 12
[5 rows x 110 columns]
dtypes: gender object
birthmonth int64
birthyear int64
sampletype object
ethnicity object
originalid int64
motherage int64
parentincome int64
fatherhighgrade int64
motherhighgrade int64
Length: 110, dtype: object
nrows: 8984
ncols: 110
index: Index([135335, 999406, 151672, 750699, 781297, 613800, 403743,
474817, 530234, 351406,
...
290800, 209909, 756325, 543646, 411195, 505861, 368078,
215605, 643085, 713757],
dtype='int64', name='personid', length=8984)
- 将值传递给
getfirstlook的nrows和uniqueids参数。
这两个参数默认值分别为 5 和 None,除非我们提供值:
dfinfo = bd.getfirstlook(nls97,2,'originalid')
bd.displaydict(dfinfo)
head: gender birthmonth birthyear ... parentincome \
personid ...
135335 Female 9 1981 ... -3
999406 Male 7 1982 ... -4
fatherhighgrade motherhighgrade
personid
135335 16 8
999406 17 15
[2 rows x 110 columns]
dtypes: gender object
birthmonth int64
birthyear int64
sampletype object
ethnicity object
originalid int64
motherage int64
parentincome int64
fatherhighgrade int64
motherhighgrade int64
Length: 110, dtype: object
nrows: 8984
ncols: 110
index: Index([135335, 999406, 151672, 750699, 781297, 613800, 403743,
474817, 530234, 351406,
...
290800, 209909, 756325, 543646, 411195, 505861, 368078,
215605, 643085, 713757],
dtype='int64', name='personid', length=8984)
uniqueids: 8984
- 使用一些返回的字典键和值。
我们还可以显示从 getfirstlook 返回的字典中选择的关键值。显示行数和数据类型,并检查每行是否具有 uniqueid 实例(dfinfo['nrows'] == dfinfo['uniqueids']):
dfinfo['nrows']
8984
dfinfo['dtypes']
gender object
birthmonth int64
birthyear int64
sampletype object
ethnicity object
originalid int64
motherage int64
parentincome int64
fatherhighgrade int64
motherhighgrade int64
Length: 110, dtype: object
dfinfo['nrows'] == dfinfo['uniqueids']
True
让我们更仔细地看看函数的工作原理以及如何调用它。
工作原理...
在这个示例中,几乎所有的操作都在 getfirstlook 函数中,我们将在步骤 1中查看。我们将 getfirstlook 函数放在一个单独的文件中,命名为 basicdescriptives.py,我们可以将其作为模块导入,名称为该名称(去掉扩展名)。
我们本可以将函数键入到我们正在使用的文件中,并从那里调用它。但是,将其放入一个模块中,我们可以从任何具有对模块保存的文件夹的访问权限的文件中调用它。当我们在步骤 3中导入 basicdescriptives 模块时,我们加载了 basicdescriptives 中的所有代码,从而可以调用该模块中的所有函数。
getfirstlook 函数返回一个关于传递给它的 DataFrame 的有用信息的字典。我们看到前五行、列数和行数、数据类型和索引。通过向 uniqueid 参数传递一个值,我们还可以得到该列的唯一值数。
通过添加具有默认值的关键字参数(nrows 和 uniqueid),我们提高了 getfirstlook 的灵活性,而不会增加在不需要额外功能时调用函数所需的工作量。
在第一次调用中,在步骤 4中,我们没有为 nrows 或 uniqueid 传递值,保持默认值。在步骤 5中,我们指示只显示两行,并且要检查 originalid 的唯一值。
还有更多...
这个示例及其后续示例的重点不是提供可以下载并在自己的数据上运行的代码,尽管您当然可以这样做。我主要是想演示如何将您喜欢的数据清理方法收集到方便的模块中,以及如何通过这种方式实现轻松的代码重用。这里的具体代码只是一种供参考的建议。
每当我们使用位置参数和关键字参数的组合时,位置参数必须首先出现。
用于显示摘要统计和频率的函数
在与 DataFrame 工作的头几天,我们尝试对连续变量的分布和分类变量的计数有一个良好的了解。我们经常按选定的组进行计数。虽然 pandas 和 NumPy 有许多内置方法用于这些目的——describe、mean、valuecounts、crosstab 等等——数据分析师通常对如何使用这些工具有自己的偏好。例如,如果分析师发现他们通常需要看到比describe生成的更多的百分位数,他们可以使用自己的函数代替。我们将在这个示例中创建用于显示摘要统计和频率的用户定义函数。
准备工作
在这个示例中,我们将再次使用 basicdescriptives 模块。我们将定义的所有函数都保存在该模块中。我们将继续使用 NLS 数据。
如何做...
我们将使用我们创建的函数生成摘要统计和计数:
- 在
basicdescriptives模块中创建gettots函数。
该函数接受一个 pandas DataFrame,并创建一个包含选定摘要统计的字典。它返回一个 pandas DataFrame:
def gettots(df):
... out = {}
... out['min'] = df.min()
... out['per15'] = df.quantile(0.15)
... out['qr1'] = df.quantile(0.25)
... out['med'] = df.median()
... out['qr3'] = df.quantile(0.75)
... out['per85'] = df.quantile(0.85)
... out['max'] = df.max()
... out['count'] = df.count()
... out['mean'] = df.mean()
... out['iqr'] = out['qr3']-out['qr1']
... return pd.DataFrame(out)
- 导入
pandas、os和sys库。
从一个不同的文件中执行此操作,您可以将其命名为 taking_measure.py:
import pandas as pd
import os
import sys
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True)
-
导入
basicdescriptives模块:sys.path.append(os.getcwd() + "/helperfunctions") import basicdescriptives as bd -
显示连续变量的汇总统计。
使用我们在步骤 1中创建的basicdescriptives模块中的gettots函数:
bd.gettots(nls97[['satverbal','satmath']]).T
satverbal satmath
min 14 7
per15 390 390
qr1 430 430
med 500 500
qr3 570 580
per85 620 621
max 800 800
count 1,406 1,407
mean 500 501
iqr 140 150
bd.gettots(nls97.filter(like="weeksworked"))
min per15 qr1 ... count mean iqr
weeksworked00 0 0 5 ... 8626 26 45
weeksworked01 0 0 10 ... 8591 30 41
weeksworked02 0 0 13 ... 8591 32 39
weeksworked03 0 0 14 ... 8535 34 38
weeksworked04 0 1 17 ... 8513 35 35
weeksworked05 0 5 22 ... 8468 37 31
weeksworked06 0 9 27 ... 8419 38 25
weeksworked07 0 10 30 ... 8360 39 22
weeksworked08 0 9 30 ... 8292 39 22
weeksworked09 0 0 22 ... 8267 38 30
weeksworked10 0 0 21 ... 8198 37 31
weeksworked11 0 0 22 ... 8123 38 31
weeksworked12 0 0 23 ... 7988 38 29
weeksworked13 0 0 28 ... 7942 39 24
weeksworked14 0 0 26 ... 7896 39 26
weeksworked15 0 0 33 ... 7767 40 19
weeksworked16 0 0 31 ... 7654 40 22
weeksworked17 0 0 38 ... 7496 40 14
weeksworked18 0 0 35 ... 7435 40 17
weeksworked19 0 4 42 ... 7237 41 10
weeksworked20 0 0 21 ... 6971 38 31
weeksworked21 0 0 35 ... 6627 36 15
weeksworked22 0 0 2 ... 2202 11 17
[23 rows x 10 columns]
- 创建一个函数,通过列和行来计算缺失值的数量。
getmissings函数将接受一个 DataFrame 和一个显示百分比或计数的参数。它返回两个 Series,一个显示每列的缺失值,另一个显示每行的缺失值。将该函数保存到basicdescriptives模块中:
def getmissings(df, byrowperc=False):
return df.isnull().sum(),\
df.isnull().sum(axis=1).\
value_counts(normalize=byrowperc).\
sort_index()
- 调用
getmissings函数。
首先调用它,将byrowperc(第二个参数)设置为True。这样可以显示每行缺失值数量的百分比。例如,missingbyrows值显示weeksworked20和weeksworked21的 73%的行没有缺失值。然后再次调用它,保持byrowperc为默认值False,以获取计数:
missingsbycols, missingsbyrows = \
bd.getmissings(nls97[['weeksworked20',
'weeksworked21']], True)
missingsbycols
weeksworked20 2013
weeksworked21 2357
dtype: int64
missingsbyrows
0 0.73
1 0.05
2 0.22
Name: proportion, dtype: float64
missingsbycols, missingsbyrows = \
bd.getmissings(nls97[['weeksworked20',
'weeksworked21']])
missingsbyrows
0 6594
1 410
2 1980
Name: count, dtype: int64
- 创建一个函数,计算所有类别变量的频率。
makefreqs函数遍历传递的 DataFrame 中所有类别数据类型的列,并对每一列运行value_counts。频率将保存到由outfile指定的文件中:
def makefreqs(df, outfile):
... freqout = open(outfile, 'w')
... for col in df.\
... select_dtypes(include=["category"]):
... print(col, "----------------------",
... "frequencies",
... df[col].value_counts().sort_index(),
... "percentages",
... df[col].value_counts(normalize=True).\
... sort_index(),
... sep="\n\n", end="\n\n\n",
... file=freqout)
... freqout.close()
- 调用
makefreqs函数。
首先,将每个对象列的数据类型更改为category。此调用会对 NLS DataFrame 中的类别数据列运行value_counts,并将频率保存到当前文件夹中的views子文件夹下的nlsfreqs.txt文件中。
nls97.loc[:, nls97.dtypes == 'object'] = \
... nls97.select_dtypes(['object']). \
... apply(lambda x: x.astype('category'))
bd.makefreqs(nls97, "views/nlsfreqs.txt")
- 创建一个函数,根据组来获取计数。
getcnts函数计算cats(一个列名列表)中每一组合列值的行数。它还计算排除cats中最后一列后的每一组合列值的行数。这将提供最后一列所有值的总数。(接下来的步骤会展示它的效果。)
def getcnts(df, cats, rowsel=None):
... tots = cats[:-1]
... catcnt = df.groupby(cats, dropna=False).size().\
... reset_index(name='catcnt')
... totcnt = df.groupby(tots, dropna=False).size().\
... reset_index(name='totcnt')
... percs = pd.merge(catcnt, totcnt, left_on=tots,
... right_on=tots, how="left")
... percs['percent'] = percs.catcnt / percs.totcnt
... if (rowsel is not None):
... percs = percs.loc[eval("percs." + rowsel)]
... return percs
- 将
maritalstatus和colenroct00列传递给getcnts函数。
这将返回一个 DataFrame,包含每个列值组合的计数,以及排除最后一列后的所有列值组合的计数。此结果用于计算组内的百分比。例如,有 669 名受访者已离婚,其中 560 人(即 84%)在 2000 年 10 月时没有入学:
bd.getcnts(nls97,
['maritalstatus','colenroct00'])
maritalstatus colenroct00 catcnt totcnt percent
0 Divorced 1\. Not enrolled 560 669 0.84
1 Divorced 2\. 2-year college 50 669 0.07
2 Divorced 3\. 4-year college 59 669 0.09
3 Married 1\. Not enrolled 2264 3068 0.74
4 Married 2\. 2-year college 236 3068 0.08
5 Married 3\. 4-year college 568 3068 0.19
6 Never-married 1\. Not enrolled 2363 2767 0.85
7 Never-married 2\. 2-year college 131 2767 0.05
8 Never-married 3\. 4-year college 273 2767 0.10
9 Separated 1\. Not enrolled 127 148 0.86
10 Separated 2\. 2-year college 13 148 0.09
11 Separated 3\. 4-year college 8 148 0.05
12 Widowed 1\. Not enrolled 19 23 0.83
13 Widowed 2\. 2-year college 1 23 0.04
14 Widowed 3\. 4-year college 3 23 0.13
15 NaN 1\. Not enrolled 1745 2309 0.76
16 NaN 2\. 2-year college 153 2309 0.07
17 NaN 3\. 4-year college 261 2309 0.11
18 NaN NaN 150 2309 0.06
-
使用
getcnts的rowsel参数限制输出为特定行。这将仅显示未入学的行:bd.getcnts(nls97, ['maritalstatus','colenroct20'], "colenroct20.str[0:1]=='1'")maritalstatus colenroct00 catcnt totcnt percent 0 Divorced 1\. Not enrolled 560 669 0.84 3 Married 1\. Not enrolled 2264 3068 0.74 6 Never-married 1\. Not enrolled 2363 2767 0.85 9 Separated 1\. Not enrolled 127 148 0.86 12 Widowed 1\. Not enrolled 19 23 0.83 15 NaN 1\. Not enrolled 1745 2309 0.76
这些步骤展示了如何创建函数并使用它们生成汇总统计和频率。
它是如何工作的...
在步骤 1中,我们创建了一个名为gettots的函数,用于计算 DataFrame 中所有列的描述性统计,并将这些结果返回为一个汇总的 DataFrame。大多数统计量可以通过describe方法生成,但我们添加了一些额外的统计量——第 15 百分位数、第 85 百分位数和四分位距。我们在步骤 4中调用了这个函数两次,第一次是针对 SAT 语言和数学成绩,第二次是针对所有工作周数的列。
步骤 5 和 步骤 6 创建并调用一个函数,显示传递给 DataFrame 的每列的缺失值数量。该函数还计算每行的缺失值数量,并显示缺失值的频率。通过将True传递给byrowperc参数,还可以将每行的缺失值频率显示为所有行的百分比。
步骤 7 和 步骤 8 会生成一个文本文件,其中包含传递给 DataFrame 的所有分类变量的频率。我们只需循环遍历所有类别数据类型的列,并运行value_counts。由于输出通常很长,我们将其保存到文件中。将频率保存在某处以供以后参考也是很有用的。
我们在 步骤 9 中创建并在 步骤 10 和 步骤 11 中调用的getcnts函数有点特殊。pandas 有一个非常有用的crosstab函数,我经常使用。但我经常需要一种简单的方法来查看组内子组的组计数和百分比。getcnts函数可以做到这一点。
还有更多...
即使一个函数没有做很多事情,它也可能非常有帮助。getmissings函数中的代码并不多,但我经常检查缺失值,所以小小的时间节省在累积起来时是显著的。它还提醒我按列和按行检查缺失值。
另请参阅
我们将在 第三章,测量数据中探索 pandas 用于生成摘要统计信息和频率的工具。
用于识别异常值和意外值的函数
如果我必须选择一个数据清理领域,我发现可重复使用的代码最有益处的领域,那就是识别异常值和意外值。这是因为我们的先前假设通常导致我们关注分布的中心趋势,而不是极端值。快速想象一只猫——除非你在想你生活中的某只特定猫,否则你脑海中可能会浮现一只体重在 8 到 10 磅之间的普通猫;而不是一只体重为 6 磅或 22 磅的猫。
我们经常需要更有意识地提升极端值到意识中。在这里,拥有一套标准的诊断函数来运行我们的数据非常有帮助。即使没有特别的触发条件,我们也可以运行这些函数。这个示例提供了我们可以定期使用的函数示例,用于识别异常值和意外值。
准备工作
在这个示例中,我们将创建两个文件,一个包含我们将用来检查异常值的函数,另一个包含我们将用来调用这些函数的代码。让我们把包含我们将使用的函数的文件命名为outliers.py,并将其放在一个名为helperfunctions的子文件夹中。
为了运行本示例中的代码,你需要matplotlib和scipy库,除了 pandas。你可以通过在终端客户端或 Windows PowerShell 中输入pip install matplotlib和pip install scipy来安装matplotlib和scipy。你还需要pprint实用程序,你可以通过pip install pprint来安装。
在本示例中,我们将处理 NLS 和 COVID-19 数据。COVID-19 数据中每一行代表一个国家,包含该国的累计病例和死亡数。
数据说明
Our World in Data 提供了 COVID-19 公共使用数据,网址:ourworldindata.org/covid-cases。该数据集包括各国的累计病例和死亡数、已进行的测试、医院床位以及一些人口统计数据,如中位年龄、国内生产总值和糖尿病患病率。本示例使用的数据集是在 2024 年 3 月 3 日下载的。
如何操作...
我们创建并调用函数来检查变量的分布、列出极端值并可视化分布:
- 导入
pandas、os、sys和pprint库。
同时,加载 NLS 和 COVID-19 数据:
import pandas as pd
import os
import sys
import pprint
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True)
covidtotals = pd.read_csv("data/covidtotals.csv")
- 创建一个函数来展示分布的一些重要属性。
getdistprops函数接受一个 Series 并生成中心趋势、形状和分布的度量。该函数返回一个包含这些度量的字典。它还处理 Shapiro 正态性检验未返回值的情况。若发生这种情况,将不会为normstat和normpvalue添加键。将该函数保存在当前目录下helperfunctions子文件夹中的一个名为outliers.py的文件中。(同时加载我们在此模块中其他函数需要的pandas、matplotlib、scipy和math库。)
import pandas as pd
import matplotlib.pyplot as plt
import scipy.stats as scistat
import math
def getdistprops(seriestotest):
... out = {}
... normstat, normpvalue = scistat.shapiro(seriestotest)
... if (not math.isnan(normstat)):
... out['normstat'] = normstat
... if (normpvalue>=0.05):
... out['normpvalue'] = str(round(normpvalue, 2)) + ": Accept Normal"
... elif (normpvalue<0.05):
... out['normpvalue'] = str(round(normpvalue, 2)) + ": Reject Normal"
... out['mean'] = seriestotest.mean()
... out['median'] = seriestotest.median()
... out['std'] = seriestotest.std()
... out['kurtosis'] = seriestotest.kurtosis()
... out['skew'] = seriestotest.skew()
... out['count'] = seriestotest.count()
... return out
- 将每百万总病例数的 Series 传递给
getdistprops函数。
skew和kurtosis值表明total_cases_pm的分布有正偏态且尾部比正态分布的变量更短。Shapiro 正态性检验(normpvalue)证实了这一点。(使用pprint改进getdistprops返回的字典的显示效果。)
dist = ol.getdistprops(covidtotals.total_cases_pm)
pprint.pprint(dist)
{'count': 231,
'kurtosis': -0.4280595203351645,
'mean': 206177.79462337663,
'median': 133946.251,
'normpvalue': '0.0: Reject Normal',
'normstat': 0.8750641345977783,
'skew': 0.8349032460009967,
'std': 203858.09625231632}
- 创建一个函数来列出 DataFrame 中的异常值。
getoutliers函数遍历sumvars中的所有列。它为这些列确定异常值阈值,设置阈值为第一四分位数下方或第三四分位数上方 1.5 倍四分位间距的值。然后,它选择所有超出高阈值或低阈值的行。它还会添加表示检查的变量(varname)的列以及阈值水平的列。它还会包括othervars列表中的列,并将其返回为 DataFrame:
def getoutliers(dfin, sumvars, othervars):
... dfin = dfin[sumvars + othervars]
... dfout = pd.DataFrame(columns=dfin.columns, data=None)
... dfsums = dfin[sumvars]
... for col in dfsums.columns:
... thirdq, firstq = dfsums[col].quantile(0.75),\
... dfsums[col].quantile(0.25)
... interquartilerange = 1.5*(thirdq-firstq)
... outlierhigh, outlierlow = interquartilerange+thirdq,\
... firstq-interquartilerange
... df = dfin.loc[(dfin[col]>outlierhigh) | \
... (dfin[col]<outlierlow)]
... df = df.assign(varname = col, threshlow = outlierlow,\
... threshhigh = outlierhigh)
... dfout = pd.concat([dfout, df])
... return dfout
- 调用
getoutlier函数。
将检查异常值的列的列表(sumvars)和要包含在返回的 DataFrame 中的列的另一个列表(othervars)传递给函数。展示每个变量的异常值计数,并查看 SAT 数学的异常值:
sumvars = ['satmath','wageincome20']
othervars = ['originalid','highestdegree','gender','maritalstatus']
outliers = ol.getoutliers(nls97, sumvars, othervars)
outliers.varname.value_counts(sort=False)
varname
satmath 10
wageincome20 234
Name: count, dtype: int64
outliers.loc[outliers.varname=='satmath', othervars + sumvars]
originalid highestdegree ... satmath wageincome20
337438 159 2\. High School ... 200.00 32,000.00
448463 326 4\. Bachelors ... 47.00 NaN
799095 535 5\. Masters ... 59.00 NaN
267254 1622 2\. High School ... 48.00 NaN
955430 2547 2\. High School ... 200.00 NaN
748274 3394 4\. Bachelors ... 42.00 NaN
399109 3883 2\. High School ... 36.00 37,000.00
223058 6696 0\. None ... 46.00 NaN
291029 7088 2\. High School ... 51.00 NaN
738290 7705 2\. High School ... 7.00 50,000.00
[10 rows x 6 columns]
outliers.to_excel("views/nlsoutliers.xlsx")
- 创建一个生成直方图和箱型图的函数。
makeplot函数接受一个 Series、标题和x-轴的标签。默认绘图类型为直方图:
def makeplot(seriestoplot, title, xlabel, plottype="hist"):
... if (plottype=="hist"):
... plt.hist(seriestoplot)
... plt.axvline(seriestoplot.mean(), color='red',\
... linestyle='dashed', linewidth=1)
... plt.xlabel(xlabel)
... plt.ylabel("Frequency")
... elif (plottype=="box"):
... plt.boxplot(seriestoplot.dropna(), labels=[xlabel])
... plt.title(title)
... plt.show()
-
调用
makeplot函数来创建直方图:ol.makeplot(nls97.satmath, "Histogram of SAT Math", "SAT Math")
这将生成以下直方图:

图 12.1:SAT 数学值的频率分布
-
使用
makeplot函数创建一个箱线图:ol.makeplot(nls97.satmath, "Boxplot of SAT Math", "SAT Math", "box")
这将生成以下箱线图:

图 12.2:使用箱线图显示中位数、四分位距和异常值阈值
前面的步骤展示了我们如何开发可重复使用的代码来检查异常值和意外值。
工作原理...
我们首先通过将 Series 传递给步骤 3中的getdistprop函数来获取分布的关键属性,包括均值、中位数、标准差、偏度和峰度。我们得到一个包含这些度量值的字典。
步骤 4中的函数选择了sumvars中某一列具有异常值的行。它还包括了othervars列的值和返回的 DataFrame 中的阈值金额。
我们在步骤 6中创建了一个函数,使得创建简单直方图或箱线图变得更加容易。matplotlib的功能很强大,但是当我们只想创建一个简单的直方图或箱线图时,可能需要花一点时间来回想语法。我们可以通过定义一个带有几个常规参数的函数来避免这种情况:Series、标题和x-label。我们在步骤 7和8中调用该函数。
还有更多...
在对连续变量进行过多处理之前,我们需要先了解其数值分布情况;中心趋势和分布形状是什么?如果我们对关键连续变量运行类似本示例中的函数,那么我们就会有一个良好的起点。
Python 模块的相对轻松可移植性使得这变得相当容易。如果我们想使用本示例中使用的outliers模块,只需将outliers.py文件保存到我们的程序可以访问的文件夹中,将该文件夹添加到 Python 路径中,并导入它。
通常,当我们检查极端值时,我们希望更好地了解其他变量的背景,这些变量可能解释为什么该值是极端的。例如,178 厘米的身高对于成年男性来说不是异常值,但对于 9 岁的孩子来说绝对是异常值。步骤 4和5生成的 DataFrame 为我们提供了异常值以及可能相关的其他数据。将数据保存到 Excel 文件中使得以后检查异常行或与他人分享数据变得更加容易。
另请参阅
我们在第四章中详细讨论了如何检测异常值和意外值,在数据子集中识别异常值。我们在第五章中研究了直方图、箱线图和许多其他可视化方法,使用可视化方法识别意外值。
用于聚合或合并数据的函数
大多数数据分析项目都需要对数据进行某种形状的调整。我们可能需要按组聚合数据,或者纵向或横向合并数据。在准备数据以进行这些形状调整时,我们每次都会进行类似的任务。我们可以通过函数将其中一些任务标准化,从而提高代码的可靠性和完成工作的效率。有时我们需要在合并之前检查按值合并的列是否匹配,检查面板数据在一个周期到下一个周期之间的值是否发生了意外变化,或者一次性连接多个文件并验证数据是否已正确合并。
这些只是数据聚合和组合任务的一些示例,这些任务可能更适合使用更通用的编码解决方案。在这个示例中,我们定义了可以帮助完成这些任务的函数。
准备工作
在这个示例中,我们将使用 COVID-19 每日数据。该数据包括按天计算的每个国家的新病例和新死亡人数。我们还将使用 2023 年多个国家的土地温度数据。每个国家的数据在单独的文件中,并且每个月的每个气象站有一行数据。
数据说明
土地温度数据框包含来自全球超过 12,000 个站点的 2023 年平均温度数据(单位:°C),尽管大多数站点位于美国。原始数据来自全球历史气候学网络集成数据库。美国国家海洋和大气管理局在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly上为公众提供了这些数据。
如何实现...
我们将使用函数来聚合数据,纵向合并数据,并检查按值合并:
-
导入
pandas、os和sys库:import pandas as pd import os import sys -
创建一个函数(
adjmeans)来按组汇总每个周期的值。
按组(byvar)和然后按period对传入的数据框的值进行排序。将数据框的值转换为 NumPy 数组。循环遍历这些值,按var列做一个累加,并在遇到byvar的新值时将累加值重置为 0。在进行聚合之前,检查每个周期之间值的极端变化。changeexclude参数表示从一个周期到下一个周期应该视为极端变化的大小。excludetype参数表示changeexclude值是var列均值的绝对值还是百分比。在helperfunctions子文件夹中将该函数保存在名为combineagg.py的文件中:
def adjmeans(df, byvar, var, period, changeexclude=None, excludetype=None):
... df = df.sort_values([byvar, period])
... df = df.dropna(subset=[var])
... # iterate using numpy arrays
... prevbyvar = 'ZZZ'
... prevvarvalue = 0
... rowlist = []
... varvalues = df[[byvar, var]].values
... # convert exclusion ratio to absolute number
... if (excludetype=="ratio" and changeexclude is not None):
... changeexclude = df[var].mean()*changeexclude
... # loop through variable values
... for j in range(len(varvalues)):
... byvar = varvalues[j][0]
... varvalue = varvalues[j][1]
... if (prevbyvar!=byvar):
... if (prevbyvar!='ZZZ'):
... rowlist.append({'byvar':prevbyvar, 'avgvar':varsum/byvarcnt,\
... 'sumvar':varsum, 'byvarcnt':byvarcnt})
... varsum = 0
... byvarcnt = 0
... prevbyvar = byvar
... # exclude extreme changes in variable value
... if ((changeexclude is None) or (0 <= abs(varvalue-prevvarvalue) \
... <= changeexclude) or (byvarcnt==0)):
... varsum += varvalue
... byvarcnt += 1
... prevvarvalue = varvalue
... rowlist.append({'byvar':prevbyvar, 'avgvar':varsum/byvarcnt, \
... 'sumvar':varsum, 'byvarcnt':byvarcnt})
... return pd.DataFrame(rowlist)
-
导入
combineagg模块:sys.path.append(os.getcwd() + "/helperfunctions") import combineagg as ca -
加载数据框(DataFrames):
coviddaily = pd.read_csv("data/coviddaily.csv") ltbrazil = pd.read_csv("data/ltbrazil.csv") countries = pd.read_csv("data/ltcountries.csv") locations = pd.read_csv("data/ltlocations.csv") -
调用
adjmeans函数按组和时间周期汇总面板数据。
指明我们希望按location对new_cases进行汇总:
ca.adjmeans(coviddaily, 'location','new_cases','casedate')
byvar avgvar sumvar byvarcnt
0 Afghanistan 1,129 231,539 205
1 Albania 1,914 334,863 175
2 Algeria 1,439 272,010 189
3 American Samoa 144 8,359 58
4 Andorra 304 48,015 158
.. ... ... ... ...
226 Vietnam 60,542 11,624,000 192
227 Wallis and Futuna 154 3,550 23
228 Yemen 98 11,945 122
229 Zambia 2,019 349,304 173
230 Zimbabwe 1,358 266,266 196
[231 rows x 4 columns]
-
再次调用
adjmeans函数,这次排除new_cases从一天到下一天的变化超过 5,000 的值。注意,一些国家的计数有所减少:ca.adjmeans(coviddaily, 'location','new_cases','casedate', 5000)byvar avgvar sumvar byvarcnt 0 Afghanistan 1,129 231,539 205 1 Albania 1,855 322,772 174 2 Algeria 1,290 239,896 186 3 American Samoa 144 8,359 58 4 Andorra 304 48,015 158 .. ... ... ... ... 226 Vietnam 6,410 967,910 151 227 Wallis and Futuna 154 3,550 23 228 Yemen 98 11,945 122 229 Zambia 1,555 259,768 167 230 Zimbabwe 1,112 214,526 193 [231 rows x 4 columns] -
创建一个函数来检查一个文件中合并列的值,但在另一个文件中没有这些值。
checkmerge函数对传入的两个 DataFrame 进行外连接,使用第三个和第四个参数作为第一个和第二个 DataFrame 的合并列。然后,它生成一个交叉表,显示在两个 DataFrame 中都存在的合并列值的行数,以及只出现在一个 DataFrame 中而另一个 DataFrame 中不存在的行数。它还会显示最多 20 行只在一个文件中找到的合并列值的数据:
def checkmerge(dfleft, dfright, mergebyleft, mergebyright):
... dfleft['inleft'] = "Y"
... dfright['inright'] = "Y"
... dfboth = pd.merge(dfleft[[mergebyleft,'inleft']],\
... dfright[[mergebyright,'inright']], left_on=[mergebyleft],\
... right_on=[mergebyright], how="outer")
... dfboth.fillna('N', inplace=True)
... print(pd.crosstab(dfboth.inleft, dfboth.inright))
... print(dfboth.loc[(dfboth.inleft=='N') | (dfboth.inright=='N')].head(20))
- 调用
checkmerge函数。
检查countries土地温度 DataFrame(每个国家一行)和locations DataFrame(每个国家每个气象站一行)之间的合并。交叉表显示,27,472 个合并列值同时存在于两个 DataFrame 中,两个值只在countries文件中,而不在locations文件中,一个值只在locations文件中,而不在countries文件中:
ca.checkmerge(countries.copy(), locations.copy(),\
... "countryid", "countryid")
inright N Y
inleft
N 0 1
Y 2 27472
countryid inleft inright
7363 FO N Y
9716 LQ Y N
13104 ST Y N
- 创建一个函数,用于连接文件夹中的所有 CSV 文件。
该函数遍历指定文件夹中的所有文件名。它使用endswith方法检查文件名是否具有 CSV 文件扩展名。然后,它加载 DataFrame 并打印出行数。最后,它使用concat将新 DataFrame 的行追加到已追加的行中。如果文件中的列名不同,它会打印出这些列名:
def addfiles(directory):
... dfout = pd.DataFrame()
... columnsmatched = True
... # loop through the files
... for filename in os.listdir(directory):
... if filename.endswith(".csv"):
... fileloc = os.path.join(directory, filename)
... # open the next file
... with open(fileloc) as f:
... dfnew = pd.read_csv(fileloc)
... print(filename + " has " + str(dfnew.shape[0]) + " rows.")
... dfout = pd.concat([dfout, dfnew])
... # check if current file has any different columns
... columndiff = dfout.columns.symmetric_difference(dfnew.columns)
... if (not columndiff.empty):
... print("", "Different column names for:", filename,\
... columndiff, "", sep="\n")
... columnsmatched = False
... print("Columns Matched:", columnsmatched)
... return dfout
- 使用
addfiles函数连接所有countries土地温度文件。
看起来阿曼(ltoman)的文件稍有不同。它没有latabs列。注意,合并后的 DataFrame 中每个国家的行数与每个国家文件中的行数相匹配:
landtemps = ca.addfiles("data/ltcountry")
ltpoland.csv has 120 rows.
ltcameroon.csv has 48 rows.
ltmexico.csv has 852 rows.
ltjapan.csv has 1800 rows.
ltindia.csv has 1116 rows.
ltoman.csv has 288 rows.
Different column names for:
ltoman.csv
Index(['latabs'], dtype='object')
ltbrazil.csv has 1008 rows.
Columns Matched: False
landtemps.country.value_counts()
country
Japan 1800
India 1116
Brazil 1008
Mexico 852
Oman 288
Poland 120
Cameroon 48
Name: count, dtype: int64
上述步骤演示了我们如何将一些杂乱的数据重塑工作系统化。我相信你可以想到许多其他可能有用的函数。
它是如何工作的……
你可能注意到,在步骤 2中定义的adjmeans函数中,我们实际上并不会在到达下一个byvar列值之前追加var列值的汇总。这是因为,直到我们到达下一个byvar列值时,无法知道我们是否已经到了某个byvar值的最后一行。这不是问题,因为我们会在重置为0之前将汇总追加到rowlist中。这也意味着,我们需要做一些特别的处理来输出最后一个byvar值的总计,因为没有下一个byvar值。这是通过在循环结束后进行最终的追加操作来实现的。
在步骤 5中,我们调用了步骤 2中定义的adjmeans函数。由于我们没有为changeexclude参数设置值,该函数会将所有值包含在聚合中。这将给我们与使用groupby和聚合函数相同的结果。然而,当我们传递一个参数给changeexclude时,我们就能确定从聚合中排除哪些行。在步骤 6中,调用adjmeans时的第五个参数表示我们应该排除新案例值与前一天的值相差超过 5000 个案例的行。
步骤 9中的函数在要合并的数据文件结构相同或几乎相同时效果很好。当列名不同时,我们会打印出警告,正如步骤 10所示。latabs列在阿曼文件中不存在。这意味着在合并后的文件中,阿曼的所有行将缺少latabs这一列。
还有更多...
adjmeans函数在将每个新值加入总数之前会进行相对简单的检查。但我们也可以想象更加复杂的检查。我们甚至可以在adjmeans函数中调用另一个函数,用来决定是否包含某行数据。
另见
我们将在第十章《合并数据框时处理数据问题》中讨论垂直和水平合并 DataFrame。
包含更新 Series 值逻辑的类
我们有时会长时间处理一个特定的数据集,甚至可能是几年。数据可能会定期更新,添加新的月份或年份,或者增加额外的个体,但数据结构可能相对稳定。如果该数据集还包含大量列,我们通过实现类,可能会提高代码的可靠性和可读性。
当我们创建类时,会定义对象的属性和方法。在我的数据清理工作中,我倾向于将类概念化为代表我的分析单元。所以,如果我的分析单元是学生,那么我会创建一个学生类。由该类创建的每个学生实例可能具有出生日期和性别属性,以及课程注册方法。我还可以为校友创建一个子类,继承学生类的方法和属性。
NLS DataFrame 的数据清理可以通过类来很好地实现。该数据集在过去 25 年里相对稳定,无论是变量还是每个变量的允许值。我们在本食谱中探索如何为 NLS 调查响应创建一个 respondent 类。
准备工作
为了运行本食谱中的代码,你需要在当前目录中创建一个名为helperfunctions的子文件夹。我们将把新类的文件(respondent.py)保存在这个子文件夹中。
如何操作...
我们将定义一个 respondent 类,根据 NLS 数据创建几个新的 Series:
- 导入
pandas、os、sys和pprint库。
我们将这段代码存储在一个不同于保存响应者类的文件中。我们将这个文件命名为class_cleaning.py。我们将在这个文件中实例化响应者对象:
import pandas as pd
import os
import sys
import pprint
- 创建一个
Respondent类,并将其保存到helperfunctions子文件夹中的respondent.py文件中。
当我们调用我们的类(实例化类对象)时,__init__方法会自动运行。(init前后都有双下划线。)__init__方法的第一个参数是self,正如任何实例方法一样。此类的__init__方法还有一个respdict参数,它期望一个来自 NLS 数据的字典值。在后续步骤中,我们将为 NLS DataFrame 中的每一行数据实例化一个响应者对象。
__init__方法将传递的respdict值赋给self.respdict,以创建一个实例变量,我们可以在其他方法中引用它。最后,我们递增一个计数器respondentcnt。稍后我们可以用它来确认我们创建的respondent实例的数量。我们还导入了math和datetime模块,因为稍后会需要它们。(请注意,类名通常是大写的。)
import math
import datetime as dt
class Respondent:
... respondentcnt = 0
... def __init__(self, respdict):
... self.respdict = respdict
... Respondent.respondentcnt+=1
- 添加一个方法,用于计算孩子的数量。
这是一个非常简单的方法,它将与响应者同住的孩子数和不与响应者同住的孩子数相加,得到孩子的总数。它使用self.respdict字典中的childathome和childnotathome键值:
def childnum(self):
... return self.respdict['childathome'] + self.respdict['childnotathome']
- 添加一个方法,用于计算调查中 25 年期间的平均工作周数。
使用字典推导式创建一个字典(workdict),其中包含没有缺失值的工作周数键。将workdict中的值相加并除以workdict的长度:
def avgweeksworked(self):
... workdict = {k: v for k, v in self.respdict.items() \
... if k.startswith('weeksworked') and not math.isnan(v)}
... nweeks = len(workdict)
... if (nweeks>0):
... avgww = sum(workdict.values())/nweeks
... else:
... avgww = 0
... return avgww
- 添加一个方法,用于计算某个特定日期的年龄。
此方法采用一个日期字符串(bydatestring)作为年龄计算的结束日期。我们使用datetime模块将date字符串转换为datetime对象bydate。我们从self.respdict中减去出生年份值,并从该计算中减去 1,如果出生日期在该年还没有发生。(我们在 NLS 数据中只有出生月和出生年,所以我们选择 15 作为中间点。)
def ageby(self, bydatestring):
... bydate = dt.datetime.strptime(bydatestring, '%Y%m%d')
... birthyear = self.respdict['birthyear']
... birthmonth = self.respdict['birthmonth']
... age = bydate.year - birthyear
... if (bydate.month<birthmonth or (bydate.month==birthmonth \
... and bydate.day<15)):
... age = age -1
... return age
- 添加一个方法,如果响应者曾经在 4 年制大学注册过,则创建一个标志。
使用字典推导式检查是否有任何大学注册值为 4 年制大学:
def baenrollment(self):
... colenrdict = {k: v for k, v in self.respdict.items() \
... if k.startswith('colenr') and v=="3\. 4-year college"}
... if (len(colenrdict)>0):
... return "Y"
... else:
... return "N"
- 导入响应者类。
现在我们已经准备好实例化一些Respondent对象了!我们从步骤 1开始的class_cleaning.py文件中进行操作。首先,我们导入响应者类。(此步骤假设respondent.py文件位于helperfunctions子文件夹中。)
sys.path.append(os.getcwd() + "/helperfunctions")
import respondent as rp
- 加载 NLS 数据并创建字典列表。
使用to_dict方法创建字典列表(nls97list)。DataFrame 中的每一行将是一个字典,列名作为键。显示第一个字典的一部分(第一行):
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97list = nls97.to_dict('records')
nls97.shape
(8984, 111)
len(nls97list)
8984
pprint.pprint(nls97list[0:1])
[{'birthmonth': 9,
'birthyear': 1981,
'childathome': nan,
'childnotathome': nan,
'colenrfeb00': '3\. 4-year college',
'colenrfeb01': '3\. 4-year college',
...
'weeksworked21': nan,
'weeksworked22': nan}]
- 遍历列表,每次创建一个
respondent实例。
我们将每个字典传递给rp.Respondent(respdict)响应者类。一旦我们创建了一个响应者对象(resp),我们就可以使用所有实例方法来获取我们需要的值。我们使用这些方法返回的值创建一个新的字典,然后将该字典追加到analysisdict中:
analysislist = []
for respdict in nls97list:
... resp = rp.Respondent(respdict)
... newdict = dict(originalid=respdict['originalid'],
... childnum=resp.childnum(),
... avgweeksworked=resp.avgweeksworked(),
... age=resp.ageby('20201015'),
... baenrollment=resp.baenrollment())
... analysislist.append(newdict)
- 将字典传递给 pandas
DataFrame方法。
首先,检查analysislist中的项数和创建的实例数:
len(analysislist)
8984
resp.respondentcnt
8984
pprint.pprint(analysislist[0:2])
[{'age': 39,
'avgweeksworked': 48.4375,
'baenrollment': 'Y',
'childnum': nan,
'originalid': 1},
{'age': 38,
'avgweeksworked': 49.90909090909091,
'baenrollment': 'Y',
'childnum': nan,
'originalid': 2}]
analysis = pd.DataFrame(analysislist)
analysis.head(2)
originalid childnum avgweeksworked age baenrollment
0 1 NaN 48 39 Y
1 2 NaN 50 38 Y
这些步骤展示了如何在 Python 中创建一个类,如何向类传递数据,如何创建类的实例,以及如何调用类的方法来更新变量值。
它是如何工作的...
这个配方中的关键工作在第 2 步中完成。它创建了响应者类,并为剩余的步骤做好了准备。我们将每行的值传递给该类的__init__方法。__init__方法将该字典分配给一个实例变量,该变量对所有类的方法都可用(self.respdict = respdict)。
第 3 步到第 6 步使用该字典来计算子女数量、每年平均工作周数、年龄和大学入学情况。第 4 步和第 6 步展示了字典推导式在我们需要对多个键测试相同值时的有用性。字典推导式选择相关的键(weeksworked##、colenroct##、colenrfeb##),并允许我们检查这些键的值。当我们的数据以这种方式杂乱无章时,这非常有用,正如调查数据常常表现出来的那样。
在第 8 步中,我们使用to_dict方法创建一个字典列表。它包含预期数量的列表项,8,984,与 DataFrame 中的行数相同。我们使用pprint来显示字典在第一个列表项中的样子。该字典具有列名作为键,列值作为值。
我们在第 9 步中遍历列表,创建一个新的响应者对象并传递列表项。我们调用方法来获取我们想要的值,除了originalid,它可以直接从字典中提取。我们用这些值创建一个字典(newdict),并将其追加到列表(analysislist)中。
在第 10 步中,我们从在第 9 步创建的列表(analysislist)中创建一个 pandas DataFrame。我们通过将列表传递给 pandas DataFrame 方法来完成这一操作。
还有更多...
我们传递字典给类,而不是数据行,这也是一种选择。我们这么做是因为,相比于通过 itertuples 或 iterrows 遍历 DataFrame,遍历 NumPy 数组更高效。当我们使用字典而非 DataFrame 行时,我们并没有失去类所需的太多功能。我们依然可以使用诸如 sum 和 mean 等函数,并计算符合特定条件的值的数量。
在这种响应者类的概念化中,很难避免必须遍历数据。这个响应者类与我们对分析单元——调查响应者——的理解是一致的。这也正是数据呈现给我们的方式。但即使是更高效的 NumPy 数组,逐行遍历数据也是资源密集型的。
然而,我认为,在处理具有许多列且结构在时间上变化不大的数据时,通过构建像这样的类,你获得的好处超过了失去的。最重要的优势在于它符合我们对数据的直观理解,并将我们的工作集中在理解每个响应者的数据上。我还认为,当我们构建类时,通常会比否则情况减少很多遍历数据的次数。
另见
我们在 第九章,聚合时修复杂乱数据 中,探讨了如何遍历 DataFrame 行和 NumPy 数组。
这是一篇关于在 Python 中使用类的简要介绍。如果你想深入了解 Python 中的面向对象编程,我推荐 Dusty Phillips 编写的 Python 3 面向对象编程,第三版。
处理非表格数据结构的类
数据科学家越来越多地接收到非表格数据,通常是 JSON 或 XML 文件。JSON 和 XML 的灵活性使得组织能够在一个文件中捕捉数据项之间复杂的关系。在企业数据系统中存储在两个表中的一对多关系,可以通过 JSON 通过一个父节点来表示一方,多个子节点来表示多方数据,来很好地表示。
当我们接收 JSON 数据时,我们通常会尝试对其进行规范化。事实上,在本书的一些实例中,我们就是这么做的。我们尝试恢复由于 JSON 灵活性而混淆的数据中的一对一和一对多关系。但也有另一种处理这种数据的方式,它有许多优势。
我们可以创建一个类,在适当的分析单元上实例化对象,并使用类的方法来导航一对多关系的多个方面,而不是规范化数据。例如,如果我们获取一个包含学生节点的 JSON 文件,并且每个学生所修课程都有多个子节点,我们通常会通过创建一个学生文件和一个课程文件来规范化数据,学生 ID 作为两个文件的合并列。在本例中,我们将探讨另一种方法:保持数据原样,创建一个学生类,并创建方法对子节点进行计算,例如计算总学分。
让我们通过这个食谱来尝试,使用来自克利夫兰艺术博物馆的数据,其中包含了收藏项目、每个项目的一个或多个媒体引文节点,以及每个项目的一个或多个创作者节点。
准备工作
本示例假设你已经安装了requests和pprint库。如果没有安装,可以通过pip进行安装。在终端或 PowerShell(Windows 系统中)输入pip install requests和pip install pprint。
这里展示了使用克利夫兰艺术博物馆(Cleveland Museum of Art)collections API 时创建的 JSON 文件结构(我已将 JSON 文件缩短以节省空间)。
{
"id": 165157,
"title": "Fulton and Nostrand",
"creation_date": "1958",
"citations": [
{
"citation": "Annual Exhibition: Sculpture, Paintings, Watercolors, Drawings,
"page_number": "Unpaginated, [8],[12]",
"url": null
},
{
"citation": "\"Moscow to See Modern U.S. Art,\"<em> New York Times</em> (May 31, 1959).",
"page_number": "P. 60",
"url": null
}]
"creators": [
{
"description": "Jacob Lawrence (American, 1917-2000)",
"role": "artist",
"birth_year": "1917",
"death_year": "2000"
}
]
}
数据说明
克利夫兰艺术博物馆提供了一个 API,允许公众访问这些数据:openaccess-api.clevelandart.org/。通过 API 可以访问的内容远远超过本例中使用的引文和创作者数据。
如何实现...
我们创建了一个收藏项目类,用于汇总创作者和媒体引文的数据:
- 导入
pandas、json、pprint和requests库。
首先,我们创建一个文件,用来实例化收藏项目对象,命名为class_cleaning_json.py:
import pandas as pd
import json
import pprint
import requests
- 创建一个
Collectionitem类。
我们将每个收藏项目的字典传递给类的__init__方法,该方法在类的实例化时会自动运行。我们将收藏项目字典分配给实例变量。将该类保存为collectionitem.py文件,并放置在helperfunctions文件夹中:
class Collectionitem:
... collectionitemcnt = 0
... def __init__(self, colldict):
... self.colldict = colldict
... Collectionitem.collectionitemcnt+=1
- 创建一个方法来获取每个收藏项目的第一个创作者的出生年份。
请记住,收藏项目可能有多个创作者。这意味着creators键的值可能是一个或多个列表项,这些项本身是字典。要获取第一个创作者的出生年份,我们需要['creators'][0]['birth_year']。我们还需要考虑出生年份键可能缺失的情况,因此首先要进行检查:
def birthyearcreator1(self):
... if ("birth_year" in self.colldict['creators'][0]):
... byear = self.colldict['creators'][0]['birth_year']
... else:
... byear = "Unknown"
... return byear
- 创建一个方法来获取所有创作者的出生年份。
使用列表推导式循环遍历所有creators项。这将返回一个包含出生年份的列表:
def birthyearsall(self):
... byearlist = [item.get('birth_year') for item in \
... self.colldict['creators']]
... return byearlist
-
创建一个方法来统计创作者的数量:
def ncreators(self): ... return len(self.colldict['creators']) -
创建一个方法来统计媒体引文的数量:
def ncitations(self): ... return len(self.colldict['citations']) -
导入
collectionitem模块。
我们从步骤 1创建的class_cleaning_json.py文件中执行此操作:
sys.path.append(os.getcwd() + "/helperfunctions")
import collectionitem as ci
- 加载艺术博物馆的收藏数据。
这返回的是一个字典列表。我们只提取了带有非裔美国艺术家数据的博物馆收藏子集:
response = requests.get("https://openaccess-api.clevelandart.org/api/artworks/?african_american_artists")
camcollections = json.loads(response.text)
camcollections = camcollections['data']
- 遍历
camcollections列表。
为camcollections中的每个项目创建一个集合项实例。将每个项目(即包含集合、创作者和引用键的字典)传递给类。调用我们刚刚创建的方法,并将它们返回的值分配给一个新的字典(newdict)。将该字典附加到一个列表(analysislist)中。(一些值可以直接从字典中提取,如title=colldict['title'],因为我们不需要以任何方式更改其值。)
analysislist = []
for colldict in camcollections:
... coll = ci.Collectionitem(colldict)
... newdict = dict(id=colldict['id'],
... title=colldict['title'],
... type=colldict['type'],
... creationdate=colldict['creation_date'],
... ncreators=coll.ncreators(),
... ncitations=coll.ncitations(),
... birthyearsall=coll.birthyearsall(),
... birthyear=coll.birthyearcreator1())
... analysislist.append(newdict)
- 使用新的字典列表创建一个分析 DataFrame。
确认我们获得了正确的计数,并打印第一个项目的字典:
len(camcollections)
1000
len(analysislist)
1000
pprint.pprint(analysislist[0:1])
[{'birthyear': '1917',
'birthyearsall': ['1917'],
'creationdate': '1958',
'id': 165157,
'ncitations': 30,
'ncreators': 1,
'title': 'Fulton and Nostrand',
'type': 'Painting'}]
analysis = pd.DataFrame(analysislist)
analysis.birthyearsall.value_counts().head()
birthyearsall
[1951] 283
[1953] 119
[1961, None] 105
[1937] 55
[1922] 41
Name: count, dtype: int64
analysis.head(2).T
0 1
id 165157 163769
title Fulton and Nostrand Go Down Death
type Painting Painting
creationdate 1958 1934
ncreators 1 1
ncitations 30 18
birthyearsall [1917] [1899]
birthyear 1917 1899
这些步骤展示了我们如何使用类来处理非表格数据。
它是如何工作的……
本食谱展示了如何直接处理 JSON 文件,或任何包含隐式一对多或多对多关系的文件。我们在分析单元(本例中为一个集合项)中创建了一个类,然后创建了方法来汇总每个集合项的多个数据节点。
我们在步骤 3到步骤 6中创建的方法非常简洁。当我们第一次查看数据的结构时,它在本食谱的准备工作部分中展示,确实很难不觉得它会非常难以清理。看起来似乎什么都行。但事实证明,它有一个相对可靠的结构。我们可以依赖creators和citations中的一个或多个子节点。每个creators和citations节点也有子节点,这些子节点是键值对。这些键不总是存在,所以我们需要先检查它们是否存在,然后再尝试获取它们的值。这就是我们在步骤 3中所做的。
还有更多……
我在第二章《在处理 HTML、JSON 和 Spark 数据时预见数据清理问题》中详细讨论了直接处理 JSON 文件的优势。我认为博物馆的收藏数据是一个很好的例子,说明了为什么如果可能的话,我们可能更愿意坚持使用 JSON 格式。即使数据的形式非常不同,它的结构实际上是有意义的。当我们试图将其规范化时,始终存在一个风险,那就是我们可能会遗漏其结构的某些方面。
用于检查整体数据质量的函数
我们可以通过更明确地说明我们正在评估的内容来加强数据质量检查。我们在数据分析项目的初期,可能已经对变量值的分布、允许值的范围以及缺失值的数量有了一些预期。这些预期可能来自文档、我们对数据所代表的基础现实世界过程的理解,或者我们对统计学的理解。建立一个常规流程,用于明确这些初步假设、测试它们并在项目过程中修订假设是个不错的主意。本节将演示这个过程可能是什么样的。
我们为每个感兴趣的变量设定了数据质量目标。这包括类别变量的允许值和缺失值的阈值,也包括数值的取值范围、缺失值、偏度和峰度阈值,并检查异常值。我们将检查唯一标识符变量是否存在重复和缺失值。我们从这个 CSV 文件中的假设开始,关于 NLS 文件中的变量:

图 12.3:对选定 NLS 列的数据检查
图 12.3展示了我们的初步假设。例如,对于maritalstatus,我们假设类别值为离婚|已婚|从未结婚|分居|寡妇,且不超过 20%的值将缺失。对于nightlyhrssleep(一个数值变量),我们假设值将在 3 到 9 之间,不超过 30%的值将缺失,且其偏度和峰度接近正态分布。
我们还指明了我们想要检查异常值。最后一列是一个标志,如果我们只想对某些变量进行数据检查,可以使用它。在这里,我们指出要对maritalstatus、originalid、highestgradecompleted、gpaenglish和nightlyhrssleep进行检查。
准备工作
我们将在本节中再次使用 NLS 数据。
如何操作...
我们使用预定义的数据检查目标来分析选定的 NLS 数据变量。
-
创建我们需要的函数进行数据检查,并将其保存在
helperfunctions子文件夹中,命名为runchecks.py。以下两个函数,checkcats和checkoutliers,将分别用于测试列表中的值和异常值。我们将在接下来的步骤中看到它是如何工作的:def checkcats(cat1,cat2): missingcats = \ set(cat1).symmetric_difference(set(cat2)) return missingcats def checkoutliers(values): thirdq, firstq = values.\ quantile(0.75),values.\ quantile(0.25) interquartilerange = 1.5*(thirdq-firstq) outlierhigh, outlierlow = \ interquartilerange+thirdq, \ firstq-interquartilerange return outlierhigh, outlierlow -
然后我们定义一个函数来运行所有的检查,
runchecks,它将接受一个 DataFrame(df)、我们的数据目标(dc)、一个数值列列表(numvars)、一个类别列列表(catvars)和一个标识符列列表(idvars):def runchecks(df,dc,numvars,catvars,idvars): -
在
runchecks函数中,我们对数据检查中的分类变量列进行循环。我们通过dcvals = dc.loc[col]获取该变量的所有目标值。我们从类别值中创建一个 NumPy 数组compcat,然后将该数组与传入 DataFrame 中该列的所有值进行比较(df[col].dropna().str.strip().unique())。如果一个数组中有而另一个数组中没有某个类别(即valuediff),我们会将其打印到控制台。我们还计算缺失值百分比。如果超出了我们指定的阈值,我们会打印一条消息:for col in df[catvars]: dcvals = dc.loc[col] print("\n\nChecks for categorical variable", col) compcat = list(dcvals.categories.split('|')) valuediff = checkcats(compcat,df[col].dropna().\ str.strip().unique()) if len(valuediff) > 0: print("at least one non-matching category:", valuediff) missingper = df[col].isnull().sum()/df.shape[0] if missingper > dcvals.missingthreshold: print("missing percent beyond threshold of", dcvals.missingthreshold, "is", missingper) -
现在让我们看一下检查数值变量的循环。我们从数据检查目标中的范围值创建一个 NumPy 数组,
range = np.fromstring(dcvals.range, sep='|')。range的第一个元素是范围的下限,第二个元素是上限。然后,我们从 DataFrame 中获取变量的最小值和最大值,并将其与目标文件中指示的范围进行比较。
我们计算缺失值百分比,并在超过数据检查目标文件中设置的阈值时打印出来。
如果showoutliers标志被设置为Y,我们将显示离群值。我们使用之前设置的checkoutliers函数,该函数通过简单的四分位距计算来确定离群值。最后,我们检查偏度和峰度,以便了解该变量与正态分布的差距:
for col in df[numvars]:
dcvals = dc.loc[col]
print("\n\nChecks for numeric variable", col)
range = np.fromstring(dcvals.range, sep='|')
min = df[col].min()
max = df[col].max()
if min < range[0]:
print("at least one record below range starting at ",
range[0], "min value is", min)
if max > range[1]:
print("at least one record above range ending at ",
range[1], "max value is", max)
missingper = df[col].isnull().sum()/df.shape[0]
if missingper > dcvals.missingthreshold:
print("missing percent beyond threshold of",
dcvals.missingthreshold, "is", missingper)
if dcvals.showoutliers == "Y":
outlierhigh, outlierlow = checkoutliers(df[col])
print("\nvalues less than", outlierlow, "\n",
df.loc[df[col]<outlierlow,col].\
agg(["min",'max','count']), end="\n")
print("\nvalues greater than", outlierhigh,
"\n", df.loc[df[col]>outlierhigh,col].\
agg(["min",'max','count']), end="\n")
skewcol = df[col].skew()
if abs(skewcol-dcvals.skewtarget)>1.2:
print("skew substantially different from target of",
dcvals.skewtarget, "is", skewcol)
kurtosiscol = df[col].kurtosis()
if abs(kurtosiscol-dcvals.kurtosistarget)>1.2:
print("kurtosis substantially different from target of",
dcvals.kurtosistarget, "is", kurtosiscol)
-
对于目标文件中标识为 id 变量的变量,我们进行一些简单的检查。我们检查该变量是否有重复值,并检查是否有缺失值:
for col in df[idvars]: print("\n\nChecks for id variable", col) uniquevals = df[col].nunique() nrows = df.shape[0] if uniquevals != nrows: print("not unique identifier", uniquevals, "unique values not equal to", nrows, "rows.") missingvals = df[col].isnull().sum() if missingvals > 0: print("unique value has", missingvals, "missing values") -
现在我们准备开始运行数据检查。我们首先加载 NLS DataFrame 和数据检查目标。
import pandas as pd import numpy as np import os import sys nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) dc = pd.read_csv("data/datacheckingtargets.csv") dc.set_index('varname', inplace=True) -
我们导入刚刚创建的
runchecks模块。sys.path.append(os.getcwd() + "/helperfunctions") import runchecks as rc -
让我们故意破坏一些 id 变量值来测试代码。我们还修复了
highestgradecompleted的逻辑缺失值,将其设置为实际缺失值。nls97.originalid.head(7)0 1 1 2 2 3 3 4 4 5 5 6 6 7 Name: originalid, dtype: int64nls97.loc[nls97.originalid==2,"originalid"] = 1 nls97.loc[nls97.originalid.between(3,7), "originalid"] = np.nan nls97.originalid.head(7)0 1.0 1 1.0 2 NaN 3 NaN 4 NaN 5 NaN 6 NaN Name: originalid, dtype: float64nls97["highestgradecompleted"] = nls97.highestgradecompleted.replace(95, np.nan) -
我们只选择那些被标记为包含的目标。然后,我们根据数据检查目标文件创建分类变量、数值变量和 id 变量列表:
dc = dc.loc[dc.include=="Y"] numvars = dc.loc[dc.type=="numeric"].index.to_list() catvars = dc.loc[dc.type=="categorical"].index.to_list() idvars = dc.loc[dc.type=="unique"].index.to_list() -
现在,我们准备开始运行检查。
rc.runchecks(nls97,dc,numvars,catvars,idvars)
这将产生以下输出:

图 12.4:运行检查
我们发现maritalstatus的缺失值比我们设置的 20%的阈值(26%)多。highestgradecompleted和gpaoverall的值超出了预期范围。两个变量的峰度较低。nightlyhrssleep有显著低于和高于四分位距的离群值。31 名受访者的nightlyhrssleep为 2 小时或更少。27 名受访者的nightlyhrssleep非常高,达到 12 小时或更多。
这些步骤展示了我们如何利用之前的领域知识和统计学理解,更好地针对数据质量问题进行调查。
它是如何工作的...
我们创建了一个包含数据检查目标的 CSV 文件,并在检查 NLS 数据时使用了该文件。我们通过将 NLS DataFrame 和数据检查目标一起传递给runchecks来完成此操作。runchecks中的代码遍历数据检查文件中的列名,并根据变量类型进行检查。
每个变量的目标由dcvals = dc.loc[col]定义,它抓取目标文件中该行的所有目标值。然后我们可以引用dcvals.missingthreshold来获取缺失值阈值,例如。接着,我们将缺失值的百分比(df[col].isnull().sum()/df.shape[0])与缺失阈值进行比较,如果缺失值百分比大于阈值,就打印一条消息。我们对值的范围、偏斜、异常值等进行相同类型的检查,具体取决于变量的类型。
我们可以在不更改runchecks代码的情况下,向数据检查目标文件添加新变量。我们还可以更改目标值。
还有更多……
有时候,我们需要主动进行数据检查。展示一些样本统计数据和频率分布以大致了解数据,和动用我们领域知识和统计理解来仔细检查数据质量是不同的。一个更有目的的方法可能要求我们偶尔从 Python 开发环境中抽离片刻,反思我们对数据值及其分布的预期。设置初始的数据质量目标,并定期回顾这些目标,可以帮助我们做到这一点。
使用管道进行数据预处理:一个简单的示例
在进行预测分析时,我们通常需要将所有的预处理和特征工程折叠进管道中,包括缩放、编码以及处理异常值和缺失值。我们在第八章,编码、转换和缩放特征中讨论了为什么我们可能需要将所有数据准备工作纳入数据管道。那一章的主要观点是,当我们构建解释性模型并且需要避免数据泄漏时,管道至关重要。尤其是当我们使用k-折交叉验证进行模型验证时,这一问题更加复杂,因为在评估过程中,测试和训练的 DataFrame 会发生变化。交叉验证已成为构建预测模型的标准做法。
注意
k-折交叉验证通过对所有的k折(或部分)进行训练,留下一个折用于测试。这会重复k次,每次排除不同的折进行测试。性能度量是基于k折的平均得分。
管道的另一个好处是,它们有助于确保结果的可重复性,因为它们通常旨在将我们的分析从原始数据带到模型评估。
虽然这个配方演示了如何通过管道来进行模型评估,但我们不会在此详细讨论。关于模型评估和使用 scikit-learn 工具的管道,一个很好的资源是我写的书《数据清理与机器学习探索》。
我们从一个相对简单的例子开始,这里有一个包含两个数值特征和一个数值目标的模型。在下一个配方中,我们将处理一个更加复杂的例子。
准备工作
在这个配方中,我们将使用 scikit-learn 的管道工具,并结合其他一些模块来对数据进行编码、缩放,以及填补缺失值。我们将再次使用土地温度数据。
如何操作...
-
我们首先加载本配方中将要使用的
scikit-learn模块,用于转换我们的数据。我们将使用StandardScaler来标准化特征,使用SimpleImputer填补缺失值,并使用make_pipeline将所有预处理步骤组合在一起。我们还使用train_test_split来创建训练和测试数据框。其他模块将在使用时进行讲解:import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_validate from sklearn.model_selection import KFold -
我们加载土地温度数据并创建训练和测试数据框。我们将尝试将温度建模为纬度和海拔的函数。考虑到
latabs和elevation变量的范围差异,数据缩放会变得非常重要:landtemps = pd.read_csv("data/landtemps2023avgs.csv") feature_cols = ['latabs','elevation'] X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[['avgtemp']], test_size=0.1, random_state=0)
有关train_test_split的介绍,请参见第八章,特征编码、转换和缩放。
-
我们设置了k-折交叉验证。我们指定希望有五个折叠,并且数据需要被打乱:
kf = KFold(n_splits=5, shuffle=True, random_state=0) -
现在,我们准备好设置管道了。管道将进行标准缩放,当值缺失时填补均值,然后运行线性回归模型。两个特征将以相同的方式处理。
pipeline = \ make_pipeline(StandardScaler(), SimpleImputer(strategy="mean"),LinearRegression()) -
在构建好管道并实例化k-折交叉验证对象后,我们准备好执行预处理、估算模型并生成评估指标。我们将管道传递给
cross_validate函数,并传入我们的训练数据。我们还传递了在步骤 3中创建的Kfold对象。我们得到了一个相当不错的R平方值。scores = \ cross_validate(pipeline, X=X_train, y=y_train.values, cv=kf, scoring=['r2','neg_mean_absolute_error'], n_jobs=1) print("Mean Absolute Error: %.2f, R-squared: %.2f" % (scores['test_neg_mean_absolute_error'].mean(), scores['test_r2'].mean()))Mean Absolute Error: -2.53, R-squared: 0.82
它是如何工作的...
我们使用了 scikit-learn 的make_pipeline来创建一个仅包含三步的管道:应用标准缩放、基于该变量的均值填补缺失值,并拟合一个线性回归模型。管道的一个很有用的地方是它们会自动将一个步骤的转换结果传递给下一个步骤。一旦我们掌握了这项操作,尽管有k-折交叉验证的复杂性,这个过程仍然很简单。
我们可以想象一下,当训练和测试数据框不断变化时(比如k-折交叉验证中那样),如果我们自己编写代码来处理这些数据将会有多么复杂。即使是使用均值进行填补这样简单的操作也是棘手的。每次训练数据发生变化时,我们都需要重新计算训练数据的均值。我们的管道会自动处理这些问题。
那是一个相对简单的例子,只有几个我们可以用相同方式处理的特征。我们也没有检查离群值或对目标变量进行缩放。在下一个例子中,我们将使用管道处理一个更复杂的建模项目。
使用管道进行数据预处理:一个更复杂的例子
如果你曾经构建过数据管道,你就会知道,当你处理多个不同的数据类型时,事情可能会有些混乱。例如,我们可能需要对连续特征的缺失值进行中位数插补,对于类别特征则使用最频繁的值。我们可能还需要转换我们的目标变量。在这个例子中,我们将探讨如何对不同变量应用不同的预处理。
准备开始
在这个例子中,我们将使用相当多的 scikit-learn 模块。虽然一开始可能会有些混乱,但你会很快感激 scikit-learn 提供的几乎可以做任何你需要的工具。如果需要,scikit-learn 还允许我们将自定义的转换器添加到管道中。我将在这个例子中演示如何构建我们自己的转换器。
我们将使用来自 NLS 的工资和就业数据。
如何做...
-
我们首先加载在前一个例子中使用的库。然后我们添加
ColumnTransformer和TransformedTargetRegressor类。我们将使用这些类分别转换我们的特征和目标变量。import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from feature_engine.encoding import OneHotEncoder from sklearn.impute import KNNImputer from sklearn.model_selection import cross_validate, KFold from sklearn.compose import ColumnTransformer from sklearn.compose import TransformedTargetRegressor -
列转换器非常灵活。我们甚至可以使用它与我们自己定义的预处理函数。下面的代码块从
helperfunctions子文件夹中的preprocfunc模块导入OutlierTrans类。import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans -
OutlierTrans类通过与四分位距的距离来识别缺失值。这是一种我们在第四章,“识别数据子集中的离群值”中演示过的技术,并且在本章中多次使用。
为了在 scikit-learn 管道中使用,我们的类必须具有 fit 和 transform 方法。我们还需要继承 BaseEstimator 和 TransformerMixin 类。
在这个类中,几乎所有的操作都发生在 transform 方法中。任何高于第三四分位数或低于第一四分位数超过 1.5 倍四分位距的值都会被标记为缺失,尽管这个阈值可以更改:
class OutlierTrans(BaseEstimator,TransformerMixin):
def __init__(self,threshold=1.5):
self.threshold = threshold
def fit(self,X,y=None):
return self
def transform(self,X,y=None):
Xnew = X.copy()
for col in Xnew.columns:
thirdq, firstq = Xnew[col].quantile(0.75),\
Xnew[col].quantile(0.25)
inlierrange = self.threshold*(thirdq-firstq)
outlierhigh, outlierlow = inlierrange+thirdq,\
firstq-inlierrange
Xnew.loc[(Xnew[col]>outlierhigh) | \
(Xnew[col]<outlierlow),col] = np.nan
return Xnew.values
我们的 OutlierTrans 类可以像使用 StandardScaler 和其他转换器一样,在我们的管道中使用。我们稍后会这样做。
- 现在我们准备加载需要处理的数据。我们将使用 NLS 工资数据。工资收入将作为我们的目标变量,而我们将使用高中 GPA、母亲和父亲的最高学历、父母收入、性别、工作周数以及个人是否完成本科学位作为特征。
我们在这里创建了处理不同方式的特征列表。稍后在我们指示管道对数值型、类别型和二进制特征进行不同操作时,这将非常有用。
nls97wages = pd.read_csv("data/nls97wages.csv" , low_memory=False)
nls97wages.set_index("personid", inplace=True)
num_cols = ['gpascience','gpaenglish','gpamath',
'gpaoverall','motherhighgrade','fatherhighgrade',
'parentincome','weeksworked20']
cat_cols = ['gender']
bin_cols = ['completedba']
target = nls97wages[['wageincome20']]
features = nls97wages[num_cols + cat_cols + bin_cols]
X_train, X_test, y_train, y_test = \
train_test_split(features,\
target, test_size=0.2, random_state=0)
- 现在我们可以设置列转换器。我们首先创建用于处理数值数据(
standtrans)、分类数据和二元数据的管道。
对于数值数据(连续型数据),我们希望将异常值设为缺失值。我们将值 2 传递给OutlierTrans的threshold参数,表示我们希望将超出四分位距 2 倍范围以上或以下的值设置为缺失值。请记住,通常使用 1.5,所以我们这里比较保守。
我们对gender列进行了独热编码,本质上创建了一个虚拟变量。我们丢弃了最后一个类别,以避免虚拟变量陷阱,正如第八章《编码、转换和缩放特征》中讨论的那样。
然后,我们创建一个ColumnTransformer对象,将刚刚创建的三个管道传递给它,并指明每个管道应该应用于哪些特征。
对于数值变量,我们暂时不担心缺失值。我们稍后会处理它们:
standtrans = make_pipeline(OutlierTrans(2),
StandardScaler())
cattrans = \
make_pipeline(SimpleImputer(strategy=\
"most_frequent"),OneHotEncoder(drop_last=True))
bintrans = \
make_pipeline(SimpleImputer(strategy=\
"most_frequent"))
coltrans = ColumnTransformer(
transformers=[
("stand", standtrans, num_cols),
("cat", cattrans, ['gender']),
("bin", bintrans, ['completedba'])
]
)
- 现在,我们可以将列转换器添加到一个包含我们想要运行的线性模型的管道中。我们将 KNN 插补添加到管道中,以处理数值数据的缺失值。对于分类变量的缺失值,我们已经处理过了。
我们还需要对目标进行缩放,这不能在我们的管道中完成。我们使用 scikit-learn 的TransformedTargetRegressor来完成这项工作。我们将刚才创建的管道传递给目标回归器的regressor参数。
lr = LinearRegression()
pipe1 = make_pipeline(coltrans,
KNNImputer(n_neighbors=5), lr)
ttr=TransformedTargetRegressor(regressor=pipe1,
transformer=StandardScaler())
-
让我们使用这个管道进行k-折交叉验证。我们可以通过目标回归器
ttr将我们的管道传递给cross_validate函数。kf = KFold(n_splits=10, shuffle=True, random_state=0) scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=('r2', 'neg_mean_absolute_error'), n_jobs=1) print("Mean Absolute Error: %.2f, R-squared: %.2f" % (scores['test_neg_mean_absolute_error'].mean(), scores['test_r2'].mean()))Mean Absolute Error: -32899.64, R-squared: 0.16
这些得分不是很好,尽管这并不是这次练习的重点。这里的关键是,我们通常希望将大部分预处理工作纳入管道中进行处理。这是避免数据泄露的最佳方法。列转换器是一个极其灵活的工具,允许我们对不同的特征应用不同的转换。
它是如何工作的...
我们创建了几个不同的管道来预处理数据,在拟合模型之前,一个用于数值数据,一个用于分类数据,一个用于二元数据。列转换器通过允许我们将不同的管道应用于不同的列来帮助我们。我们在步骤 5中设置了列转换器。
我们在步骤 6中创建了另一个管道。这个管道实际上从列转换器开始。然后,列转换器预处理后的数据集将传递给 KNN 插补器,以处理数值列中的缺失值,接着传递给线性回归模型。
值得注意的是,我们可以向 scikit-learn 管道中添加转换操作,即使是我们自己设计的转换操作,因为它们继承了BaseEstimator和TransformerMixin类,正如我们在步骤 3中看到的那样。
还有更多...
有一件关于管道的事非常酷而且实用,而在这个示例中并未展示。如果你曾经需要根据经过缩放或转化的变量生成预测,你可能会记得那是多么麻烦。好消息是,管道帮我们处理了这个问题,生成了适当单位的预测结果。
另见
这只是管道技术的一小部分内容。如果你想深入了解,请参阅我写的书《使用机器学习进行数据清理和探索》。
摘要
本章包含了不少内容,介绍了几种自动化数据清理的方式。我们创建了用于显示数据结构和生成描述性统计的函数。我们还创建了用于重构和聚合数据的函数。此外,我们还开发了用于处理大量变量的数据清理的 Python 类,这些变量需要非常不同的处理方式。我们还展示了如何利用 Python 类简化直接操作 JSON 文件的过程。我们还探讨了通过将数据与预定义目标进行对比,使数据清理更有针对性。最后,我们探讨了如何通过管道自动化数据清理。
留下评论!
喜欢这本书吗?帮助像你一样的读者,通过在亚马逊上留下评论。扫描下方二维码,获得你选择的免费电子书。


订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推动职业生涯发展。欲了解更多信息,请访问我们的网站。
为什么要订阅?
-
用来自 4,000 多位行业专家的实用电子书和视频,减少学习时间,增加编程时间
-
用专门为你打造的技能规划提升你的学习
-
每月获得一本免费电子书或视频
-
完全可搜索,便于获取关键信息
-
复制、粘贴、打印和书签内容
在www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费电子报,并获得 Packt 书籍和电子书的独家折扣和优惠。
你可能喜欢的其他书籍
如果你喜欢这本书,你可能会对 Packt 出版的以下其他书籍感兴趣:
Azure 数据工厂实用指南(第二版)
Dmitry Foshin
Dimtry Anoshin
Tonya Chernyshova
Xenia Ireton
ISBN: 978-1-80324-659-8
-
在 ADF 中创建编排和转换作业
-
使用 Azure Synapse 开发、执行和监控数据流
-
使用 Databricks 和 Delta 表创建大数据管道
-
使用 Spark 池在 Azure Data Lake 中处理大数据
-
将本地 SSIS 作业迁移到 ADF
-
将 ADF 与常用的 Azure 服务(如 Azure ML、Azure Logic Apps 和 Azure Functions)集成
-
在 HDInsight 和 Azure Databricks 中运行大数据计算任务
-
使用 ADF 的内置连接器,将数据从 AWS S3 和 Google Cloud Storage 复制到 Azure Storage
AWS 数据工程 - 第二版
Gareth Eagar
ISBN: 978-1-80461-442-6
-
无缝地摄取流数据,使用 Amazon Kinesis Data Firehose
-
使用 AWS Glue Studio 优化、反规范化并连接数据集
-
使用 Amazon S3 事件触发 Lambda 进程来转换文件
-
将数据加载到 Redshift 数据仓库中,并轻松运行查询
-
使用 Amazon QuickSight 可视化和探索数据
-
使用 Amazon Comprehend 从数据集中提取情感数据
-
使用 Apache Iceberg 与 Amazon Athena 构建事务性数据湖
-
了解如何在 AWS 上实现数据网格方法
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天就申请。我们与成千上万的开发者和技术专业人士合作,帮助他们与全球技术社区分享他们的见解。你可以提交一般申请,申请我们正在招聘作者的特定热门话题,或提交你自己的想法。
分享你的想法
现在你已经完成了Python 数据清理食谱,第二版,我们很想听听你的想法!如果你是在亚马逊购买的这本书,请点击这里直接前往亚马逊书评页面,分享你的反馈或在购买网站上留下评论。
你的评论对我们和技术社区非常重要,将帮助我们确保提供卓越的内容质量。


浙公网安备 33010602011771号