Python 数据清理秘籍第二版(四)

原文:annas-archive.org/md5/2774e54b23314a6bebe51d6caf9cd592

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:识别并修复缺失值

我想我可以代表许多数据分析师和科学家来说,鲜少有什么看似微小而琐碎的事情能像缺失值那样对我们的分析产生如此大的影响。我们花费大量时间担心缺失值,因为它们可能对我们的分析产生戏剧性的、令人惊讶的影响。尤其是当缺失值不是随机的,而是与因变量相关时,情况尤其如此。例如,如果我们正在做一个收入的纵向研究,但教育水平较低的个体每年更可能跳过收入问题,那么很可能会对我们关于教育水平的参数估计产生偏差。

当然,识别缺失值只解决了问题的一部分。我们还需要决定如何处理它们。我们是删除任何包含缺失值的观测值,还是基于像均值这样的样本统计量插补一个值?或者,基于更有针对性的统计量,例如某个类别的均值,来插补?对于时间序列或纵向数据,我们是否应该考虑用最接近的时间值来填补?或者,是否应该使用更复杂的多变量技术进行插补,可能是基于回归或 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 数据每个调查响应者有一条观察记录。每年的就业、收入和大学入学数据都存储在带有后缀的列中,后缀表示年份,如 weeksworked21weeksworked22 分别代表 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 函数来识别缺失值和逻辑缺失值(即尽管数据本身不缺失,但却代表缺失的非缺失值)。

  1. 让我们从加载 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) 
    
  2. 接下来,我们统计每个变量的缺失值数量。我们可以使用 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 数据。

  1. 如果我们想要了解每一行的缺失值数量,可以在求和时指定 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 
    
  2. 让我们看一看一些缺失值超过 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 
    
  3. 我们还将检查总病例和死亡人数的缺失值。每百万人的病例和每百万人的死亡人数分别有一个缺失值:

    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 
    
  4. 我们可以轻松检查某个国家是否同时缺失每百万的病例数和每百万的死亡人数。我们看到有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 是非访谈。

  1. 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 
    
  2. 有 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] 
    
  3. 对于我们的分析,非响应的原因并不重要。我们只需要统计每列的非响应数量,无论非响应的原因是什么:

    nlsparents.transform(lambda x: x.between(-5,-1)).sum() 
    
    motherage            608
    parentincome        2396
    fatherhighgrade     1856
    motherhighgrade      688
    dtype: int64 
    
  4. 在我们进行分析之前,应该将这些值设置为缺失值。我们可以使用 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 方法为缺失值分配替代值,例如使用变量均值:

  1. 让我们加载 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) 
    
  2. 我们可以使用前面章节中探讨的技术来识别缺失值。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 
    
  3. 我们可以创建一个 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 
    
  4. 我们将删除那些在 8 个变量中有 7 个或更多缺失值的观测值。我们可以通过将 dropnathresh 参数设置为 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 的个体与其他个体在一些重要预测变量上有所不同,我们不希望失去这些数据。

  1. 最直接的方法是将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。这是使用数据集均值来填补所有缺失值的一个缺点。

  1. 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 
    
  2. 与其将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 版本中已被弃用。向后填充的语法也是如此,我们接下来将使用这种方法。

  1. 我们也可以使用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 
    

如果缺失值是随机分布的,那么前向或后向填充相比使用平均值有一个优势。它更可能接近非缺失值的分布。注意,在后向填充后,标准差变化不大。

有时,根据相似观测值的平均值或中位数来填充缺失值是有意义的;例如,具有相同相关变量值的观测值。让我们在下一步中尝试这种方法。

  1. 在 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 
    
  2. 以下代码为缺失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中使用groupbytransform的理解仍然有些不清楚,不必担心。在第九章聚合时清理杂乱数据中,我们将更深入地使用groupbytransformapply

使用回归法填充缺失值

我们在上一教程的结尾处,给缺失值分配了组均值,而不是整体样本均值。正如我们所讨论的,这在决定组的变量与缺失值变量相关时非常有用。使用回归法填充值在概念上与此类似,但通常是在填充基于两个或更多变量时使用。

回归填充通过回归模型预测的相关变量值来替代变量的缺失值。这种特定的填充方法被称为确定性回归填充,因为填充值都位于回归线上,并且不会引入误差或随机性。

这种方法的一个潜在缺点是,它可能会大幅度减少缺失值变量的方差。我们可以使用随机回归填充来解决这一缺点。在本教程中,我们将探讨这两种方法。

准备工作

我们将在本教程中使用statsmodels模块来运行线性回归模型。statsmodels通常包含在 Python 的科学发行版中,但如果你还没有安装,可以通过pip install statsmodels来安装它。

如何做到这一点…

NLS 数据集上的wageincome20列存在大量缺失值。我们可以使用线性回归来填补这些值。工资收入值是 2020 年的报告收入。

  1. 我们首先重新加载 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 
    
  2. 我们对超过 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 
    
  3. 正如我们已经发现的那样,我们需要将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 
    
  4. 我们应该检查一下,具有工资收入缺失值的观测对象在某些重要方面是否与那些没有缺失值的观测对象不同。以下代码显示,这些观测对象的学位获得水平、父母收入和工作周数显著较低。在这种情况下,使用整体均值来分配值显然不是最佳选择:

    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 年有工资收入是没有意义的。

  1. 我们来试试回归插补。我们首先用平均值替换缺失的parentincome值。我们将hdegnum折叠为达到以下三种学位水平的人群:少于本科、本科及以上。我们将它们设置为哑变量,当FalseTrue时,值为01。这是处理回归分析中分类数据的一种经过验证的方法。它允许我们基于组成员身份估计不同的 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) 
  1. 接下来,我们定义一个函数,getlm,用于使用statsmodels模块运行线性模型。该函数具有目标变量或依赖变量名称ycolname以及特征或自变量名称xcolnames的参数。大部分工作由statsmodelsfit方法完成,即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 
    
  2. 现在我们可以使用 getlm 函数来获取参数估计和模型摘要。所有系数都是正的,并且在 95% 水平下显著,p-值小于 0.05。正如我们预期的那样,工资收入随着工作周数和父母收入的增加而增加。拥有大学学位的收入比没有大学学位的人多 $18.5K。拥有研究生学位的人比那些学历较低的人多了近 $45.6K。(degcoldegadv 的系数是相对于没有大学学位的人来解释的,因为这个变量被省略掉了。)

    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 
    
  3. 我们使用这个模型来插补缺失的工资收入值。由于我们的模型包含了常数项,因此我们需要在预测中添加一个常数。我们可以将预测结果转换为 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 
    
  4. 我们应该查看一下我们的工资收入插补的汇总统计,并将其与实际的工资收入值进行比较。(记住,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 
    
  5. 随机回归插补会在基于我们模型残差的预测中添加一个正态分布的误差。我们希望这个误差的均值为零,且标准差与我们的残差相同。我们可以使用 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 
  1. 这应该会增加方差,但不会对均值产生太大影响。让我们验证一下这一点。我们首先需要用随机预测值替换缺失的工资收入值:

    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 插补来执行与上一篇文章中回归插补相同的插补操作。

  1. 我们首先从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) 
    
  2. 接下来,我们准备变量。我们将学位获得情况合并为三个类别——低于大学、大学和大学以上学位——每个类别用不同的虚拟变量表示。我们还将家长收入的逻辑缺失值转换为实际的缺失值:

    nls97['hdegnum'] = \
     nls97.highestdegree.str[0:1].astype('float')
    nls97['parentincome'] = \
     nls97.parentincome.\
       replace(list(range(-5,0)),
       np.nan) 
    
  3. 让我们创建一个仅包含工资收入和一些相关变量的 DataFrame。我们还只选择那些有工作周数为正值的行:

    wagedatalist = ['wageincome20','weeksworked20',
      'parentincome','hdegnum']
    wagedata = \
     nls97.loc[nls97.weeksworked20>0, wagedatalist]
    wagedata.shape 
    
    (5889, 6) 
    
  4. 现在,我们可以使用 KNN 填补器的fit_transform方法,为传入的 DataFrame wagedata中的所有缺失值生成填补值。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) 
  1. 我们将填补后的数据与原始的 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 
    
  2. 让我们看看原始变量和填补变量的汇总统计数据。毫不奇怪,填补后的工资收入均值低于原始均值。正如我们在前一个菜谱中发现的,缺失工资收入的观测值通常具有较低的学历、较少的工作周数和较低的父母收入。我们还失去了一些工资收入的方差。

    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 次迭代。

做好准备

要运行这个配方中的代码,您需要安装MissForestMiceForest模块。可以通过pip安装这两个模块。

如何做到……

运行 MissForest 比使用我们在前一个配方中使用的 KNN 插补器还要简单。我们将对之前处理过的工资收入数据进行插补。

  1. 让我们从导入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) 
    
  2. 我们应该做与前一个配方中相同的数据清洗:

    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] 
    
  3. 现在我们准备运行 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']]) 
    
  4. 让我们看一下我们的一些插补值和一些汇总统计信息。插补后的值具有较低的均值。考虑到我们已经知道缺失值并非随机分布,且具有较低学位和工作周数的人更有可能缺失工资收入,这一点并不令人惊讶:

    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. 我们使用在步骤 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() 
    
  2. 然后,我们可以查看插补结果:

    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 工具来完成本章中之前执行过的许多任务。

  1. 我们首先导入pandasnumpy库,以及OpenAIpandasai。在这个食谱中,我们将与 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) 
    
  2. 我们对父母收入和最高学位变量进行与之前示例相同的数据清理:

    nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('category')
    nls97['parentincome'] = \
     nls97.parentincome.\
       replace(list(range(-5,0)),
       np.nan) 
    
  3. 我们创建了一个仅包含工资和学位数据的 DataFrame,然后从 PandasAI 中创建一个SmartDataframe

    wagedatalist = ['wageincome20','weeksworked20',
      'parentincome','hdegnum']
    wagedata = nls97[wagedatalist]
    wagedatasdf = SmartDataframe(wagedata, config={"llm": llm}) 
    
  4. 显示所有变量的非缺失计数、平均值和标准差。我们向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 
    
  5. 我们将基于每个变量的均值填充缺失值。此时,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 
    
  6. 我们再来看一下最高学位的值。注意到最频繁的值是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 
    
  7. 我们可以将学位变量的缺失值设置为其最频繁的非缺失值,这是一种常见的处理分类变量缺失值的方法。现在,所有的缺失值都被填充为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 
    
  8. 我们本可以使用内置的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 
    
  9. 我们可以使用 KNN 方法填充收入和工作周数的缺失值。我们从一个未更改的 DataFrame 开始。在填充后,wageincome20的均值比原来要低,如步骤 4所示。这并不奇怪,因为我们在其他示例中看到,缺失wageincome20的个体在与wageincome20相关的其他变量上也有较低的值。wageincome20parentincome的标准差变化不大。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 
    

它是如何工作的……

每当我们将自然语言命令传递给SmartDataframechat方法时,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

第八章:编码、转换和缩放特征

我们的数据清理工作通常是为了准备数据,以便将其用于机器学习算法。机器学习算法通常需要对变量进行某种形式的编码。我们的模型通常也需要进行某种形式的缩放,以防止具有更高变异性的特征压倒优化过程。本章中将展示相关的例子,并说明标准化如何解决这个问题。

机器学习算法通常需要对变量进行某种形式的编码。我们几乎总是需要对特征进行编码,以便算法能够正确理解它们。例如,大多数算法无法理解 femalemale 这些值,或者无法意识到不能将邮政编码当作有序数据处理。尽管通常不必要,但当我们的特征范围差异巨大时,缩放通常是一个非常好的选择。当我们使用假设特征服从高斯分布的算法时,可能需要对特征进行某种形式的转换,以使其符合该假设。

本章我们将探讨以下内容:

  • 创建训练数据集并避免数据泄漏

  • 识别并移除无关或冗余的观测值

  • 对类别特征进行编码:独热编码

  • 对类别特征进行编码:有序编码

  • 对中等或高基数的特征进行编码

  • 转换特征

  • 分箱特征

  • 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 来创建训练和测试数据:

  1. 首先,我们从 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) 
    
  2. 然后,我们可以为特征(X_trainX_test)以及目标(y_trainy_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) 
    
  3. 让我们看看使用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 
    
  4. 我们还来看一下测试 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 提供了很好的工具来构建从数据准备到模型评估的机器学习管道。一本很好的资源是我的书*《机器学习中的数据清洗与探索》*。

在本章的其余部分,我们将使用sklearntrain_test_split来创建独立的训练和测试 DataFrame。接下来,我们通过移除显然无用的特征开始特征工程工作,因为这些特征与其他特征的数据相同,或者响应值没有变化。

移除冗余或无用的特征

在数据清洗和操作的过程中,我们常常会得到一些不再有意义的数据。也许我们根据单一特征值对数据进行了子集划分,并保留了该特征,尽管现在它对所有观测值都有相同的值。或者,在我们使用的数据子集中,两个特征的值相同。理想情况下,我们会在数据清洗过程中捕捉到这些冗余。然而,如果我们在该过程中没有捕捉到它们,我们可以使用开源的feature-engine包来帮助我们解决这个问题。

也可能存在一些特征之间高度相关,以至于我们几乎不可能构建出能够有效使用所有特征的模型。feature-engine提供了一个名为DropCorrelatedFeatures的方法,可以在特征与另一个特征高度相关时轻松删除该特征。

准备工作

在本章中,我们将大量使用feature-enginecategory_encoders包。你可以通过 pip 安装这些包,命令为pip install feature-enginepip install category_encoders。本章的代码使用的是feature-engine的 1.7.0 版本和category_encoders的 2.6.3 版本。注意,pip install feature-enginepip 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上访问。

如何操作…

  1. 让我们从feature_enginesklearn中导入所需的模块,然后加载 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) 
    
  2. 接下来,我们将创建训练和测试的 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) 
    
  3. 我们可以使用 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 
    

gpaoverallgpasciencegpaenglishgpamath高度相关。corr方法默认返回皮尔逊相关系数。当我们可以假设特征之间存在线性关系时,这种方式是合适的。但当这一假设不成立时,我们应该考虑请求斯皮尔曼相关系数。我们可以通过将spearman传递给corr方法的参数来实现这一点。

  1. 让我们删除与其他特征的相关性超过 0.75 的特征。我们将 0.75 传递给 DropCorrelatedFeaturesthreshold 参数,并且通过将变量设置为 None 来表示我们希望使用皮尔逊系数并评估所有特征。我们在训练数据上使用 fit 方法,然后对训练和测试数据进行转换。info 方法显示,结果训练 DataFrame (X_train_tr) 中包含所有特征,除了 gpaoverall,它与 gpasciencegpaenglish 的相关性分别为 0.790.84DropCorrelatedFeatures 会从左到右进行评估,因此如果 gpamathgpaoverall 高度相关,它会删除 gpaoverall)。

如果 gpaoverallgpamath 的左边,它将删除 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 管道。

  1. 现在让我们从波兰的土地温度数据中创建训练和测试 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 
    
  2. 让我们删除在训练数据集中值相同的特征。注意,yearcountry 在转换后被删除:

    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 
    
  3. 让我们删除那些与其他特征值相同的特征。在这种情况下,转换会删除 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 engineselection 对象的 fit 方法。这收集了进行后续转换所需的信息。在这种情况下,转换是删除具有常量值的特征。我们通常只在训练数据上执行拟合。我们可以通过使用 fit_transform 将拟合和转换结合在一起,这将在本章的大部分内容中使用。

本章的其余部分探讨了一些较为复杂的特征工程挑战:编码、转换、分箱和缩放。

编码类别特征:独热编码

在大多数机器学习算法中,我们可能需要对特征进行编码,原因有几个。首先,这些算法通常要求数据为数值型。其次,当一个类别特征用数字表示时,例如,女性为 1,男性为 2,我们需要对这些值进行编码,使其被识别为类别数据。第三,该特征可能实际上是顺序的,具有离散的值,这些值表示某种有意义的排序。我们的模型需要捕捉到这种排序。最后,一个类别特征可能具有大量取值(称为高基数),我们可能希望通过编码来合并某些类别。

我们可以使用独热编码来处理具有有限取值的特征,假设取值为 15 或更少。我们将在本节中介绍独热编码,接下来讨论顺序编码。随后,我们将讨论如何处理高基数类别特征的策略。

独热编码对每个特征的取值创建一个二进制向量。所以,如果一个特征,称为 letter,有三个唯一的值,ABC,独热编码会创建三个二进制向量来表示这些值。第一个二进制向量,称为 letter_A,当 letter 的值为 A 时为 1,当它是 BC 时为 0。letter_Bletter_C 也会按类似的方式编码。经过转换的特征 letter_Aletter_Bletter_C,通常被称为 虚拟变量图 8.1 展示了独热编码的示意图。

letterletter_Aletter_Bletter_C
A100
B010
C001

图 8.1:类别特征的独热编码

准备工作

我们将在接下来的两个食谱中使用 feature_enginescikit_learn 中的 OneHotEncoderOrdinalEncoder 模块。我们将继续使用 NLS 数据。

如何做到这一点…

来自 NLS 数据的若干特征适合进行独热编码。我们在以下代码块中编码其中的一些特征:

  1. 让我们从导入 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) 
    
  2. 接下来,我们为 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) 
  1. 我们可以选择的编码方式之一是 pandas 的 get_dummies 方法。我们可以使用它来指示我们希望转换 gendermaritalstatus 特征。get_dummiesgendermaritalstatus 的每个取值创建一个虚拟变量。例如,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的值。以这种方式创建冗余被不优雅地称为虚拟变量陷阱。为了避免这个问题,我们从每个组中删除一个虚拟变量。

注意

对于某些机器学习算法,比如线性回归,删除一个虚拟变量实际上是必需的。在估计线性模型的参数时,矩阵需要进行求逆。如果我们的模型有截距,并且包括所有虚拟变量,那么矩阵就无法求逆。

  1. 我们可以将get_dummiesdrop_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的替代方法是sklearnfeature_engine中的独热编码器。这些独热编码器的优势在于它们可以轻松集成到机器学习管道中,并且能够将从训练数据集中获取的信息传递到测试数据集中。

  1. 让我们使用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_engineOneHotEncoder完成与get_dummies相同的任务。使用feature_engine的优势是它提供了多种工具,可在 scikit-learn 数据管道中使用,包括能够处理训练集或测试集中的类别,但不能同时处理两者。

还有更多

我没有在本教程中讨论 scikit-learn 自带的 one-hot 编码器。它的工作原理与 feature_engine 的 one-hot 编码器非常相似。虽然使用其中一个没有比使用另一个有太多优势,但我发现 feature_enginetransformfit_transform 方法返回的是 DataFrame,而 scikit-learn 的这些方法返回的是 NumPy 数组,这一点挺方便的。

编码类别特征:顺序编码

类别特征可以是名义性的或顺序性的。性别和婚姻状况是名义性的,它们的值没有顺序。例如,未婚并不比离婚的值更高。

然而,当类别特征是顺序时,我们希望编码能捕捉到值的排序。例如,如果我们有一个特征,值为低、中和高,那么 one-hot 编码会丢失这个排序。相反,如果我们将低、中、高分别转化为 1、2、3 的值,这样会更好。我们可以通过顺序编码来实现这一点。

NLS 数据集中的大学入学特征可以视为顺序特征。其值从 1. 未入学3. 4 年制大学。我们应该使用顺序编码将其准备好用于建模。接下来,我们就这么做。

准备工作

我们将在本教程中使用来自 scikit-learnOrdinalEncoder 模块。

如何操作…

  1. 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 年制大学入学)具有相同的影响。

  1. 我们可以通过将上述数组传递给 categories 参数,告诉 OrdinalEncoder 按照隐含的顺序对值进行排序。然后,我们可以使用 fit_transform 方法来转换大学入学字段 colenroct99。(sklearn 的 OrdinalEncoderfit_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) 
    
  2. 让我们查看从结果 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-learnOrdinalEncoder 使用起来非常简单。我们在 步骤 2 开始时实例化了一个 OrdinalEncoder 对象,并传递了一个按意义排序的值数组作为类别。然后,我们将仅包含 colenroct99 列的训练数据传递给 OrdinalEncoderfit_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 日下载的。

如何做…

  1. 让我们从 COVID-19 数据中创建训练和测试 DataFrame,然后导入 feature_enginecategory_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 
  1. 我们可以再次使用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 
    

当分类特征具有许多唯一值时,特征哈希是一种替代的独热编码方法。

特征哈希将大量唯一的特征值映射到较少的虚拟变量上。我们可以指定要创建的虚拟变量数量。每个特征值仅映射到一个虚拟变量组合。然而,冲突是可能的——也就是说,一些特征值可能映射到相同的虚拟变量组合。随着我们减少请求的虚拟变量数量,冲突的数量会增加。

  1. 我们可以使用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,并且我们想要六个虚拟变量。我们使用了HashingEncoderfit_transform方法来拟合和转换我们的数据,正如我们在feature_engineOneHotEncoder和 scikit-learn 的OrdinalEncoder中所做的那样。

我们在最后三个配方中已经涵盖了常见的编码策略:独热编码、序数编码和特征哈希。在我们可以将几乎所有的分类特征应用到模型之前,几乎都会需要某种编码。但有时我们还需要以其他方式修改我们的特征,包括变换、分箱和缩放。我们将在接下来的三个配方中探讨修改特征的原因,并探索用于实现这些操作的工具。

使用数学变换

有时候,我们希望使用的特征不具备高斯分布,而机器学习算法假设我们的特征是以这种方式分布的。当出现这种情况时,我们要么需要改变使用的算法(例如选择 KNN 或随机森林而不是线性回归),要么转换我们的特征,使其近似于高斯分布。在本示例中,我们讨论了几种实现后者的策略。

准备工作

在本示例中,我们将使用来自 feature engine 的 transformation 模块。我们继续使用 COVID-19 数据,其中每个国家都有总病例和死亡数以及一些人口统计数据。

如何实现…

  1. 我们首先从 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) 
    
  2. 让我们看看各国总病例分布如何。我们还应计算偏度:

    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() 
    

这产生了以下直方图:

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_01.png

图 8.1:总 COVID-19 病例的直方图

这说明了总病例的非常高的偏度。实际上,它看起来是对数正态分布,这并不奇怪,考虑到非常低的值和几个非常高的值的大量存在。

  1. 让我们尝试一个对数变换。要使 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() 
    

这产生了以下直方图:

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_02.png

图 8.2:经对数变换后的总 COVID-19 病例的直方图

实际上,对数变换增加了分布低端的变异性,并减少了高端的变异性。这产生了一个更对称的分布。这是因为对数函数的斜率对较小值比对较大值更陡。

  1. 这确实是一个很大的改进,但我们还是尝试一个 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() 
    

这产生了以下直方图:

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_03.png

图 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_001.png

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_002.png

其中 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 数据进行等宽和等频分箱。

如何操作…

  1. 我们首先需要从feature_engine导入EqualFrequencyDiscretiserEqualWidthDiscretiser。我们还需要从 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) 
    
  2. 我们可以使用 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 
    
  3. 我们可以通过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 
    
  4. 接下来,我们创建一个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来生成它。等频分箱解决了偏斜和异常值问题。

  1. 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 个。

  1. 让我们来看看每个箱的范围。我们可以看到,由于分布顶部观察值数量较少,等宽分箱器甚至不能构建等宽箱:

    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

如何做…

  1. 我们首先实例化一个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 
    
  2. 让我们比较原始总案例变量的偏斜和峰度与分箱变量的偏斜和峰度。回想一下,我们期望具有高斯分布的变量的偏斜为 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 
    
  3. 让我们更仔细地查看每个箱中总案例值的范围。第一个箱的范围达到 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)缩放。最小-最大缩放将每个值替换为其在范围中的位置。更具体地说:

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_004.png

在这里,z[ij] 是最小-最大得分,x[ij] 是 i^(th) 观测值对应的 j^(th) 特征的值,min[j] 和 max[j] 分别是 j^(th) 特征的最小值和最大值。

标准缩放将特征值标准化为均值为 0 的数据。那些学过本科统计学的人会认识到这就是 z-score。具体来说:

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_005.png

在这里,x[ij] 是 i^(th) 观测值对应的 j^(th) 特征的值,u[j] 是 j 特征的均值,s[j] 是该特征的标准差。

准备就绪

我们将使用 scikit-learn 的预处理模块进行本食谱中的所有变换。我们将再次使用 COVID-19 数据。

如何进行…

我们可以使用 scikit-learn 的预处理模块来获取最小-最大和标准缩放器:

  1. 我们首先导入 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) 
    
  2. 现在我们可以运行最小-最大缩放器。正如我们在之前的食谱中使用 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 
    
  3. 我们以相同的方式运行标准缩放器:

    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_006.png

其中 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/B18596_08_007.pngj^(th) 特征的值,median[j]、3^(rd) quantile[j] 和 1^(st) quantile[j] 分别是 j^(th) 特征的中位数、第三四分位数和第一四分位数。鲁棒缩放对极端值的敏感度较低,因为它不使用均值或方差。

  1. 我们可以使用 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-learnpreprocessing模块使得使用各种缩放转换变得简单。我们只需实例化缩放器,然后运行fittransformfit_transform方法。

总结

本章中我们涵盖了广泛的特征工程技术。我们使用工具删除冗余或高度相关的特征。我们探讨了最常见的编码方法——独热编码、顺序编码和哈希编码。然后我们使用变换改善特征的分布。最后,我们使用常见的分箱和缩放方法来解决偏斜、峰度和异常值问题,并调整具有不同范围的特征。在下一章,我们将学习如何在汇总时修复杂乱的数据。

留下评论!

享受这本书吗?通过留下亚马逊评论来帮助像您这样的读者。扫描下面的二维码,免费获取您选择的电子书。

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/Review_copy.png

第九章:聚合时修复凌乱的数据

本书的前几章介绍了生成整个 DataFrame 汇总统计数据的技巧。我们使用了 describemeanquantile 等方法来实现这一点。本章讨论了更复杂的聚合任务:按类别变量聚合以及使用聚合来改变 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 每日数据和巴西的月度陆地温度数据。我们将添加逻辑来处理缺失数据和关键变量值在不同时间段之间的意外变化:

  1. 导入 pandasnumpy,并加载 COVID-19 和陆地温度数据:

    import pandas as pd
    coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"])
    ltbrazil = pd.read_csv("data/ltbrazil.csv") 
    
  2. 按位置和日期对数据进行排序:

    coviddaily = coviddaily.sort_values(['location','casedate']) 
    
  3. 使用 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}] 
  1. 从汇总值列表 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 
  1. 现在,我们对陆地温度数据做同样的处理。我们首先按 stationmonth 排序。

同时,删除温度缺失的行:

ltbrazil = ltbrazil.sort_values(['station','month'])
ltbrazil = ltbrazil.dropna(subset=['temperature']) 
  1. 排除每一周期之间变化较大的行。

计算年度平均温度,排除比上个月的温度高出或低于 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}] 
  1. 根据汇总值创建一个 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 步 中通过 locationcasedate 对 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 数组中。然后,我们遍历该数组,按组计算总和并检查值的意外变化:

  1. 导入pandasnumpy,并加载 COVID-19 和土地温度数据:

    import pandas as pd
    coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"])
    ltbrazil = pd.read_csv("data/ltbrazil.csv") 
    
  2. 创建一个位置列表:

    loclist = coviddaily.location.unique().tolist() 
    
  3. 使用 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 
  1. 对陆地温度数据进行排序,并删除温度缺失值的行:

    ltbrazil = ltbrazil.sort_values(['station','month'])
    ltbrazil = ltbrazil.dropna(subset=['temperature']) 
    
  2. 使用 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}] 
  1. 创建一个包含陆地温度平均值的 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 步开始处理陆地温度数据,首先按 stationmonth 排序,然后删除温度缺失值的行。第 5 步中的逻辑与前一个示例中的第 6 步几乎相同。主要的区别是,我们需要引用数组中站点(tempvalues[j][0])和温度(tempvalues[j][1])的位置。

还有更多…

当你需要遍历数据时,NumPy 数组通常比通过 itertuplesiterrows 遍历 pandas DataFrame 更快。此外,如果你尝试使用 itertuples 来运行第 3 步中的列表推导式,虽然是可行的,但你将需要等待较长时间才能完成。通常,如果你想对某一数据段做快速汇总,使用 NumPy 数组是一个合理的选择。

另见

本章剩余的示例依赖于 pandas DataFrame 中强大的 groupby 方法来生成分组总数。

使用 groupby 按组组织数据

在大多数数据分析项目中,我们必须按组生成汇总统计信息。虽然可以使用前一个示例中的方法完成这项任务,但在大多数情况下,pandas DataFrame 的 groupby 方法是一个更好的选择。如果 groupby 能够处理聚合任务——而且通常可以——那么它很可能是完成该任务的最有效方式。我们将在接下来的几个示例中充分利用 groupby。我们将在本示例中介绍基础知识。

准备工作

我们将在本食谱中处理 COVID-19 每日数据。

如何做到…

我们将创建一个 pandas 的groupby DataFrame,并使用它生成按组的汇总统计:

  1. 导入pandasnumpy,并加载 COVID-19 每日数据:

    import pandas as pd
    coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"]) 
    
  2. 创建一个 pandas 的groupby DataFrame:

    countrytots = coviddaily.groupby(['location'])
    type(countrytots) 
    
    <class 'pandas.core.groupby.generic.DataFrameGroupBy'> 
    
  3. 为每个国家创建第一次出现的行的 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 
  1. 为每个国家创建最后几行的 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'> 
    
  2. 获取某个国家的所有行:

    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 
    
  3. 遍历各组。

仅显示马耳他和科威特的行:

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 
  1. 显示每个国家的行数:

    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 
    
  2. 按国家显示汇总统计:

    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中,我们使用pandasgroupby方法创建一个groupby对象,传入一个列或多个列进行分组。一旦我们拥有了一个groupby的 DataFrame,我们可以使用与整个 DataFrame 生成汇总统计相同的工具来按组生成统计数据。describemeansum等方法可以在groupby的 DataFrame 或由其创建的系列上按预期工作,区别在于汇总统计会针对每个组执行。

步骤 3 和 4中,我们使用firstlast来创建包含每个组的第一次和最后一次出现的 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 做了比之前示例更复杂的聚合操作,利用了其灵活性:

  1. 导入 pandas 并加载 NLS 数据:

    import pandas as pd
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
    nls97.set_index("personid", inplace=True) 
    
  2. 查看数据的结构:

    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 
    
  3. 查看一些类别数据:

    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 
    
  4. 查看一些描述性统计信息:

    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 
    
  5. 按性别查看 学术能力评估测试SAT)数学成绩。

我们将列名传递给 groupby,根据该列进行分组:

nls97.groupby('gender')['satmath'].mean() 
gender
Female	487
Male	517
Name: satmath, dtype: float64 
  1. 按性别和最高学历查看 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 
  1. 按性别和最高学历查看 SAT 数学和语言成绩。

我们可以使用列表来汇总多个变量的值,在这种情况下是 satmathsatverbal

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 
  1. 对一个变量做多个聚合函数。

使用 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 
  1. 使用字典进行更复杂的聚合:

    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 
    

我们为 weeksworked06childathome 显示了相同的汇总统计数据,但我们也可以为每个变量指定不同的聚合函数,使用与 步骤 9 中相同的语法。

如何操作…

我们首先查看 DataFrame 中关键列的汇总统计信息。在 步骤 3 中,我们获得了类别变量的频率,在 步骤 4 中,我们得到了连续变量的一些描述性统计信息。生成按组统计数据之前,先查看整个 DataFrame 的汇总值是个不错的主意。

接下来,我们准备使用 groupby 创建汇总统计数据。这涉及三个步骤:

  1. 根据一个或多个类别变量创建 groupby DataFrame。

  2. 选择用于汇总统计数据的列。

  3. 选择聚合函数。

在这个示例中,我们使用了链式操作,一行代码完成了三件事。因此,nls97.groupby('gender')['satmath'].mean()步骤 5中做了三件事情:nls97.groupby('gender') 创建了一个 groupby DataFrame 对象,['satmath'] 选择了聚合列,mean() 是聚合函数。

我们可以像在步骤 5中那样传递列名,或者像在步骤 6中那样传递列名列表,来通过一个或多个列进行分组。我们可以使用一个变量列表来选择多个变量进行聚合,正如在步骤 7中使用[['satmath','satverbal']]一样。

我们可以链式调用特定的汇总函数,例如meancountmax。另外,我们也可以将一个列表传递给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 数据。

如何操作…

我们将创建自己的函数,定义我们按组需要的汇总统计量:

  1. 导入 pandas 和 NLS 数据:

    import pandas as pd
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
    nls97.set_index("personid", inplace=True) 
    
  2. 创建一个函数来定义四分位数范围:

    def iqr(x):
    ...   return x.quantile(0.75) - x.quantile(0.25) 
    
  3. 运行四分位数范围函数。

创建一个字典,指定每个分析变量运行的聚合函数:

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 
  1. 定义一个函数来返回选定的汇总统计量:

    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 
    
  2. 使用 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 
  1. 使用 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 
    
  2. 反而用 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。这将根据索引的第二级(具有 qr1medqr3count 值)创建列。

还有更多……

有趣的是,随着教育程度的提高,工作周数和家中孩子数量的四分位数范围显著下降。那些教育程度较低的群体在这些变量上似乎有更大的变异性。这应该被更仔细地检查,并且对于统计检验有影响,因为统计检验假设各组之间的方差是相同的。

另见

第十一章《整理与重塑数据》中,我们对 stackunstack 做了更多的操作。

使用 groupby 改变 DataFrame 的分析单位

在前一个步骤的最后,我们创建的 DataFrame 是我们努力按组生成多个汇总统计量时的一个意外副产品。有时我们确实需要聚合数据来改变分析的单位——例如,从每个家庭的月度公用事业费用到每个家庭的年度公用事业费用,或从学生按课程的成绩到学生的整体 平均绩点 (GPA)。

groupby 是一个很好的工具,特别适用于折叠分析单位,特别是在需要进行汇总操作时。当我们只需要选择未重复的行时——也许是每个个体在给定间隔内的第一行或最后一行——那么 sort_valuesdrop_duplicates 的组合就能胜任。但是,我们经常需要在折叠之前对每组的行进行一些计算。这时 groupby 就非常方便了。

准备工作

我们将再次处理每日病例数据,该数据每天每个国家有一行记录。我们还将处理巴西陆地温度数据,该数据每个气象站每个月有一行记录。

如何做…

我们将使用 groupby 创建一个按组的汇总值的 DataFrame:

  1. 导入 pandas 并加载 COVID-19 和陆地温度数据:

    import pandas as pd
    coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"])
    ltbrazil = pd.read_csv("data/ltbrazil.csv") 
    
  2. 让我们查看数据的样本,以便回顾其结构。每个国家(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 
    
  3. 现在,我们可以将 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 
    
  4. 让我们看一看巴西平均温度数据的一些行:

    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 
    
  5. 创建一个包含巴西每个气象站平均温度的 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_casesnew_deaths 作为聚合变量,并选择 sum 作为聚合函数。这将为每个组(casedate)产生 new_casesnew_deaths 的总和。根据您的目的,您可能不希望 casedate 成为索引,如果没有将 as_index 设置为 False 将会发生这种情况。

我们经常需要在不同的聚合变量上使用不同的聚合函数。我们可能想要对一个变量取第一个(或最后一个)值,并对另一个变量的值按组取平均值。这就是我们在 步骤 5 中所做的。我们通过将一个字典传递给 agg 函数来实现这一点,字典的键是我们的聚合变量,值是要使用的聚合函数。

使用 pivot_table 改变 DataFrame 的分析单位

在前一个示例中,我们可以使用 pandas 的 pivot_table 函数而不是 groupbypivot_table 可以用于根据分类变量的值生成汇总统计信息,就像我们用 groupby 做的那样。pivot_table 函数还可以返回一个 DataFrame,这在本示例中将会看到。

准备工作

我们将再次处理 COVID-19 每日病例数据和巴西陆地温度数据。温度数据每个气象站每个月有一行记录。

如何做…

让我们从 COVID-19 数据创建一个 DataFrame,显示每一天在所有国家中的总病例数和死亡人数:

  1. 我们首先重新加载 COVID-19 和温度数据:

    import pandas as pd
    coviddaily = pd.read_csv("data/coviddaily.csv", parse_dates=["casedate"])
    ltbrazil = pd.read_csv("data/ltbrazil.csv") 
    
  2. 现在,我们可以调用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 
    
  3. 让我们尝试使用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 空间,与作者和其他读者讨论:

discord.gg/p8uSgEAETX

https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/py-dt-cln-cb-2e/img/QR_Code10336218961138498953.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值