原文:
annas-archive.org/md5/2774e54b23314a6bebe51d6caf9cd592
译者:飞龙
第七章:识别并修复缺失值
我想我可以代表许多数据分析师和科学家来说,鲜少有什么看似微小而琐碎的事情能像缺失值那样对我们的分析产生如此大的影响。我们花费大量时间担心缺失值,因为它们可能对我们的分析产生戏剧性的、令人惊讶的影响。尤其是当缺失值不是随机的,而是与因变量相关时,情况尤其如此。例如,如果我们正在做一个收入的纵向研究,但教育水平较低的个体每年更可能跳过收入问题,那么很可能会对我们关于教育水平的参数估计产生偏差。
当然,识别缺失值只解决了问题的一部分。我们还需要决定如何处理它们。我们是删除任何包含缺失值的观测值,还是基于像均值这样的样本统计量插补一个值?或者,基于更有针对性的统计量,例如某个类别的均值,来插补?对于时间序列或纵向数据,我们是否应该考虑用最接近的时间值来填补?或者,是否应该使用更复杂的多变量技术进行插补,可能是基于回归或 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).T
iso_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: int64
schoolrecord.loc[misscnt>=7].head(4).T
personid 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: float64
schoolrecord.fillna({"gpaoverall":\ schoolrecord.gpaoverall.mean()}, inplace=True) schoolrecord.gpaoverall.isnull().sum()
0
schoolrecord.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()
3783
wageincome20.head().T
personid 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().T
personid 135335 NaN 999406 115,000 151672 115,000 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64
wageincome20.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().T
personid 135335 NaN 999406 115,000 151672 NaN 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64
wageincome20.std()
59616.290306039584
wageincome20.bfill(inplace=True) wageincome20.head().T
personid 135335 115,000 999406 115,000 151672 45,000 750699 45,000 781297 150,000 Name: wageincome20, dtype: float64
wageincome20.std()
58199.4895818016
如果缺失值是随机分布的,那么前向或后向填充相比使用平均值有一个优势。它更可能接近非缺失值的分布。注意,在后向填充后,标准差变化不大。
有时,根据相似观测值的平均值或中位数来填充缺失值是有意义的;例如,具有相同相关变量值的观测值。让我们在下一步中尝试这种方法。
-
在 NLS DataFrame 中,2020 年的工作周数与获得的最高学历有相关性。以下代码显示了不同学历水平下的工作周数平均值如何变化。工作周数的平均值是 38,但没有学位的人为 28,拥有职业学位的人为 48。在这种情况下,给没有学位的人的缺失工作周数分配 28 可能比分配 38 更合适:
nls97.weeksworked20.mean()
38.35403815808349
nls97.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 52
wagedata[['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 | wageincome20
wagedataimpmice = 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 或随机森林插补。在本章中,我们已经探讨了所有这些方法,接下来的章节中,我们将学习特征编码、转换和标准化。
留下评价!
喜欢这本书吗?通过在亚马逊上留下评价帮助像你一样的读者。扫描下面的二维码,获取一本你选择的免费电子书。
https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/Review_copy.png
第八章:编码、转换和缩放特征
我们的数据清理工作通常是为了准备数据,以便将其用于机器学习算法。机器学习算法通常需要对变量进行某种形式的编码。我们的模型通常也需要进行某种形式的缩放,以防止具有更高变异性的特征压倒优化过程。本章中将展示相关的例子,并说明标准化如何解决这个问题。
机器学习算法通常需要对变量进行某种形式的编码。我们几乎总是需要对特征进行编码,以便算法能够正确理解它们。例如,大多数算法无法理解 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]
8984
X_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 KB
y_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 KB
y_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 Poland
X_train.year.value_counts()
year 2023 84 Name: count, dtype: int64
X_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).T
606986 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).T
606986 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).T
606986 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 0
X_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: int64
X_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).T
31 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.092053479609332
plt.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.09944093918837159
plt.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.010531307863482307
plt.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 值,生成最接近正态分布的分布。它使用以下方程进行变换:
或
其中 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_003.png 是我们的转换后特征。仅仅出于好奇,让我们看看用于转换 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: float64
y_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
如果数据中有异常值,鲁棒缩放可能是一个不错的选择。鲁棒缩放从每个变量的值中减去中位数,并将该值除以四分位数间距。因此,每个值是:
其中 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_007.png 是 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
方法。
总结
本章中我们涵盖了广泛的特征工程技术。我们使用工具删除冗余或高度相关的特征。我们探讨了最常见的编码方法——独热编码、顺序编码和哈希编码。然后我们使用变换改善特征的分布。最后,我们使用常见的分箱和缩放方法来解决偏斜、峰度和异常值问题,并调整具有不同范围的特征。在下一章,我们将学习如何在汇总时修复杂乱的数据。
留下评论!
享受这本书吗?通过留下亚马逊评论来帮助像您这样的读者。扫描下面的二维码,免费获取您选择的电子书。
https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/Review_copy.png
第九章:聚合时修复凌乱的数据
本书的前几章介绍了生成整个 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 的
groupby
DataFrame: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 0
type(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).T
location 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,774
countrytots.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.8
nls97.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
创建汇总统计数据。这涉及三个步骤:
-
根据一个或多个类别变量创建
groupby
DataFrame。 -
选择用于汇总统计数据的列。
-
选择聚合函数。
在这个示例中,我们使用了链式操作,一行代码完成了三件事。因此,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
来使用默认索引,而不是由groupby
DataFrame 创建的索引: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).T
0 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 空间,与作者和其他读者讨论: