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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:解决合并 DataFrame 时的数据问题

在大多数数据清洗项目的某个阶段,分析师必须将来自不同数据表的数据进行合并。这包括将具有相同结构的数据追加到现有的数据行中,或执行合并以从另一张数据表中提取列。前者有时称为垂直合并数据,或连接数据,而后者则称为水平合并数据,或合并数据。

合并可以根据合并依据列值的重复量进行分类。在一对一合并中,合并依据列的值在每张数据表中各出现一次。在一对多合并中,合并依据列的值在一方没有重复,而在另一方则存在重复。在多对多合并中,合并依据列的值在两方中都有重复。合并过程进一步复杂化,因为数据表中的合并依据值通常没有完全匹配的关系;每个数据表的合并依据列可能包含在另一张数据表中没有的值。

在合并数据时,可能会引入新的数据问题。当数据被追加时,即使列的名称和数据类型相同,也可能与原始数据具有不同的逻辑值。对于合并操作,每当合并依据的某一方缺少值时,该方的其他列也将缺少值。对于一对一或一对多的合并,合并依据值可能会出现意外的重复,导致其他列的值被不小心重复。

本章将介绍如何垂直和水平合并 DataFrame,并考虑如何处理合并时常见的数据问题。具体来说,本章的内容将涵盖以下主题:

  • 垂直合并 DataFrame

  • 一对一合并

  • 通过多列进行一对一合并

  • 一对多合并

  • 多对多合并

  • 开发合并例程

技术要求

为了完成本章中的所有内容,您需要使用 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码也可以在 pandas 1.5.3 或更高版本上运行。

本章的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition

垂直合并 DataFrame

在某些情况下,我们需要将一张数据表中的行追加到另一张数据表中。这通常是将几乎相同列和数据类型的表格数据进行合并。例如,我们每个月可能会获得一份包含医院病人结果的新的 CSV 文件,并需要将其添加到现有的数据中。或者,我们可能会在某个学区的办公室工作,接收来自不同学校的数据。在这种情况下,我们可能会想要在进行分析之前先将这些数据合并。

即使跨月份和跨学校(在这些示例中)的数据结构在理论上是相同的,实际情况中可能并非如此。商业实践可能会从一个时期到另一个时期发生变化。这可能是有意为之,也可能是由于人员流动或其他外部因素而无意发生的。一个机构或部门的实践可能与另一个有所不同,并且某些数据值可能在某些机构中有所不同,甚至完全缺失。

当我们放松警惕时,通常会遇到看似相似的数据变化,通常是在我们开始假设新数据将与旧数据相似时。我每次合并数据时都会提醒自己这一点。在本章的其余部分,我将使用“纵向合并”或“附加”来指代将数据纵向合并。

在本食谱中,我们将使用 pandas 的concat函数将一组 pandas DataFrame 的行附加到另一个 DataFrame 中。我们还将对concat操作进行一些常见检查,以确认最终的 DataFrame 是否符合我们的预期。

准备工作

在本食谱中,我们将使用来自多个国家的陆地温度数据。这些数据包括 2023 年期间每个国家多个气象站的月平均温度、纬度、经度和海拔。每个国家的数据都保存在一个 CSV 文件中。

数据备注

陆地温度 DataFrame 包含 2023 年来自全球 12,000 多个气象站的平均温度(以°C 为单位),尽管大多数气象站位于美国。原始数据是从全球历史气候学网络集成数据库获取的。美国国家海洋和大气管理局在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly上提供公开使用。

如何做……

在本食谱中,我们将纵向合并结构相似的 DataFrame,检查合并后数据的值,并修复缺失值。让我们开始吧:

  1. 导入pandasnumpy,以及os模块:

    import pandas as pd
    import numpy as np
    import os 
    
  2. 从喀麦隆和阿曼加载数据并检查行数和列数:

    ltcameroon = pd.read_csv("data/ltcountry/ltcameroon.csv")
    ltoman = pd.read_csv("data/ltcountry/ltoman.csv")
    ltcameroon.shape 
    
    (48, 11) 
    
    ltoman.shape 
    
    (288, 10) 
    

比较喀麦隆和阿曼 DataFrame 中的列。通过查看列,我们可以看到喀麦隆的 DataFrame 中有latabs列,而阿曼的 DataFrame 中没有。我们可以使用symetric_difference来确认这一点,并确保没有其他列只出现在一个 DataFrame 中而不在另一个中。它显示latabs是唯一只在一个 DataFrame 中出现的列:

ltcameroon.columns 
Index(['locationid', 'year', 'month', 'temperature',
       'latitude', 'longitude', 'elevation', 'station',
       'countryid', 'country', 'latabs'],
      dtype='object') 
ltoman.columns 
Index(['locationid', 'year', 'month', 'temperature',
       'latitude', 'longitude', 'elevation', 'station',
       'countryid', 'country'],
      dtype='object') 
ltcameroon.columns.\
  symmetric_difference(ltoman.columns) 
Index(['latabs'], dtype='object') 
  1. 我们仍然可以合并这两个 DataFrame。唯一的问题是,现在我们有一个名为latabs的列,该列在喀麦隆的所有行中都有非缺失值,而在阿曼的所有行中都是缺失值。我们将在本食谱的最后一步解决这个问题:

    ltall = pd.concat([ltcameroon, ltoman])
    ltall.country.value_counts() 
    
    country
    Oman        288
    Cameroon     48
    Name: count, dtype: int64 
    
    ltall[['country','station','temperature',
      'latitude','latabs']].\
      sample(5, random_state=3) 
    
     country          station    temperature    latitude    latabs
    276        Oman            BAHLA          21.44      23.000       NaN
    26         Oman             DIBA          21.85      25.617       NaN
    281        Oman      RAS_AL_HADD          23.74      22.300       NaN
    15     Cameroon           GAROUA          33.91       9.336     9.336
    220        Oman      SOHAR_MAJIS          30.85      24.467       NaN 
    
    ltall.groupby(['country'])['latabs'].count() 
    
    country
    Cameroon    48
    Oman         0
    Name: latabs, dtype: int64 
    
  2. 创建一个函数来进行合并,并结合我们已做的数据检查。该函数接受一个文件名列表,遍历列表,读取与每个文件名相关联的 CSV 文件到一个 DataFrame 中,然后合并该 DataFrame。我们可以得到预期的计数。我们没有检查列名,接下来我们将在下一步进行。

    def concatfiles(filelist):
      directory = "data/ltcountry/"
      ltall = pd.DataFrame()
      for filename in filelist:
        ltnew = pd.read_csv(directory + filename + ".csv")
        print(filename + " has " +
          str(ltnew.shape[0]) + " rows.")
        ltall = pd.concat([ltall, ltnew])
      return ltall
    ltall = concatfiles(['ltcameroon','ltoman']) 
    
    ltcameroon has 48 rows.
    ltoman has 288 rows. 
    
    ltall.country.value_counts() 
    
    country
    Oman        288
    Cameroon     48
    Name: count, dtype: int64 
    

如果我们有很多文件需要合并,创建一个文件名列表可能会很麻烦。我们可以通过加载文件夹中所有具有 CSV 文件扩展名的文件,利用 Python 的os模块来帮助我们。接下来我们来做这件事,同时还要加入一些代码来检查列。我们将基于前一步的代码进行构建。

  1. 合并文件夹中所有国家的数据文件。

遍历包含每个国家 CSV 文件的文件夹中的所有文件名。使用endswith方法检查文件名是否具有 CSV 文件扩展名。使用read_csv创建一个新的 DataFrame 并打印行数。使用concat将新 DataFrame 的行附加到已附加的行上。最后,显示最近的 DataFrame 中缺失的列,或者是最近的 DataFrame 中有而之前的 DataFrame 中没有的列:

def concatallfiles():
  directory = "data/ltcountry"
  ltall = pd.DataFrame()
  for filename in os.listdir(directory):
    if filename.endswith(".csv"):
      fileloc = os.path.join(directory, filename)

      # open the next file
      with open(fileloc):
        ltnew = pd.read_csv(fileloc)
        print(filename + " has " +
          str(ltnew.shape[0]) + " rows.")
        ltall = pd.concat([ltall, ltnew])

        # check for differences in columns
        columndiff = ltall.columns.\
          symmetric_difference(ltnew.columns)
        if (not columndiff.empty):
          print("", "Different column names for:",
           filename, columndiff, "", sep="\n")

  return ltall 
  1. 使用我们刚刚创建的函数来读取子文件夹中所有的国家 CSV 文件,显示行数并检查列名。我们再次看到ltoman DataFrame 缺少latabs列:

    ltall = concatallfiles() 
    
    ltpoland.csv has 120 rows.
    ltcameroon.csv has 48 rows.
    ltmexico.csv has 852 rows.
    ltjapan.csv has 1800 rows.
    ltindia.csv has 1116 rows.
    ltoman.csv has 288 rows.
    Different column names for:
    ltoman.csv
    Index(['latabs'], dtype='object')
    ltbrazil.csv has 1008 rows. 
    
  2. 显示一些合并后的数据:

    ltall[['country','station','month',
     'temperature','latitude']].\
     sample(5, random_state=1) 
    
     country               station   month   temperature   latitude
    583   Japan             TOKUSHIMA       4            16         34
    635   India   NEW_DELHI_SAFDARJUN       7            31         29
    627  Mexico      COATZACOALCOSVER       9            30         18
    28   Poland               WLODAWA       3             5         52
    775  Mexico           ARRIAGACHIS      11            28         16 
    
  3. 检查合并数据中的值。

注意到阿曼的latabs值全部缺失。这是因为阿曼的 DataFrame 中缺少latabs列(latabs是每个站点纬度的绝对值):

ltall.country.value_counts().sort_index() 
country
Brazil      1008
Cameroon      48
India       1116
Japan       1800
Mexico       852
Oman         288
Poland       120
Name: count, dtype: int64 
ltall.groupby(['country']).\
  agg({'temperature':['mean','max','count'],
  'latabs':['mean','max','count']}) 
 temperature              latabs         
                mean   max   count     mean   max   count
country                                       
Brazil            25    34     900       14    34    1008
Cameroon          27    35      39        8    10      48
India             26    35    1096       21    34    1116
Japan             14    31    1345       36    45    1800
Mexico            23    36     685       22    32     852
Oman              28    38     264      NaN   NaN       0
Poland            10    21     120       52    55     120 
  1. 修复缺失的值。

将阿曼的latabs值设置为latitude值。(阿曼的所有latitude值都位于赤道以北且为正数。在全球历史气候学网络集成数据库中,赤道以北的纬度值为正,而赤道以南的纬度值为负)。具体操作如下:

ltall['latabs'] = np.where(ltall.country=="Oman", ltall.latitude, ltall.latabs)
ltall.groupby(['country']).\
...   agg({'temperature':['mean','max','count'],
...   'latabs':['mean','max','count']}) 
 temperature             latabs         
                mean   max   count     mean   max   count
country                                       
Brazil            25    34     900       14    34    1008
Cameroon          27    35      39        8    10      48
India             26    35    1096       21    34    1116
Japan             14    31    1345       36    45    1800
Mexico            23    36     685       22    32     852
Oman              28    38     264       22    26     288
Poland            10    21     120       52    55     120 

到此为止,我们已经将选定文件夹中找到的七个 CSV 文件的数据合并了。我们还确认了已附加正确数量的行,找出了某些文件中缺失的列,并修复了缺失的值。

它是如何工作的…

我们在第 3 步中将一个 pandas DataFrame 的列表传递给了 pandas 的concat函数。第二个 DataFrame 的行被附加到了第一个 DataFrame 的底部。如果我们列出了第三个 DataFrame,那么这些行就会附加到前两个 DataFrame 合并后的行上。在合并之前,我们在第 2 步使用了shape属性来检查行数,并检查了列名。在第 3 步合并后,我们确认了结果 DataFrame 包含了每个国家的预期行数。

我们有时需要连接两个或三个以上的文件。第 4 步第 6 步指导我们通过定义一个函数来重复代码,从而处理多个文件。在第 4 步中,我们将文件名列表传递给了这个函数。

第 5 步第 6 步中,我们查找了指定文件夹中的所有 CSV 文件,将找到的每个文件加载到内存中,然后将每个文件的行追加到一个 DataFrame 中。我们打印了每个加载的数据文件的行数,以便稍后将这些数字与拼接数据中的总数进行比较。我们还标识了任何与其他文件列不同的 DataFrame。在第 8 步中,我们使用 value_counts 确认了每个国家的行数是否正确。

pandas 的 groupby 方法可以用来检查每个原始 DataFrame 中的列值。我们按国家分组,因为它能标识每个原始 DataFrame 的行——每个 DataFrame 中的所有行对于国家都有相同的值。(即使该信息不用于后续分析,始终在拼接后的 DataFrame 中保留标识原始 DataFrame 的列也是很有帮助的。)在第 8 步中,这帮助我们注意到阿曼的 latabs 列没有值。在第 9 步中,我们替换了阿曼 latabs 列的缺失值。

还有更多内容…

根据你正在附加的 DataFrame 的大小和工作站的可用内存,合并 DataFrame 可能会消耗机器的资源,甚至在内存使用超过一定的资源量时导致代码提前终止。确保数据文件尽可能高效地存储数据始终是一个好主意。例如,将数字值进行降位处理,或者在适当时将字符数据转换为分类数据,都是良好的做法。

另请参见

我们在第九章《修复聚合时的脏数据》中详细讨论了强大的 pandas groupby 方法。

我们在第六章《使用 Series 操作清理和探索数据》中讨论了 NumPy 的 where 函数。

进行一对一合并

本章的其余部分将探讨横向合并数据;即,将一个数据表的列与另一个数据表的列合并。借用 SQL 开发中的术语,我们通常将这种操作称为连接操作:左连接、右连接、内连接和外连接。本节将研究一对一合并,其中合并依据的值在两个文件中都没有重复。后续的章节将展示一对多合并,其中合并依据的值在数据表中有重复,以及多对多合并,其中合并依据的值在左和右数据表中都有重复。

我们常常说合并的左侧和右侧,在本章中我们将遵循这一约定。但这并没有实际的意义,除了为了更清晰的阐述。如果 A 是左侧数据表,B 是右侧数据表,我们可以通过合并完成完全相同的操作,反之亦然。

在本章中,我使用了“合并列”和“合并值”这些表达方式,而不是“关键列”或“索引列”。这样可以避免与 pandas 索引对齐产生可能的混淆。索引可以作为合并列使用,但也可以使用其他列。我还希望避免在讨论中依赖于关系数据库中的概念,如主键或外键。当我们从关系型系统中提取数据时,了解哪些数据列作为主键或外键是有帮助的,且我们在设置 pandas 索引时应考虑这一点。但是,对于大多数数据清洗项目中的合并,往往超出了这些键的范畴。

在简单的 1 对 1 合并情况下,左侧数据表中的每一行都会根据合并值与右侧数据表中的一行(且仅一行)匹配。当合并值出现在一张数据表中,但另一张数据表中没有时,合并结果的处理方式取决于指定的连接类型。下图展示了四种不同的连接类型:

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

图 10.1:展示四种不同类型连接的图示

当两个数据表通过内连接合并时,只有当合并值同时出现在左侧和右侧数据表中时,相关行才会保留。这是左侧和右侧数据表的交集,如前图中的B所示。外连接会返回所有行;也就是说,返回在两个数据表中都出现合并值的行、在左侧数据表中出现但在右侧数据表中未出现的行,以及在右侧数据表中出现但在左侧数据表中未出现的行——分别是BAC。这被称为并集。左连接返回合并值在左侧数据表中出现的行,无论它们是否出现在右侧数据表中。这是AB。右连接返回合并值在右侧数据表中出现的行,无论它们是否出现在左侧数据表中。

缺失值可能由外连接、左连接或右连接产生。这是因为返回的合并数据表会在合并条件未找到的列中出现缺失值。例如,在执行左连接时,左侧数据集可能包含一些在右侧数据集中没有出现的合并条件值。在这种情况下,右侧数据集的所有列都会缺失。(这里我说可能是因为可以执行外连接、左连接或右连接,得到与内连接相同的结果,因为相同的合并条件值出现在两边。有时,我们做左连接是为了确保返回的所有行都来自左侧数据集,并且仅返回这些行。)

在本教程中,我们将查看四种连接类型。

准备工作

我们将处理来自全国纵向调查NLS)的两个文件。这两个文件每个包含一行数据。一个包含就业、教育程度和收入数据,另一个文件包含受访者父母的收入和教育程度数据。

数据说明

全国纵向调查NLS),由美国劳工统计局管理,是对 1997 年开始进行调查时在高中就读的个人进行的纵向调查。参与者每年都会接受调查,直到 2023 年。调查结果可以在nlsinfo.org上公开获取。

如何操作…

在本教程中,我们将对两个数据框执行左连接、右连接、内连接和外连接,每个合并条件值有一行数据。让我们开始吧:

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

    import pandas as pd
    nls97 = pd.read_csv("data/nls97f.csv", low_memory=False)
    nls97.set_index("personid", inplace=True)
    nls97add = pd.read_csv("data/nls97add.csv") 
    
  2. 查看一些 NLS 数据:

    nls97.head() 
    
     gender    birthmonth    birthyear  ...  \
    personid                                       ...  
    135335      Female             9         1981  ...  
    999406        Male             7         1982  ...  
    151672      Female             9         1983  ...  
    750699      Female             2         1981  ...  
    781297        Male            10         1982  ...  
                  colenrfeb22   colenroct22    originalid 
    personid                                          
    135335                NaN           NaN             1 
    999406                NaN           NaN             2 
    151672    1\. Not enrolled           NaN             3 
    750699                NaN           NaN             4 
    781297                NaN           NaN             5 
    [5 rows x 106 columns] 
    
    nls97.shape 
    
    (8984, 106) 
    
    nls97add.head() 
    
     originalid    motherage    parentincome  \
    0             1           26              -3  
    1             2           19              -4  
    2             3           26           63000  
    3             4           33           11700  
    4             5           34              -3  
         fatherhighgrade    motherhighgrade 
    0                 16                  8 
    1                 17                 15 
    2                 -3                 12 
    3                 12                 12 
    4                 12                 12 
    
    nls97add.shape 
    
    (8984, 5) 
    
  3. 检查originalid的唯一值数量是否等于行数。

我们将稍后使用originalid作为我们的合并列:

nls97.originalid.nunique()==nls97.shape[0] 
True 
nls97add.originalid.nunique()==nls97add.shape[0] 
True 
  1. 创建一些不匹配的 ID。

不幸的是,NLS 数据对于我们的目的来说有点过于干净。因此,我们将会故意修改一些originalid的值:

nls97 = nls97.sort_values('originalid')
nls97add = nls97add.sort_values('originalid')
nls97.loc[[135335,999406], "originalid"] = \
  nls97.originalid+10000
nls97.originalid.head(2) 
personid
135335    10001
999406    10002
Name: originalid, dtype: int64 
nls97add.loc[[0,1], "originalid"] = \
  nls97add.originalid+20000
nls97add.originalid.head(2) 
0    20001
1    20002
Name: originalid, dtype: int64 
  1. 使用join执行左连接。

nls97是左数据框,nls97add是右数据框,当我们以这种方式使用join时。显示不匹配 ID 的值。注意,在右数据框没有匹配 ID 时,来自右数据框的列值都会缺失(originalid值 10001 和 10002 出现在左数据框中,但右数据框中没有这些 ID):

nls97.set_index("originalid", inplace=True)
nls97add.set_index("originalid", inplace=True)
nlsnew = nls97.join(nls97add)
nlsnew.loc[nlsnew.index>9999, ['originalid','gender','birthyear','motherage','parentincome']] 
 gender    birthyear    motherage    parentincome
originalid                                           
10001         Female         1981          NaN             NaN
10002           Male         1982          NaN             NaN 
  1. 使用merge执行左连接。

第一个数据框是左数据框,第二个数据框是右数据框。使用on参数指定合并的列。将how参数的值设置为left以执行左连接。我们得到的结果与使用join时相同:

nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="left")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']] 
 gender    birthyear    motherage   parentincome
originalid                                
10001         Female         1981          NaN            NaN
10002           Male         1982          NaN            NaN 
  1. 执行右连接。

使用右连接时,当左数据框中没有匹配 ID 时,左数据框的值会缺失:

nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="right")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']] 
 gender    birthyear    motherage    parentincome
originalid                               
20001           NaN          NaN           26              -3
20002           NaN          NaN           19              -4 
  1. 执行内连接。

在内连接后,所有不匹配的 ID(值大于9999)都不会出现。这是因为它们在两个数据框中都没有出现:

nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="inner")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']] 
Empty DataFrame
Columns: [gender, birthyear, motherage, parentincome]
Index: [] 
  1. 执行外连接。

这会保留所有的行,因此左侧 DataFrame 中有合并列值但右侧没有的行将被保留(originalid 值为 1000110002),而右侧 DataFrame 中有合并列值但左侧没有的行也会被保留(originalid 值为 2000120002):

nlsnew = pd.merge(nls97, nls97add, on=['originalid'], how="outer")
nlsnew.loc[nlsnew.index>9999, ['gender','birthyear','motherage','parentincome']] 
 gender    birthyear    motherage      parentincome
originalid                                
10001         Female        1,981          NaN               NaN
10002           Male        1,982          NaN               NaN
20001            NaN          NaN           26                -3
20002            NaN          NaN           19                -4 
  1. 创建一个函数来检查 ID 不匹配。

该函数接受一个左侧和一个右侧的 DataFrame,以及一个合并列。它执行外连接,因为我们想查看在任一 DataFrame 中,或者两者中都存在的合并列值:

def checkmerge(dfleft, dfright, idvar):
...   dfleft['inleft'] = "Y"
...   dfright['inright'] = "Y"
...   dfboth = pd.merge(dfleft[[idvar,'inleft']],\
...     dfright[[idvar,'inright']], on=[idvar], how="outer")
...   dfboth.fillna('N', inplace=True)
...   print(pd.crosstab(dfboth.inleft, dfboth.inright))
...
checkmerge(nls97.reset_index(),nls97add.reset_index(), "originalid") 
inright    N       Y
inleft         
N          0       2
Y          2    8982 

到此为止,我们展示了如何在一对一合并中执行四种类型的连接。

它是如何工作的…

一对一的合并相对简单。合并列值在左侧和右侧的 DataFrame 中仅出现一次。然而,某些合并列值可能只出现在其中一个 DataFrame 中。这使得连接的类型变得重要。如果所有的合并列值都出现在两个 DataFrame 中,那么左连接、右连接、内连接或外连接将返回相同的结果。我们在前几步中查看了这两个 DataFrame。

第 3 步中,我们确认了按 originalid 列合并时,唯一值的数量等于两个 DataFrame 中的行数。这告诉我们将进行一对一的合并。

如果合并列是索引,则执行左连接的最简单方法是使用 join DataFrame 方法。我们在第 5 步中做了这个操作。我们将右侧 DataFrame 传递给左侧 DataFrame 的 join 方法。当我们在第 6 步中使用 pandas 的 merge 函数执行左连接时,返回了相同的结果。我们使用 how 参数指定左连接,并通过 on 来指示合并列。

第 7 步第 9 步中,我们分别执行了右连接、内连接和外连接。这是通过 how 参数指定的,这是这些步骤中唯一不同的部分。

我们在第 10 步中创建的简单 checkmerge 函数统计了在一个 DataFrame 中存在但在另一个 DataFrame 中不存在的合并列值的行数,以及两个 DataFrame 中都存在的值的数量。将这两个 DataFrame 的副本传递给此函数告诉我们,左侧 DataFrame 中有两行不在右侧,右侧 DataFrame 中有两行不在左侧,且有 8,982 行在两者中都存在。

还有更多…

在进行任何非平凡的合并之前,你应该运行一个类似于我们在第 10 步中创建的 checkmerge 函数——在我看来,几乎所有的合并都算是非平凡合并。

merge 函数比我在本例中使用的示例更灵活。例如,在第 6 步中,我们不需要将左侧的 DataFrame 作为第一个参数。我本可以像这样显式地指定左侧和右侧的 DataFrame:

nlsnew = pd.merge(right=nls97add, left=nls97, on=['originalid'], how="left") 

我们还可以通过使用 left_onright_on 来指定左侧和右侧 DataFrame 的不同合并列,而不是使用 on

nlsnew = pd.merge(nls97, nls97add, left_on=['originalid'], right_on=['originalid'], how="left") 

merge函数的灵活性使它成为每当我们需要水平合并数据时的一个极好的工具。

使用多个列进行一对一合并

我们用来执行一对一合并的逻辑同样适用于多个合并列的合并。无论是两个还是更多的合并列,内连接、外连接、左连接和右连接的工作方式都是一样的。我们将在本食谱中演示这一点。

准备工作

我们将在本食谱中使用 NLS 数据,特别是 2017 年至 2021 年的工作周和大学注册数据。工作周和大学注册文件每个文件每年包含一行数据。

如何操作…

我们将使用多个合并列对两个数据框进行一对一合并。让我们开始:

  1. 导入pandas并加载 NLS 工作周和大学注册数据:

    import pandas as pd
    nls97weeksworked = pd.read_csv("data/nls97weeksworked.csv")
    nls97colenr = pd.read_csv("data/nls97colenr.csv") 
    
  2. 看一下部分 NLS 工作周数据:

    nls97weeksworked.loc[nls97weeksworked.\
      originalid.isin([2,3])] 
    
     originalid    year    weeksworked
    5            2    2017             52
    6            2    2018             52
    7            2    2019             52
    8            2    2020             52
    9            2    2021             46
    10           3    2017             52
    11           3    2018             52
    12           3    2019              9
    13           3    2020              0
    14           3    2021              0 
    
    nls97weeksworked.shape 
    
    (44920, 3) 
    
    nls97weeksworked.originalid.nunique() 
    
    8984 
    
  3. 看一下部分 NLS 大学注册数据:

    nls97colenr.loc[nls97colenr.\
      originalid.isin([2,3])] 
    
     originalid    year             colenr
    1                 2    2017    1\. Not enrolled
    2                 3    2017    1\. Not enrolled
    8985              2    2018    1\. Not enrolled
    8986              3    2018    1\. Not enrolled
    17969             2    2019    1\. Not enrolled
    17970             3    2019    1\. Not enrolled
    26953             2    2020    1\. Not enrolled
    26954             3    2020    1\. Not enrolled
    35937             2    2021    1\. Not enrolled
    35938             3    2021    1\. Not enrolled 
    
    nls97colenr.shape 
    
    (44920, 3) 
    
    nls97colenr.originalid.nunique() 
    
    8984 
    
  4. 检查合并列中的唯一值。

我们获得的按列值组合的合并数量(44,920)与两个数据框中的行数相同:

nls97weeksworked.groupby(['originalid','year'])\
...   ['originalid'].count().shape 
(44920,) 
nls97colenr.groupby(['originalid','year'])\
...   ['originalid'].count().shape 
(44920,) 
  1. 检查合并列中的不匹配情况。所有originalidyear的组合在两个文件中都有出现:

    def checkmerge(dfleft, dfright, idvar):
    ...   dfleft['inleft'] = "Y"
    ...   dfright['inright'] = "Y"
    ...   dfboth = pd.merge(dfleft[idvar + ['inleft']],\
    ...     dfright[idvar + ['inright']], on=idvar, how="outer")
    ...   dfboth.fillna('N', inplace=True)
    ...   print(pd.crosstab(dfboth.inleft, dfboth.inright))
    ...
    checkmerge(nls97weeksworked.copy(),nls97colenr.copy(), ['originalid','year']) 
    
    inright      Y
    inleft       
    Y        44920 
    
  2. 使用多个合并列执行合并:

    nls97workschool = \
      pd.merge(nls97weeksworked, nls97colenr,
      on=['originalid','year'], how="inner")
    nls97workschool.shape 
    
    (44920, 4) 
    
    nls97workschool.loc[nls97workschool.\
      originalid.isin([2,3])] 
    
     originalid    year    weeksworked             colenr
    5            2    2017             52    1\. Not enrolled
    6            2    2018             52    1\. Not enrolled
    7            2    2019             52    1\. Not enrolled
    8            2    2020             52    1\. Not enrolled
    9            2    2021             46    1\. Not enrolled
    10           3    2017             52    1\. Not enrolled
    11           3    2018             52    1\. Not enrolled
    12           3    2019              9    1\. Not enrolled
    13           3    2020              0    1\. Not enrolled
    14           3    2021              0    1\. Not enrolled 
    

这些步骤表明,当有多个合并列时,运行合并的语法几乎没有变化。

工作原理…

NLS 数据中的每个人在工作周和大学注册数据框中都有五行数据,每年从 2017 年到 2021 年都有一行。两个文件都包含 44,920 行数据,涉及 8,984 个独特的个体(由originalid表示)。这一切都是有道理的(8,984*5=44,920)。

第 4 步 确认了我们将用于合并的列组合即使在个人数据重复的情况下也不会重复。每个人每年只有一行数据。这意味着工作周和大学注册数据的合并将是一次一对一的合并。在第 5 步中,我们检查了是否有在一个数据框中存在但另一个数据框中不存在的个人和年份组合。没有发现。

最后,在第 6 步中,我们准备好了进行合并。我们将on参数设置为一个包含两个列名的列表(['originalid','year']),以告诉合并函数使用这两列进行合并。我们指定了内连接,尽管使用任何连接都会得到相同的结果。这是因为相同的合并列值在两个文件中都存在。

还有更多…

我们在前一个食谱中讨论的所有合并数据的逻辑和潜在问题都适用,无论我们是用一个合并列还是多个合并列。内连接、外连接、右连接和左连接的工作方式相同。我们仍然可以在执行合并之前计算出返回的行数。我们还应该检查合并列的唯一值数量以及两个数据框之间的匹配情况。

如果你在之前的章节中使用过涉及 NLS 工作周和大学入学数据的示例,你可能注意到这里的结构不同。在之前的示例中,每个人有一行数据,包含多个关于工作周数和大学入学的列,表示多年的工作周数和大学入学情况。例如,weeksworked21表示 2021 年工作的周数。我们在本示例中使用的工作周数和大学入学数据框架结构比我们在早期示例中使用的 NLS 数据框架更为整洁。我们将在第十一章《整理和重塑数据》中学习如何整理数据。

执行一对多合并

在一对多合并中,左侧数据表的合并列(或列组合)具有不重复的值,而右侧数据表中的这些列则具有重复的值。对于这种合并,我们通常会使用内连接或左连接。当合并列的值在右侧数据表中缺失时,选择使用哪种连接方式是非常重要的。在进行左连接时,所有从内连接中返回的行都会返回,此外,对于左侧数据集中存在但右侧数据集缺失的每个合并列值,还会多返回一行。对于这些额外的行,右侧数据表中所有列的值将在结果合并数据中缺失。这个相对简单的事实实际上非常重要,在编写一对多合并代码之前,应该仔细考虑。

这是我开始感到紧张的地方,我认为在这里感到紧张是有道理的。在进行数据清洗工作坊时,我会在开始这个话题之前停顿一下,告诉大家,“在你能带上朋友之前,不要开始一对多合并。”

当然,我是在开玩笑……大部分时间是这样。我想表达的重点是,在进行非平凡的合并之前,应该有所停顿,而一对多的合并从来都不简单。我们的数据结构可能发生很多变化。

具体来说,在开始之前,我们需要了解将要合并的两个数据框架的一些信息。首先,我们应该知道每个数据框架上哪些列可以作为合并列。一对多合并通常用于从企业数据库系统中重新捕捉关系,并且需要与使用的主键和外键一致。(在关系型数据库中,左侧数据表上的主键通常与右侧数据表上的外键关联。)其次,我们应该知道将使用哪种连接方式以及原因。

第三,我们应该知道两个数据表各有多少行。第四,我们应该大致了解根据连接类型、每个数据集的行数以及对合并值的初步检查,将保留多少行。如果所有的合并值在两个数据集中都有,或者如果我们正在做内连接,那么行数将等于右侧数据集的行数,适用于一对多合并。但通常情况并不像这样简单。我们经常进行左连接的一对多合并。在左连接的情况下,保留的行数将等于右数据集中具有匹配合并值的行数,加上左数据集中没有匹配合并值的行数。

一旦我们通过本教程中的示例进行操作,这应该会更加清晰。

准备工作

本教程将使用来自全球历史气候网络(Global Historical Climatology Network)集成数据库的气象站数据。一个数据框包含每个国家的一行数据,另一个包含每个气象站的一行数据。每个国家通常有多个气象站。

如何实现…

在本教程中,我们将进行一对多的合并,将每个国家一行的数据与气象站数据合并,气象站数据包含每个国家的多个站点。让我们开始吧:

  1. 导入pandas并加载气象站和国家数据:

    import pandas as pd
    countries = pd.read_csv("data/ltcountries.csv")
    locations = pd.read_csv("data/ltlocations.csv") 
    
  2. 为气象站(locations)和国家数据设置索引。

确认countries数据框中的合并列值是唯一的:

countries.set_index(['countryid'], inplace=True)
locations.set_index(['countryid'], inplace=True)
countries.head() 
 country
countryid                     
AC              Antigua and Barbuda
AE             United Arab Emirates
AF                      Afghanistan
AG                          Algeria
AJ                       Azerbaijan 
countries.index.nunique()==countries.shape[0] 
True 
locations[['locationid','latitude','stnelev']].head(10) 
 locationid	latitude	stnelev
countryid                               
AC		ACW00011604	58		18
AE		AE000041196	25		34
AE		AEM00041184	26		31
AE		AEM00041194	25		10
AE		AEM00041216	24		3
AE		AEM00041217	24		27
AE		AEM00041218	24		265
AF		AF000040930	35		3,366
AF		AFM00040911	37		378
AF		AFM00040938	34		977 
  1. 使用join对国家和地点进行左连接:

    stations = countries.join(locations)
    stations[['locationid','latitude',
    ...   'stnelev','country']].head(10) 
    
     locationid	latitude	stnelev  \
    countryid                                  
    AC		ACW00011604	58		18  
    AE		AE000041196	25		34  
    AE		AEM00041184	26		31  
    AE		AEM00041194	25		10  
    AE		AEM00041216	24		3  
    AE		AEM00041217	24		27  
    AE		AEM00041218	24		265  
    AF		AF000040930	35		3,366  
    AF		AFM00040911	37		378  
    AF		AFM00040938	34		977  
                               country 
    countryid                       
    AC             Antigua and Barbuda 
    AE            United Arab Emirates 
    AE            United Arab Emirates 
    AE            United Arab Emirates 
    AE            United Arab Emirates 
    AE            United Arab Emirates 
    AE            United Arab Emirates 
    AF                     Afghanistan 
    AF                     Afghanistan 
    AF                     Afghanistan 
    

连接似乎正常工作。但我们试着改用 merge 方法。

  1. 在进行合并之前,检查合并列是否匹配。

首先,重新加载数据框,因为我们做了一些更改。checkmerge函数显示,在两个数据框中有countryid合并值的行数为27,472,在countries(左侧数据框)中存在但在locations中不存在的行数为 2。这意味着内连接将返回27,472行,左连接将返回27,474行。该函数的最后语句标识出在一个数据框中有但在另一个数据框中没有的countryid值:

countries = pd.read_csv("data/ltcountries.csv")
locations = pd.read_csv("data/ltlocations.csv")
def checkmerge(dfleft, dfright, idvar):
...   dfleft['inleft'] = "Y"
...   dfright['inright'] = "Y"
...   dfboth = pd.merge(dfleft[[idvar,'inleft']],\
...     dfright[[idvar,'inright']], on=[idvar], how="outer")
...   dfboth.fillna('N', inplace=True)
...   print(pd.crosstab(dfboth.inleft, dfboth.inright))
...   print(dfboth.loc[(dfboth.inleft=='N') | (dfboth.inright=='N')])
...
checkmerge(countries.copy(), locations.copy(), "countryid") 
inright	N	Y
inleft          
N	0	1
Y	2	27472
      countryid inleft inright
9715         LQ      Y       N
13103        ST      Y       N
27474        FO      N       Y 
  1. 显示一个文件中有而另一个文件中没有的行。

上一步的最后语句显示了在countries中有但在locations中没有的两个countryid值,以及在locations中有但在countries中没有的一个值:

countries.loc[countries.countryid.isin(["LQ","ST"])] 
 countryid                          country
124          LQ    Palmyra Atoll [United States]
195          ST                      Saint Lucia 
locations.loc[locations.countryid=="FO"] 
 locationid  latitude  longitude  stnelev   station countryid
7363  FOM00006009        61         -7      102  AKRABERG        FO 
  1. 合并locationscountries数据框。

执行左连接。同时,统计在国家数据中存在但在气象站数据中缺失的每一列的缺失值数量:

stations = pd.merge(countries, locations, on=["countryid"], how="left")
stations[['locationid','latitude',
...   'stnelev','country']].head(10) 
 locationid	latitude	stnelev		country
0	ACW00011604	58		18		Antigua and Barbuda
1	AE000041196	25		34		United Arab Emirates
2	AEM00041184	26		31		United Arab Emirates
3	AEM00041194	25		10		United Arab Emirates
4	AEM00041216	24		3		United Arab Emirates
5	AEM00041217	24		27		United Arab Emirates
6	AEM00041218	24		265		United Arab Emirates
7	AF000040930	35		3,366		Afghanistan
8	AFM00040911	37		378		Afghanistan
9	AFM00040938	34		977		Afghanistan 
stations.shape 
(27474, 7) 
stations.loc[stations.countryid.isin(["LQ","ST"])].isnull().sum() 
countryid	0
country	0
locationid	2
latitude	2
longitude	2
stnelev	2
station	2
dtype: int64 

一对多合并返回预期的行数以及新的缺失值。

它是如何工作的…

第 3 步中,我们使用了join DataFrame 方法,执行了countrieslocations DataFrame 的左连接。这是执行连接的最简单方法。由于join方法使用 DataFrame 的索引进行连接,我们需要首先设置索引。然后,我们将右侧 DataFrame 传递给左侧 DataFrame 的join方法。

尽管join比这个例子中所示的稍微灵活(例如,你可以指定连接类型),但对于所有但最简单的连接,我更喜欢使用较为冗长的 pandas merge函数。我可以确信,使用merge函数时,所有我需要的选项都可以使用。在我们使用merge函数之前,我们在第 4 步中做了一些检查。这告诉我们如果执行内连接或左连接,合并后的 DataFrame 预期行数分别为 27,472 或 27,474。

我们还展示了一个 DataFrame 中有 merge-by 值的行,而另一个没有。如果我们要做左连接,我们需要决定如何处理右侧 DataFrame 中缺失的值。在这个例子中,有两个 merge-by 值在右侧 DataFrame 中未找到,导致这些列出现了缺失值。

还有更多…

你可能已经注意到,在我们调用checkmerge时,我们传递了countrieslocations DataFrame 的副本:

checkmerge(countries.copy(), locations.copy(), "countryid") 

我们在这里使用copy,因为我们不希望checkmerge函数对原始 DataFrame 做任何修改。

另见

我们在本章的进行一对一连接部分详细讨论了连接类型。

进行多对多连接

多对多连接会在左侧和右侧 DataFrame 中都产生重复的 merge-by 值。我们应该只在少数情况下才需要进行多对多的连接。即使数据以这种形式出现,通常也是因为我们缺少在多个一对多关系中的核心文件。例如,有捐赠者、捐赠者贡献和捐赠者联系信息的数据表,后两个文件每个捐赠者有多行。但是,在这个案例中,我们无法访问捐赠者文件,它与捐赠和联系信息文件有一对多关系。这种情况比你想象的更常见。人们有时会给我们提供数据,而不了解数据的底层结构。当我做多对多连接时,通常是因为我缺少一些关键信息,而不是因为数据库就是这么设计的。

多对多连接返回 merge-by 列值的笛卡尔积。因此,如果一个捐赠者 ID 在捐赠者联系信息文件中出现两次,在捐赠者贡献文件中出现五次,那么连接将返回 10 行数据。这在分析时通常会引发一些问题。在这个例子中,多对多连接将重复捐赠者的贡献,每个地址一次。

当面对潜在的多对多合并情况时,解决方案往往不是进行合并。相反,我们可以恢复隐含的单对多关系。以捐赠者为例,我们可以删除所有行,保留最新的联系信息,从而确保每个捐赠者只有一行数据。然后我们可以与捐赠者贡献文件进行单对多合并。但我们并不总能避免进行多对多合并。有时,我们必须生成一个分析文件或平面文件,保持所有数据,不考虑重复。这份指南展示了在需要时如何进行这些合并。

准备工作

我们将使用克利夫兰艺术博物馆(Cleveland Museum of Art)的数据。我们将使用两个 CSV 文件:一个包含集合中每个项目的媒体引用,另一个包含每个项目的创作者。

数据说明

克利夫兰艺术博物馆提供了一个公共 API,用于访问这些数据:openaccess-api.clevelandart.org/。通过该 API 可以获取比引用和创作者数据更多的数据。本指南中的数据是 2024 年 4 月下载的。

如何做…

按照以下步骤完成这份指南:

  1. 加载pandas并加载克利夫兰艺术博物馆CMA)的收藏数据:

    import pandas as pd
    cmacitations = pd.read_csv("data/cmacitations.csv")
    cmacreators = pd.read_csv("data/cmacreators.csv") 
    
  2. 看看citations数据。itemid是集合项的标识符。前 10 个引用都是针对集合项94979的:

    cmacitations['citation'] = cmacitations.citation.str[0:15]
    cmacitations.head(10) 
    
     itemid            citation
    0   94979     Perkins, August
    1   94979     Bayley, Frank W
    2   94979     W. H. D. "The F
    3   94979     <em>The America
    4   94979     "Clevel'd Gets
    5   94979     "The Inaugurati
    6   94979     Bell, Hamilton.
    7   94979     Cleveland Museu
    8   94979     "Special Exhibi
    9   94979     Dunlap, William 
    
    cmacitations.shape 
    
    (16053, 2) 
    
    cmacitations.itemid.nunique() 
    
    974 
    
  3. 看看creators数据。creatorid是创作者的标识符:

    cmacreators['creator'] = cmacreators.creator.str[0:15]
    cmacreators.loc[:,['itemid','creator','birth_year',
     'creatorid']].head(10) 
    
     itemid            creator   birth_year    creatorid
    0   94979    John Singleton          1738         2409
    1  102578    William Merritt         1849         3071
    2   92937    George Bellows          1882         3005
    3  151904    Thomas Eakins (         1844         4037
    4  141639    Frederic Edwin          1826         2697
    5  110180    Albert Pinkham          1847         3267
    6  149112    Charles Sheeler         1883          889
    7  126769    Henri Rousseau          1844         1804
    8  149410    Paul Gauguin (F         1848         1776
    9  135299    Vincent van Gog         1853         1779 
    
    cmacreators.shape 
    
    (694, 8) 
    
    cmacreators.itemid.nunique() 
    
    618 
    
    cmacreators.creatorid.nunique() 
    
    486 
    
  4. 显示citations数据中重复的合并值。

集合项 148758 有 182 条媒体引用:

cmacitations.itemid.value_counts().head(10) 
itemid
148758    182
113164    127
122351    125
155783    119
151904    112
124245    108
92937     104
123168     98
94979      98
149112     97
Name: count, dtype: int64 
  1. 显示creators数据中重复的合并值:

    cmacreators.itemid.value_counts().head(10) 
    
    itemid
    149386    4
    142753    3
    112932    3
    149042    3
    114538    3
    140001    3
    146797    3
    149041    3
    140427    3
    109147    2
    Name: count, dtype: int64 
    
  2. 检查合并结果。

使用我们在进行单对多合并中使用的checkmerge函数:

def checkmerge(dfleft, dfright, idvar):
...   dfleft['inleft'] = "Y"
...   dfright['inright'] = "Y"
...   dfboth = pd.merge(dfleft[[idvar,'inleft']],\
...     dfright[[idvar,'inright']], on=[idvar], how="outer")
...   dfboth.fillna('N', inplace=True)
...   print(pd.crosstab(dfboth.inleft, dfboth.inright))
...
checkmerge(cmacitations.copy(), cmacreators.copy(), "itemid") 
inright     N      Y
inleft             
N           0     14
Y        4277  12710 
  1. 显示在两个数据框中都重复的合并值:

    cmacitations.loc[cmacitations.itemid==124733] 
    
     itemid             citation
    14533      124733      Weigel, J. A. G
    14534      124733      Winkler, Friedr
    14535      124733       Francis, Henry
    14536      124733      Kurz, Otto. <em
    14537      124733      Minneapolis Ins
    14538      124733      Pilz, Kurt. "Ha
    14539      124733      Koschatzky, Wal
    14540      124733      Johnson, Mark M
    14541      124733      Kaufmann, Thoma
    14542      124733        Koreny, Fritz.
    14543      124733      Achilles-Syndra
    14544      124733       Schoch, Rainer,
    14545      124733      DeGrazia, Diane
    14546      124733       Dunbar, Burton 
    
    cmacreators.loc[cmacreators.itemid==124733,
    ...   ['itemid','creator','birth_year','title']] 
    
     itemid              creator      birth_year                title
    591  124733      Hans Hoffmann (           1545      Dead Blue Roller
    592  124733      Albrecht Dürer            1471      Dead Blue Roller 
    
  2. 进行多对多合并:

    cma = pd.merge(cmacitations, cmacreators, on=['itemid'], how="outer")
    cma.set_index("itemid", inplace=True)
    cma.loc[124733, ['citation','creator','birth_year']] 
    
     citation             creator    birth_year
    itemid                                                      
    124733     Weigel, J. A. G     Hans Hoffmann (          1545
    124733     Weigel, J. A. G     Albrecht Dürer           1471
    124733     Winkler, Friedr     Hans Hoffmann (          1545
    124733     Winkler, Friedr     Albrecht Dürer           1471
    124733      Francis, Henry     Hans Hoffmann (          1545
    124733     Francis, Henry      Albrecht Dürer           1471
    124733     Kurz, Otto. <em     Hans Hoffmann (          1545
    124733     Kurz, Otto. <em     Albrecht Dürer           1471
    124733     Minneapolis Ins     Hans Hoffmann (          1545
    124733     Minneapolis Ins     Albrecht Dürer           1471
    124733     Pilz, Kurt. "Ha     Hans Hoffmann (          1545
    124733     Pilz, Kurt. "Ha     Albrecht Dürer           1471
    124733     Koschatzky, Wal     Hans Hoffmann (          1545
    124733     Koschatzky, Wal     Albrecht Dürer           1471
    ... last 14 rows removed to save space 
    

现在,既然我已经带你了解了多对多合并的复杂性,我将再多说一点它是如何工作的。

它是如何工作的…

步骤 2告诉我们有 16,053 条引用,涉及 974 个独特项目。每个项目都有一个唯一的 ID,itemid,表示博物馆中的每个项目。平均而言,每个项目有 16 条媒体引用(16,053/974)。步骤 3告诉我们,博物馆有 694 位创作者,涉及 618 个有创作者的项目,因此绝大多数作品只有一个创作者。但citationscreators数据框中都存在重复的itemid(我们用于合并的值),这意味着我们的合并将是多对多合并。

步骤 4让我们了解了哪些itemidcitations DataFrame 中重复。博物馆中的一些项目有超过 100 个引用。值得仔细查看这些项目的引用,以判断它们是否合理。步骤 5显示,即使有多个创作者,通常也不会超过三个。在步骤 6中,我们看到大多数itemid出现在citations文件和creators文件中,但也有相当一部分有citations行但没有creators行。如果我们进行内连接或右连接,我们将丢失那 4,277 行数据,但进行左连接或外连接则不会丢失。(假设citations DataFrame 是左侧 DataFrame,creators DataFrame 是右侧 DataFrame。)

我们查看了步骤 7中在两个 DataFrame 中重复的itemid值。该集合项在citations DataFrame 中有 14 行,在creators DataFrame 中有 2 行。这将导致在合并的 DataFrame 中生成 28 行(2 * 14)。citations数据将会为creators中的每一行重复。

当我们查看在步骤 8中合并的结果时,这一点得到了确认。我们使用itemid作为合并列进行了外连接。当我们展示合并文件中相同 ID 的行时,使用的是在步骤 7中使用的 ID,我们得到了预期的 28 行(为了节省空间,我删除了最后 14 行输出)。

还有更多…

理解何时进行多对多合并非常重要,因为有时这是无法避免的。但即便在这种情况下,我们可以知道,多对多关系其实只是两个一对多关系,只是数据文件在其中一侧缺失。很可能会有一个数据表,其中每一行代表一个集合项,并且与citations数据和creators数据都有一对多关系。当我们无法访问类似的文件时,最好尝试重新生成一个具有该结构的文件。利用这些数据,我们可以创建一个包含itemid,也许还包括title的文件,然后分别与citationscreators数据进行一对多合并。

然而,有时我们必须生成一个平面文件以供后续分析。当我们,或者从我们这里获取清洗数据的同事,使用无法很好处理关系数据的软件时,可能需要这么做。例如,其他部门的某个人可能会使用 Excel 做很多数据可视化工作。只要那个人知道哪些分析需要去除重复的行,那么像我们在步骤 8中生成的结构可能会很好用。

开发合并程序

我发现将数据合并看作数据清理过程中的停车场很有帮助。数据合并和停车似乎是例行公事,但它们是意外发生的高发地带。避免在停车场发生事故的一种方法是,每次进入某个特定停车场时,都使用类似的策略。也许你总是去一个相对交通较少的区域,并且大部分时间你都是通过相同的方式到达那里。

我认为类似的方法可以应用于在数据合并时相对不受损害地进出。如果我们选择一种对我们有效的通用方法,能在 80%到 90%的情况下起作用,我们可以专注于最重要的内容——数据,而不是操控数据的技术。

在这个方法中,我将展示对我有效的一般方法,但我使用的具体技术并不重要。我认为拥有一种你了解并且能够熟练使用的方法是很有帮助的。

准备工作

我们将回到本章的进行一对多合并方法中所关注的目标。我们想要对countries数据和全球历史气候网络集成数据库中的locations数据进行左连接。

如何实现……

在这个方法中,我们将在检查合并依据值的不匹配后,对countrieslocations数据进行左连接。让我们开始吧:

  1. 导入pandas并加载气象站和国家数据:

    import pandas as pd
    countries = pd.read_csv("data/ltcountries.csv")
    locations = pd.read_csv("data/ltlocations.csv") 
    
  2. 检查合并依据的列是否匹配:

    def checkmerge(dfleft, dfright, mergebyleft, mergebyright):
    ...   dfleft['inleft'] = "Y"
    ...   dfright['inright'] = "Y"
    ...   dfboth = \
    ...     pd.merge(dfleft[[mergebyleft,'inleft']],\
    ...     dfright[[mergebyright,'inright']],\
    ...     left_on=[mergebyleft],\
    ...     right_on=[mergebyright], how="outer")
    ...   dfboth.fillna('N', inplace=True)
    ...   print(pd.crosstab(dfboth.inleft,
    ...     dfboth.inright))
    ...   print(dfboth.loc[(dfboth.inleft=='N') | \
    ...     (dfboth.inright=='N')].head(20))
    checkmerge(countries.copy(), locations.copy(), "countryid", "countryid") 
    
    inright            N         Y
    inleft                        
    N                  0         1
    Y                  2     27472
          countryid inleft inright
    7363         FO      N       Y
    9716         LQ      Y       N
    13104        ST      Y       N 
    
  3. 合并国家和位置数据:

    stations = pd.merge(countries, locations, left_on=["countryid"], right_on=["countryid"], how="left")
    stations[['locationid','latitude',
    ...   'stnelev','country']].head(10) 
    
     locationid      latitude      stnelev                 country
    0  ACW00011604       57.7667         18.0     Antigua and Barbuda
    1  AE000041196       25.3330         34.0    United Arab Emirates
    2  AEM00041184       25.6170         31.0    United Arab Emirates
    3  AEM00041194       25.2550         10.4    United Arab Emirates
    4  AEM00041216       24.4300          3.0    United Arab Emirates
    5  AEM00041217       24.4330         26.8    United Arab Emirates
    6  AEM00041218       24.2620        264.9    United Arab Emirates
    7  AF000040930       35.3170       3366.0             Afghanistan
    8  AFM00040911       36.7000        378.0             Afghanistan
    9  AFM00040938       34.2100        977.2             Afghanistan 
    
    stations.shape 
    
    (27474, 7) 
    

在这里,我们从左连接中得到了预期的行数:27,472行数据在两个 DataFrame 中都有合并依据值,另外有两行数据在左侧 DataFrame 中有合并依据值,但右侧没有。

它是如何工作的……

对于我进行的大多数合并,类似于步骤 2步骤 3中使用的逻辑效果很好。我们在前一个方法中使用的checkmerge函数中添加了一个第四个参数。这使我们能够为左右两个 DataFrame 指定不同的合并依据列。每次进行合并时我们不需要重新创建这个函数。我们只需要将它包含在一个模块中并导入即可。(在本书的最后一章,我们将讨论如何将辅助函数添加到模块中。)

在进行合并之前调用checkmerge函数,能给我们足够的信息,让我们知道在使用不同的连接类型进行合并时会得到什么结果。我们将知道通过内连接、外连接、左连接或右连接返回多少行数据。我们还会知道在实际合并之前,哪些地方会生成新的缺失值。当然,这个操作相当耗费资源,因为每次我们都需要运行两次合并——一次诊断性的外连接,接着再进行我们选择的任何连接类型。但我认为这通常是值得的,至少可以帮助我们停下来思考我们在做什么。

最后,我们在步骤 3中进行了合并。这是我偏好的语法。我总是将左侧的 DataFrame 用作第一个参数,右侧的 DataFrame 用作第二个参数,尽管merge允许我们以不同的方式指定左右 DataFrame。我还为left_onright_on设置了值,即使合并列相同,我也可以使用on(就像我们在前面的例子中所做的那样)。这样做是为了避免在合并列不同的情况下需要更改语法,我也喜欢它能让两个 DataFrame 的合并列变得显式。

一个稍微有争议的做法是,我默认使用左连接,将how参数初始设置为左连接。我将其作为起始假设,然后问自己是否有理由使用其他连接类型。左侧 DataFrame 中的行通常代表我的分析单元(如学生、病人、客户等),而我正在从右侧 DataFrame 中添加补充数据(如 GPA、血压、邮政编码等)。如果合并列在右侧 DataFrame 中不存在(例如执行内连接时),删除分析单元中的行可能会导致问题。例如,在本章的一对一合并食谱中,如果删除主 NLS 数据中没有出现在我们为父母提供的补充数据中的行,可能就没有意义。

另请参见

我们将在第十二章,自动化数据清理与用户自定义函数、类和管道中创建包含有用数据清理功能的模块。

我们在本章的一对一合并食谱中讨论了连接的类型。

总结

我们在本章仔细研究了纵向合并数据(也称为拼接)和横向合并数据(也称为合并)。我们讨论了拼接数据时的关键数据问题,包括文件之间的不同列。我们还考虑了合并数据时的关键问题,如合并列的缺失值和数据的意外重复。我们还探讨了不同连接类型对这些问题的影响。在下一章中,我们将学习如何整理和重塑混乱的数据。

留下评论!

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

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

第十一章:整理和重塑数据

引用列夫·托尔斯泰的智慧(“幸福的家庭都是相似的;每个不幸的家庭都有其不幸的方式。”),哈德利·威克姆告诉我们,所有整洁的数据本质上是相似的,但所有不整洁的数据都有其独特的混乱方式。我们多少次盯着某些数据行,心里想,“这…怎么回事…为什么他们这么做?” 这有点夸张。尽管数据结构不良的方式有很多,但在人类创造力方面是有限的。我们可以将数据集偏离标准化或整洁形式的最常见方式进行分类。

这是哈德利·威克姆在他关于整洁数据的开创性著作中的观察。我们可以依赖这项工作,以及我们自己在处理结构奇特的数据时的经验,为我们需要进行的重塑做好准备。不整洁的数据通常具有以下一种或多种特征:缺乏明确的按列合并关系;一对多关系中的一方数据冗余;多对多关系中的数据冗余;列名中存储值;将多个值存储在一个变量值中;数据未按分析单位进行结构化。(尽管最后一种情况不一定是数据不整洁的表现,但我们将在接下来的几个菜谱中回顾的某些技术也适用于常见的分析单位问题。)

在本章中,我们使用强大的工具来应对像前面那样的数据清理挑战。具体而言,我们将讨论以下内容:

  • 移除重复行

  • 修复多对多关系

  • 使用stackmelt将数据从宽格式重塑为长格式

  • 多组列的合并

  • 使用unstackpivot将数据从长格式重塑为宽格式

技术要求

完成本章的任务,您将需要 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但该代码可以在 pandas 1.5.3 或更高版本上运行。

本章中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition

移除重复行

数据在分析单位上的重复有几种原因:

  • 当前的 DataFrame 可能是一次一对多合并的结果,其中一方是分析单位。

  • 该 DataFrame 是反复测量或面板数据被压缩为平面文件,这只是第一种情况的特殊情况。

  • 我们可能正在处理一个分析文件,其中多个一对多关系已被展平,形成多对多关系。

单一侧面是分析单元时,多重侧面的数据可能需要以某种方式进行合并。例如,如果我们在分析一组大学生的结果,学生是分析单元;但我们也可能拥有每个学生的课程注册数据。为了准备数据进行分析,我们可能需要首先统计每个学生的课程数量、总学分或计算 GPA,最后得到每个学生的一行数据。通过这个例子,我们可以概括出,在去除重复数据之前,我们通常需要对多重侧面的信息进行聚合。

在这个示例中,我们查看了 pandas 处理去除重复行的技巧,并考虑了在这个过程中什么时候需要进行聚合,什么时候不需要。在下一示例中,我们将解决多对多关系中的重复问题。

准备工作

在本示例中,我们将处理 COVID-19 每日病例数据。该数据集为每个国家提供每一天的一行数据,每行记录当天的新病例和新死亡人数。每个国家还有人口统计数据和病例死亡的累计总数,因此每个国家的最后一行提供了总病例数和总死亡人数。

数据说明

Our World in Data 提供了 COVID-19 公共使用数据,网址是ourworldindata.org/covid-cases。该数据集包括总病例和死亡人数、已进行的测试、医院床位以及诸如中位年龄、国内生产总值和糖尿病患病率等人口统计数据。本示例中使用的数据集是 2024 年 3 月 3 日下载的。

如何操作…

我们使用drop_duplicates去除每个国家在 COVID-19 每日数据中的重复人口统计数据。当我们需要先进行一些聚合再去除重复数据时,我们也可以探索groupby作为drop_duplicates的替代方法:

  1. 导入pandas和 COVID-19 每日病例数据:

    import pandas as pd
    covidcases = pd.read_csv("data/covidcases.csv") 
    
  2. 为每日病例和死亡列、病例总数列以及人口统计列创建列表(total_casestotal_deaths列分别是该国家的病例和死亡的累计总数):

    dailyvars = ['casedate','new_cases','new_deaths']
    totvars = ['location','total_cases','total_deaths']
    demovars = ['population','population_density',
    ...   'median_age','gdp_per_capita',
    ...   'hospital_beds_per_thousand','region']
    covidcases[dailyvars + totvars + demovars].head(2).T 
    
     0               1
    casedate                        2020-03-01      2020-03-15
    new_cases                                1               6
    new_deaths                               0               0
    location                       Afghanistan     Afghanistan
    total_cases                              1               7
    total_deaths                             0               0
    population                        41128772        41128772
    population_density                      54              54
    median_age                              19              19
    gdp_per_capita                       1,804           1,804
    hospital_beds_per_thousand               0               0
    region                          South Asia      South Asia 
    
  3. 创建一个仅包含每日数据的 DataFrame:

    coviddaily = covidcases[['location'] + dailyvars]
    coviddaily.shape 
    
    (36501, 4) 
    
    coviddaily.head() 
    
     location       casedate     new_cases     new_deaths
    0  Afghanistan     2020-03-01             1              0
    1  Afghanistan     2020-03-15             6              0
    2  Afghanistan     2020-03-22            17              0
    3  Afghanistan     2020-03-29            67              2
    4  Afghanistan     2020-04-05           183              3 
    
  4. 为每个国家选择一行。

检查预计有多少个国家(位置),方法是获取唯一位置的数量。按位置和病例日期排序。然后使用drop_duplicates选择每个位置的一个行,并使用 keep 参数指示我们希望为每个国家选择最后一行:

covidcases.location.nunique() 
231 
coviddemo = \
  covidcases[['casedate'] + totvars + demovars].\
  sort_values(['location','casedate']).\
  drop_duplicates(['location'], keep='last').\
  rename(columns={'casedate':'lastdate'})
coviddemo.shape 
(231, 10) 
coviddemo.head(2).T 
 204                379
lastdate                      2024-02-04         2024-01-28
location                     Afghanistan            Albania
total_cases                      231,539            334,863
total_deaths                       7,982              3,605
population                      41128772            2842318
population_density                    54                105
median_age                            19                 38
gdp_per_capita                     1,804             11,803
hospital_beds_per_thousand             0                  3
region                        South Asia     Eastern Europe 
  1. 对每个组的值进行求和。

使用 pandas 的 DataFrame groupby 方法来计算每个国家的病例和死亡总数。(我们在这里计算病例和死亡的总和,而不是使用 DataFrame 中已存在的病例和死亡的累计总数。)同时,获取一些在每个国家的所有行中都重复的列的最后一个值:median_agegdp_per_capitaregioncasedate。(我们只选择 DataFrame 中的少数几列。)请注意,数字与第 4 步中的一致:

covidtotals = covidcases.groupby(['location'],
...   as_index=False).\
...   agg({'new_cases':'sum','new_deaths':'sum',
...     'median_age':'last','gdp_per_capita':'last',
...     'region':'last','casedate':'last',
...     'population':'last'}).\
...   rename(columns={'new_cases':'total_cases',
...     'new_deaths':'total_deaths',
...     'casedate':'lastdate'})
covidtotals.head(2).T 
 0                     1
location              Afghanistan               Albania
total_cases               231,539               334,863
total_deaths                7,982                 3,605
median_age                     19                    38
gdp_per_capita              1,804                11,803
region                 South Asia        Eastern Europe
lastdate               2024-02-04            2024-01-28
population               41128772               2842318 

选择使用drop_duplicates还是groupby来消除数据冗余,取决于我们是否需要在压缩方之前进行任何聚合。

它是如何工作的……

COVID-19 数据每个国家每天有一行,但实际上很少有数据是每日数据。只有casedatenew_casesnew_deaths可以视为每日数据。其他列则显示累计病例和死亡人数,或是人口统计数据。累计数据是冗余的,因为我们已有new_casesnew_deaths的实际值。人口统计数据在所有日期中对于每个国家来说值是相同的。

国家(及其相关人口统计数据)与每日数据之间有一个隐含的一对多关系,其中方是国家,方是每日数据。我们可以通过创建一个包含每日数据的 DataFrame 和另一个包含人口统计数据的 DataFrame 来恢复这种结构。我们在步骤 34中做到了这一点。当我们需要跨国家的总数时,我们可以自己生成,而不是存储冗余数据。

然而,运行总计变量并非完全没有用处。我们可以使用它们来检查我们关于病例总数和死亡总数的计算。步骤 5展示了如何在需要执行的不仅仅是去重时,使用groupby来重构数据。在这种情况下,我们希望对每个国家的new_casesnew_deaths进行汇总。

还有更多……

我有时会忘记一个小细节。在改变数据结构时,某些列的含义可能会发生变化。在这个例子中,casedate变成了每个国家最后一行的日期。我们将该列重命名为lastdate

另请参见

我们在第九章《聚合时修复混乱数据》中更详细地探讨了groupby

Hadley Wickham 的整洁数据论文可以在vita.had.co.nz/papers/tidy-data.pdf找到。

修复多对多关系

有时我们必须处理从多对多合并创建的数据表。这是一种在左侧和右侧的合并列值都被重复的合并。正如我们在前一章中讨论的那样,数据文件中的多对多关系通常代表多个一对多关系,其中方被移除。数据集 A 和数据集 B 之间有一对多关系,数据集 A 和数据集 C 之间也有一对多关系。我们有时面临的问题是,收到的数据文件将 B 和 C 合并在一起,而将 A 排除在外。

处理这种结构的数据的最佳方式是重新创建隐含的一对多关系,若可能的话。我们通过首先创建一个类似 A 的数据集来实现;也就是说,假设有一个多对多关系,我们可以推测 A 的结构是怎样的。能够做到这一点的关键是为数据两边的多对多关系识别出一个合适的合并列。这个列或这些列将在 B 和 C 数据集中重复,但在理论上的 A 数据集中不会重复。

本教程中使用的数据就是一个很好的例子。我们使用克利夫兰艺术博物馆的收藏数据。每个博物馆收藏品都有多行数据。这些数据包括收藏品的信息(如标题和创作日期);创作者的信息(如出生和死亡年份);以及该作品在媒体中的引文。当有多个创作者和多个引文时(这通常发生),行数会重复。更精确地说,每个收藏品的行数是引文和创作者数量的笛卡尔积。所以,如果有 5 条引文和 2 个创作者,我们将看到该项目有 10 行数据。

我们想要的是一个收藏品文件,每个收藏品一行(并且有一个唯一标识符),一个创作者文件,每个创作者对应一行,和一个引文文件,每个引文对应一行。在本教程中,我们将创建这些文件。

你们中的一些人可能已经注意到,这里还有更多的整理工作要做。我们最终需要一个单独的创作者文件,每个创作者一行,另一个文件只包含创作者 ID 和收藏品 ID。我们需要这种结构,因为一个创作者可能会为多个项目创作。我们在这个教程中忽略了这个复杂性。

我应该补充的是,这种情况并不是克利夫兰艺术博物馆的错,该博物馆慷慨地提供了一个 API,可以返回作为 JSON 文件的收藏数据。使用 API 的个人有责任创建最适合自己研究目的的数据文件。直接从更灵活的 JSON 文件结构中工作也是可能的,而且通常是一个不错的选择。我们将在 第十二章使用用户定义的函数、类和管道自动清理数据 中演示如何操作。

准备工作

我们将使用克利夫兰艺术博物馆收藏的数据。CSV 文件包含有关创作者和引文的数据,这些数据通过 itemid 列合并,itemid 用来标识收藏品。每个项目可能有一行或多行关于引文和创作者的数据。

数据说明

克利夫兰艺术博物馆提供了一个公共访问数据的 API:openaccess-api.clevelandart.org/。API 提供的数据远不止引文和创作者的数据。本文中的数据是 2024 年 4 月下载的。

如何实现…

我们通过恢复数据中隐含的多个一对多关系来处理 DataFrame 之间的多对多关系:

  1. 导入 pandas 和博物馆的 collections 数据。为了更方便显示,我们还将限制 collectiontitle 列中值的长度:

    import pandas as pd
    cma = pd.read_csv("data/cmacollections.csv")
    cma['category'] = cma.category.str.strip().str[0:15]
    cma['title'] = cma.title.str.strip().str[0:30] 
    
  2. 显示博物馆的一些 collections 数据。注意,几乎所有的数据值都是冗余的,除了 citation

同时,显示唯一的 itemidcitationcreator 值的数量。有 986 个独特的集合项,12,941 个引用,以及 1,062 对 item/creator 组合:

cma.shape 
(17001, 9) 
cma.head(4).T 
 0                      1  \
itemid                         75551                  75551  
citation             Purrmann, Hans.        <em>Henri Matis  
creatorid                      2,130                  2,130  
creator              Henri Matisse (        Henri Matisse (  
title                         Tulips                 Tulips  
birth_year                      1869                   1869  
death_year                      1954                   1954  
category             Mod Euro - Pain        Mod Euro - Pain  
creation_date                   1914                   1914  
                                   2                      3 
itemid                         75551                  75551 
citation             Flam, Jack D. <        <em>Masters of  
creatorid                      2,130                  2,130 
creator              Henri Matisse (        Henri Matisse ( 
title                         Tulips                 Tulips 
birth_year                      1869                   1869 
death_year                      1954                   1954 
category             Mod Euro - Pain        Mod Euro - Pain 
creation_date                   1914                   1914 
cma.itemid.nunique() 
986 
cma.drop_duplicates(['itemid','citation']).\
  itemid.count() 
12941 
cma.drop_duplicates(['itemid','creatorid']).\
  itemid.count() 
1062 
  1. 显示一个包含重复引用和创作者的集合项。

只显示前 6 行(实际上共有 28 行)。注意,引用数据对于每个创作者都是重复的:

cma.set_index(['itemid'], inplace=True)
cma.loc[124733, ['title','citation',
  'creation_date','creator','birth_year']].head(6) 
 title              citation  \
itemid                                     
124733       Dead Blue Roller       Weigel, J. A. G  
124733       Dead Blue Roller       Weigel, J. A. G  
124733       Dead Blue Roller       Winkler, Friedr  
124733       Dead Blue Roller       Winkler, Friedr  
124733       Dead Blue Roller       Francis, Henry   
124733       Dead Blue Roller       Francis, Henry   
            creation_date               creator birth_year 
itemid                                                
124733               1583       Hans Hoffmann (       1545 
124733               1583       Albrecht Dürer        1471 
124733               1583       Hans Hoffmann (       1545 
124733               1583       Albrecht Dürer        1471 
124733               1583       Hans Hoffmann (       1545 
124733               1583       Albrecht Dürer        1471 
  1. 创建一个集合 DataFrame。titlecategorycreation_date 应该是每个集合项唯一的,因此我们创建一个仅包含这些列的 DataFrame,并带有 itemid 索引。我们得到预期的行数 986

    collectionsvars = \
      ['title','category','creation_date']
    cmacollections = cma[collectionsvars].\
      reset_index().\
      drop_duplicates(['itemid']).\
      set_index(['itemid'])
    cmacollections.shape 
    
    (986, 3) 
    
    cmacollections.head() 
    
     title  \
    itemid                                  
    75551                           Tulips  
    75763   Procession or Pardon at Perros  
    78982       The Resurrection of Christ  
    84662                The Orange Christ  
    86110   Sunset Glow over a Fishing Vil  
                 category   creation_date 
    itemid                                
    75551   Mod Euro - Pain          1914 
    75763   Mod Euro - Pain          1891 
    78982   P - German befo          1622 
    84662   Mod Euro - Pain          1889 
    86110   ASIAN - Hanging   1460s–1550s 
    
  2. 让我们看看新 DataFrame cmacollections 中的同一项,该项在 步骤 3 中已经展示过:

    cmacollections.loc[124733] 
    
    title            Dead Blue Roller
    category              DR - German
    creation_date                1583
    Name: 124733, dtype: object 
    
  3. 创建一个引用(citations)DataFrame。

这将只包含 itemidcitation

cmacitations = cma[['citation']].\
  reset_index().\
  drop_duplicates(['itemid','citation']).\
  set_index(['itemid'])
cmacitations.loc[124733] 
 citation
itemid                     
124733       Weigel, J. A. G
124733       Winkler, Friedr
124733       Francis, Henry
124733       Kurz, Otto. <em
124733       Minneapolis Ins
124733       Pilz, Kurt. "Ha
124733       Koschatzky, Wal
124733       Johnson, Mark M
124733       Kaufmann, Thoma
124733        Koreny, Fritz.
124733       Achilles-Syndra
124733       Schoch, Rainer,
124733       DeGrazia, Diane
124733       Dunbar, Burton 
  1. 创建一个创作者 DataFrame:

    creatorsvars = \
      ['creator','birth_year','death_year']
    cmacreators = cma[creatorsvars].\
      reset_index().\
      drop_duplicates(['itemid','creator']).\
      set_index(['itemid'])
    cmacreators.loc[124733] 
    
     creator        birth_year      death_year
    itemid                                                     
    124733     Hans Hoffmann (             1545            1592
    124733     Albrecht Dürer              1471            1528 
    
  2. 统计出生在 1950 年后创作者的集合项数量。

首先,将 birth_year 值从字符串转换为数字。然后,创建一个只包含年轻艺术家的 DataFrame:

cmacreators['birth_year'] = \
  cmacreators.birth_year.str.\
  findall("\d+").str[0].astype(float)
youngartists = \
  cmacreators.loc[cmacreators.birth_year>1950,
  ['creator']].assign(creatorbornafter1950='Y')
youngartists.shape[0]==youngartists.index.nunique() 
True 
youngartists 
 creator creatorbornafter1950
itemid                                     
168529  Richard Barnes                     Y
369885  Simone Leigh (A                    Y
371392  Belkis Ayón (Cu                    Y
378931  Teresa Margolle                    Y 
  1. 现在,我们可以将 youngartists DataFrame 与集合 DataFrame 合并,创建一个标记,用于标识至少有一个创作者出生在 1950 年后 的集合项:

    cmacollections = \
      pd.merge(cmacollections, youngartists,
      left_on=['itemid'], right_on=['itemid'], how='left')
    cmacollections.fillna({'creatorbornafter1950':'N'}, inplace=True)
    cmacollections.shape 
    
    (986, 9) 
    
    cmacollections.creatorbornafter1950.value_counts() 
    
    creatorbornafter1950
    N    982
    Y      4
    Name: count, dtype: int64 
    

现在我们有了三个 DataFrame——集合项(cmacollections)、引用(cmacitations)和创作者(cmacreators)——而不是一个。cmacollectionscmacitationscmacreators 都存在一对多关系。

它是如何工作的……

如果你主要直接处理企业数据,你可能很少会看到这种结构的文件,但许多人并没有这么幸运。如果我们从博物馆请求关于其收藏的媒体引用和创作者的数据,得到类似这样的数据文件并不完全令人惊讶,其中引用和创作者的数据是重复的。但看起来像是集合项唯一标识符的存在,让我们有希望恢复集合项与其引用之间、一对多的关系,以及集合项与创作者之间、一对多的关系。

步骤 2 显示有 986 个独特的 itemid 值。这表明在 17,001 行的 DataFrame 中,可能只包含 986 个集合项。共有 12,941 对独特的 itemidcitation,即每个集合项平均有约 13 条引用。共有 1,062 对 itemidcreator

步骤 3 展示了集合项目值(如 title)的重复情况。返回的行数等于左右合并条件的笛卡尔积。对于 Dead Blue Roller 项目,共有 28 行(我们在步骤 3中只展示了其中 6 行),因为它有 14 个引用和 2 个创作者。每个创作者的行会被重复 14 次;每个引用重复一次,针对每个创作者。每个引用会出现两次;一次针对每个创作者。对于非常少的用例,保留这种状态的数据是有意义的。

我们的“北极星”是 itemid 列,它帮助我们将数据转化为更好的结构。在步骤 4中,我们利用它来创建集合 DataFrame。我们仅保留每个 itemid 值的一行,并获取与集合项目相关的其他列,而非引用或创作者——titlecategorycreation_date(因为 itemid 是索引,我们需要先重置索引,然后再删除重复项)。

我们按照相同的程序,在步骤 6步骤 7 中分别创建 citationscreators DataFrame。我们使用 drop_duplicates 保留 itemidcitation 的唯一组合,和 itemidcreator 的唯一组合。这让我们得到了预期的行数:14 行 citations 数据和 2 行 creators 数据。

步骤 8 展示了我们如何使用这些 DataFrame 来构建新列并进行分析。我们想要计算至少有一个创作者出生在 1950 年之后的集合项目数量。分析的单位是集合项目,但我们需要从创作者 DataFrame 中获取信息来进行计算。由于 cmacollectionscmacreators 之间是多对一的关系,我们确保在创作者 DataFrame 中每个 itemid 只获取一行数据,即使某个项目有多个创作者出生在 1950 年之后:

youngartists.shape[0]==youngartists.index.nunique() 

还有更多…

当我们处理定量数据时,多对多合并所产生的重复数据最为棘手。如果原始文件中包含了每个集合项目的评估价值,这些值将像 title 一样被重复。如果我们对评估价值生成描述性统计信息,结果会偏差很大。例如,如果 Dead Blue Roller 项目的评估价值为 1,000,000 美元,在汇总评估价值时,我们将得到 28,000,000 美元,因为有 28 个重复值。

这突显了标准化和整洁数据的重要性。如果有评估价值列,我们会将其包含在步骤 4中创建的 cmacollections DataFrame 中。这个值将不会被重复,并且我们能够为集合生成汇总统计数据。

我发现始终回到分析单位是非常有帮助的,它与整洁数据的概念有重叠,但在某些方面有所不同。如果我们只关心 1950 年后出生的创作者的数量,而不是 1950 年后出生的创作者所对应的收藏项数量,第 8 步中的方法会完全不同。在这种情况下,分析单位将是创作者,我们只会使用创作者数据框。

另请参见

我们在第十章处理合并数据框时的数据问题部分中讨论了多对多合并。

我们在第十二章使用用户定义函数、类和管道自动化数据清理部分中的处理非表格数据结构的类示例中,展示了处理这种结构数据的完全不同方式。

使用 stack 和 melt 将数据从宽格式转换为长格式

Wickham 确定的一种不整洁数据类型是将变量值嵌入列名中。虽然在企业或关系型数据中这种情况很少发生,但在分析数据或调查数据中却相当常见。变量名可能会有后缀,指示时间段,如月份或年份。或者,调查中相似的变量可能有类似的名称,比如 familymember1agefamilymember2age,因为这样便于使用,并且与调查设计者对变量的理解一致。

调查数据中这种混乱相对频繁发生的一个原因是,一个调查工具上可能有多个分析单位。一个例子是美国的十年一次人口普查,它既询问家庭问题,也询问个人问题。调查数据有时还包括重复测量或面板数据,但通常每个受访者只有一行数据。在这种情况下,新测量值或新回答会存储在新列中,而不是新行中,列名将与早期时期的响应列名相似,唯一的区别是后缀的变化。

美国青年纵向调查NLS)是一个很好的例子。它是面板数据,每个个体每年都进行调查。然而,分析文件中每个受访者只有一行数据。类似“在某一年工作了多少周”这样的问题的回答会放入新的列中。整理 NLS 数据意味着将如 weeksworked17weeksworked21(即 2017 年到 2021 年间的工作周数)等列,转换成仅有一列表示工作周数,另一列表示年份,且每个人有五行数据(每年一行),而不是一行数据。这有时被称为将数据从宽格式转换为长格式

令人惊讶的是,pandas 有几个函数使得像这样的转换相对容易:stackmeltwide_to_long。我们在这个示例中使用 stackmelt,并在接下来的部分探讨 wide_to_long

准备工作

我们将处理 NLS 中每年工作周数和大学入学状态的数据。DataFrame 中每行对应一位调查参与者。

数据说明

国家纵向调查NLS),由美国劳工统计局管理,是对 1997 年开始时在高中的个体进行的纵向调查。参与者每年接受一次调查,直到 2023 年。调查数据可供公众使用,网址为nlsinfo.org

如何操作…

我们将使用stackmelt将 NLS 工作周数据从宽格式转换为长格式,同时提取列名中的年份值:

  1. 导入pandas和 NLS 数据:

    import pandas as pd
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False) 
    
  2. 查看一些工作周数的值。

首先,设置索引:

nls97.set_index(['originalid'], inplace=True)
weeksworkedcols = ['weeksworked17','weeksworked18',
  'weeksworked19','weeksworked20','weeksworked21']
nls97.loc[[2,3],weeksworkedcols].T 
originalid          2       3
weeksworked17      52      52
weeksworked18      52      52
weeksworked19      52       9
weeksworked20      52       0
weeksworked21      46       0 
nls97.shape 
(8984, 110) 
  1. 使用stack将数据从宽格式转换为长格式。

首先,仅选择weeksworked##列。使用 stack 将原始 DataFrame 中的每个列名移入索引,并将weeksworked##的值移入相应的行。重置index,使得weeksworked##列名成为level_1列(我们将其重命名为year)的值,weeksworked##的值成为 0 列(我们将其重命名为weeksworked)的值:

数据说明

对于未来升级到pandas 3.0,我们需要在stack函数中提到关键字参数(future_stack=True)

weeksworked = nls97[weeksworkedcols].\
  stack().\
  reset_index().\
  rename(columns={'level_1':'year',0:'weeksworked'})
weeksworked.loc[weeksworked.originalid.isin([2,3])] 
 originalid                year       weeksworked
5            2       weeksworked17                52
6            2       weeksworked18                52
7            2       weeksworked19                52
8            2       weeksworked20                52
9            2       weeksworked21                46
10           3       weeksworked17                52
11           3       weeksworked18                52
12           3       weeksworked19                 9
13           3       weeksworked20                 0
14           3       weeksworked21                 0 
  1. 修正year值。

获取年份值的最后几位数字,将其转换为整数,并加上 2,000:

weeksworked['year'] = \
  weeksworked.year.str[-2:].astype(int)+2000
weeksworked.loc[weeksworked.originalid.isin([2,3])] 
 originalid       year       weeksworked
5                 2       2017                52
6                 2       2018                52
7                 2       2019                52
8                 2       2020                52
9                 2       2021                46
10                3       2017                52
11                3       2018                52
12                3       2019                 9
13                3       2020                 0
14                3       2021                 0 
weeksworked.shape 
(44920, 3) 
  1. 或者,使用melt将数据从宽格式转换为长格式。

首先,重置index并选择originalidweeksworked##列。使用meltid_varsvalue_vars参数,指定originalid作为ID变量,并将weeksworked##列作为要旋转或熔化的列。使用var_namevalue_name参数将列名重命名为yearweeksworkedvalue_vars中的列名成为新year列的值(我们使用原始后缀将其转换为整数)。value_vars列的值被分配到新weeksworked列中的相应行:

weeksworked = nls97.reset_index().\
  loc[:,['originalid'] + weeksworkedcols].\
  melt(id_vars=['originalid'],
  value_vars=weeksworkedcols,
  var_name='year', value_name='weeksworked')
weeksworked['year'] = \
  weeksworked.year.str[-2:].astype(int)+2000
weeksworked.set_index(['originalid'], inplace=True)
weeksworked.loc[[2,3]] 
 year       weeksworked
originalid                       
2                2017                52
2                2018                52
2                2019                52
2                2020                52
2                2021                46
3                2017                52
3                2018                52
3                2019                 9
3                2020                 0
3                2021                 0 
  1. 使用melt重塑大学入学列。

这与melt函数在处理工作周数列时的作用相同:

colenrcols = \
  ['colenroct17','colenroct18','colenroct19',
  'colenroct20','colenroct21']
colenr = nls97.reset_index().\
  loc[:,['originalid'] + colenrcols].\
  melt(id_vars=['originalid'], value_vars=colenrcols,
var_name='year', value_name='colenr')
colenr['year'] = colenr.year.str[-2:].astype(int)+2000
colenr.set_index(['originalid'], inplace=True)
colenr.loc[[2,3]] 
 year                     colenr
originalid                                
2                2017            1\. Not enrolled
2                2018            1\. Not enrolled
2                2019            1\. Not enrolled
2                2020            1\. Not enrolled
2                2021            1\. Not enrolled
3                2017            1\. Not enrolled
3                2018            1\. Not enrolled
3                2019            1\. Not enrolled
3                2020            1\. Not enrolled
3                2021            1\. Not enrolled 
  1. 合并工作周数和大学入学数据:

    workschool = \
      pd.merge(weeksworked, colenr, on=['originalid','year'], how="inner")
    workschool.shape 
    
    (44920, 3) 
    
    workschool.loc[[2,3]] 
    
     year       weeksworked                colenr
    originalid                                        
    2           2017                52       1\. Not enrolled
    2           2018                52       1\. Not enrolled
    2           2019                52       1\. Not enrolled
    2           2020                52       1\. Not enrolled
    2           2021                46       1\. Not enrolled
    3           2017                52       1\. Not enrolled
    3           2018                52       1\. Not enrolled
    3           2019                 9       1\. Not enrolled
    3           2020                 0       1\. Not enrolled
    3           2021                 0       1\. Not enrolled 
    

这将通过熔化工作周数和大学入学列,生成一个 DataFrame。

它是如何工作的…

我们可以使用stackmelt将数据从宽格式重塑为长格式,但melt提供了更多的灵活性。stack会将所有列名移动到索引中。我们在第 4 步中看到,堆叠后得到了预期的行数44920,这等于 5*8984,即初始数据中的行数。

使用melt,我们可以根据不同于索引的ID变量旋转列名和值。我们通过id_vars参数来实现这一点。我们使用value_vars参数指定要旋转的变量。

步骤 6 中,我们还重新塑造了大学入学的列。为了将重新塑造后的工作周和大学入学数据合并为一个 DataFrame,我们合并了 步骤 5步骤 6 中创建的两个 DataFrame。我们将在下一个配方中看到如何一步完成 步骤 5步骤 7 的工作。

融合多个列组

在前一个配方中,当我们需要融合多个列组时,我们使用了两次 melt 然后合并了结果的 DataFrame。那样也可以,但我们可以用 wide_to_long 函数在一步内完成相同的任务。wide_to_long 的功能比 melt 强大,但使用起来稍微复杂一些。

准备工作

我们将在本配方中使用 NLS 的工作周和大学入学数据。

如何操作…

我们将使用 wide_to_long 一次性转换多个列组:

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

    import pandas as pd
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
    nls97.set_index('personid', inplace=True) 
    
  2. 查看一些工作周和大学入学的数据:

    weeksworkedcols = ['weeksworked17','weeksworked18',
      'weeksworked19','weeksworked20','weeksworked21']
    colenrcols = ['colenroct17','colenroct18',
       'colenroct19','colenroct20','colenroct21']
    nls97.loc[nls97.originalid.isin([2,3]),
      ['originalid'] + weeksworkedcols + colenrcols].T 
    
    personid                     999406                151672
    originalid                        2                     3
    weeksworked17                    52                    52
    weeksworked18                    52                    52
    weeksworked19                    52                     9
    weeksworked20                    52                     0
    weeksworked21                    46                     0
    colenroct17         1\. Not enrolled       1\. Not enrolled
    colenroct18         1\. Not enrolled       1\. Not enrolled
    colenroct19         1\. Not enrolled       1\. Not enrolled
    colenroct20         1\. Not enrolled       1\. Not enrolled
    colenroct21         1\. Not enrolled       1\. Not enrolled 
    
  3. 运行 wide_to_long 函数。

将一个列表传递给 stubnames 以指示所需的列组。(所有列名以列表中每一项的相同字符开头的列都会被选中进行转换。)使用 i 参数指示 ID 变量(originalid),并使用 j 参数指定基于列后缀(如 1718 等)命名的列(year):

workschool = pd.wide_to_long(nls97[['originalid']
...   + weeksworkedcols + colenrcols],
...   stubnames=['weeksworked','colenroct'],
...   i=['originalid'], j='year').reset_index()
workschool['year'] = workschool.year+2000
workschool = workschool.\
...   sort_values(['originalid','year'])
workschool.set_index(['originalid'], inplace=True)
workschool.loc[[2,3]] 
 year       weeksworked             colenroct
originalid                                        
2                2017                52       1\. Not enrolled
2                2018                52       1\. Not enrolled
2                2019                52       1\. Not enrolled
2                2020                52       1\. Not enrolled
2                2021                46       1\. Not enrolled
3                2017                52       1\. Not enrolled
3                2018                52       1\. Not enrolled
3                2019                 9       1\. Not enrolled
3                2020                 0       1\. Not enrolled
3                2021                 0       1\. Not enrolled 

wide_to_long 一步完成了我们在前一个配方中使用 melt 需要多个步骤才能完成的工作。

工作原理…

wide_to_long 函数几乎为我们完成了所有工作,尽管它的设置比 stackmelt 要复杂一些。我们需要向函数提供列组的字符(在这个例子中是 weeksworkedcolenroct)。由于我们的变量名称带有表示年份的后缀,wide_to_long 会将这些后缀转换为有意义的值,并将它们融合到用 j 参数命名的列中。这几乎就像魔法一样!

还有更多…

本配方中 stubnames 列的后缀是相同的:17 到 21。但这不一定是必然的。当某个列组有后缀,而另一个没有时,后者列组对应后缀的值将会缺失。通过排除 DataFrame 中的 weeksworked17 并添加 weeksworked16,我们可以看到这一点:

weeksworkedcols = ['weeksworked16','weeksworked18',
  'weeksworked19','weeksworked20','weeksworked21']
workschool = pd.wide_to_long(nls97[['originalid']
...   + weeksworkedcols + colenrcols],
...   stubnames=['weeksworked','colenroct'],
...   i=['originalid'], j='year').reset_index()
workschool['year'] = workschool.year+2000
workschool = workschool.sort_values(['originalid','year'])
workschool.set_index(['originalid'], inplace=True)
workschool.loc[[2,3]] 
 year       weeksworked             colenroct
originalid                                                  
2                2016                53                   NaN
2                2017               NaN       1\. Not enrolled
2                2018                52       1\. Not enrolled
2                2019                52       1\. Not enrolled
2                2020                52       1\. Not enrolled
2                2021                46       1\. Not enrolled
3                2016                53                   NaN
3                2017               NaN       1\. Not enrolled
3                2018                52       1\. Not enrolled
3                2019                 9       1\. Not enrolled
3                2020                 0       1\. Not enrolled
3                2021                 0       1\. Not enrolled 

现在,2017 年的 weeksworked 值缺失了,2016 年的 colenroct 值也缺失了。

使用 unstackpivot 将数据从长格式转换为宽格式

有时候,我们实际上需要将数据从整洁格式转换为杂乱格式。这通常是因为我们需要将数据准备为某些不擅长处理关系型数据的软件包分析,或者因为我们需要提交数据给某个外部机构,而对方要求以杂乱格式提供数据。unstackpivot 在需要将数据从长格式转换为宽格式时非常有用。unstack 做的是与我们使用 stack 的操作相反的事,而 pivot 做的则是与 melt 相反的操作。

准备工作

我们在本食谱中继续处理关于工作周数和大学入学的 NLS 数据。

如何操作……

我们使用unstackpivot将融化的 NLS 数据框恢复到其原始状态:

  1. 导入pandas并加载堆叠和融化后的 NLS 数据:

    import pandas as pd
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
    nls97.set_index(['originalid'], inplace=True) 
    
  2. 再次堆叠数据。

这重复了本章早期食谱中的堆叠操作:

weeksworkedcols = ['weeksworked17','weeksworked18',
  'weeksworked19','weeksworked20','weeksworked21']
weeksworkedstacked = nls97[weeksworkedcols].\
  stack()
weeksworkedstacked.loc[[2,3]] 
originalid              
2           weeksworked17        52
            weeksworked18        52
            weeksworked19        52
            weeksworked20        52
            weeksworked21        46
3           weeksworked17        52
            weeksworked18        52
            weeksworked19         9
            weeksworked20         0
            weeksworked21         0
dtype: float64 
  1. 再次融化数据。

这重复了本章早期食谱中的melt操作:

weeksworkedmelted = nls97.reset_index().\
...   loc[:,['originalid'] + weeksworkedcols].\
...   melt(id_vars=['originalid'],
...   value_vars=weeksworkedcols,
...   var_name='year', value_name='weeksworked')
weeksworkedmelted.loc[weeksworkedmelted.\
  originalid.isin([2,3])].\
  sort_values(['originalid','year']) 
 originalid                year       weeksworked
1                    2       weeksworked17                52
8985                 2       weeksworked18                52
17969                2       weeksworked19                52
26953                2       weeksworked20                52
35937                2       weeksworked21                46
2                    3       weeksworked17                52
8986                 3       weeksworked18                52
17970                3       weeksworked19                 9
26954                3       weeksworked20                 0
35938                3       weeksworked21                 0 
  1. 使用unstack将堆叠的数据从长格式转换为宽格式:

    weeksworked = weeksworkedstacked.unstack()
    weeksworked.loc[[2,3]].T 
    
    originalid          2       3
    weeksworked17      52      52
    weeksworked18      52      52
    weeksworked19      52       9
    weeksworked20      52       0
    weeksworked21      46       0 
    
  2. 使用pivot将融化的数据从长格式转换为宽格式。

pivotunstack稍微复杂一点。我们需要传递参数来执行 melt 的反向操作,告诉 pivot 使用哪个列作为列名后缀(year),并从哪里获取要取消融化的值(在本例中来自weeksworked列):

weeksworked = weeksworkedmelted.\
...   pivot(index='originalid',
...   columns='year', values=['weeksworked']).\
...   reset_index()
weeksworked.columns = ['originalid'] + \
...   [col[1] for col in weeksworked.columns[1:]]
weeksworked.loc[weeksworked.originalid.isin([2,3])].T 
 1       2
originalid          2       3
weeksworked17      52      52
weeksworked18      52      52
weeksworked19      52       9
weeksworked20      52       0
weeksworked21      46       0 

这将 NLS 数据返回到其原始的无序形式。

它是如何工作的……

我们首先在步骤 2步骤 3分别执行stackmelt。这将数据框从宽格式转换为长格式。然后我们使用unstack步骤 4)和pivot步骤 5)将数据框从长格式转换回宽格式。

unstack使用由stack创建的多重索引来确定如何旋转数据。

pivot函数需要我们指定索引列(originalid),将附加到列名中的列(year),以及包含要取消融化值的列名称(weeksworked)。pivot将返回多级列名。我们通过从第二级提取[col[1] for col in weeksworked.columns[1:]]来修复这个问题。

总结

本章中我们探讨了关键的 tidy 数据主题。这些主题包括处理重复数据,可以通过删除冗余数据的行或按组聚合来处理。我们还将以多对多格式存储的数据重构为 tidy 格式。最后,我们介绍了将数据从宽格式转换为长格式的几种方法,并在必要时将其转换回宽格式。接下来是本书的最后一章,我们将学习如何使用用户定义的函数、类和管道来自动化数据清理。

加入我们的社区,参与 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

第十二章:使用用户定义的函数、类和管道来自动化数据清理

编写可重用的代码有很多很好的理由。当我们从当前的数据清理问题中退一步,考虑它与非常相似的问题的关系时,实际上能够帮助我们加深对关键问题的理解。当我们将目光投向长期解决方案而非短期解决方案时,也更有可能以系统化的方式处理任务。这还带来了一个额外的好处,即帮助我们将数据处理的实质问题与操作数据的机制分开。

在本章中,我们将创建多个模块来完成常规的数据清理任务。这些模块中的函数和类是可以跨 DataFrame 或针对一个 DataFrame 在较长时间内重用的代码示例。这些函数处理了我们在前十一章中讨论的许多任务,但方式是允许我们重用代码的。

本章中的教程具体包括以下内容:

  • 获取数据初步信息的函数

  • 显示汇总统计信息和频率的函数

  • 识别异常值和意外值的函数

  • 聚合或合并数据的函数

  • 包含更新 Series 值逻辑的类

  • 处理非表格数据结构的类

  • 检查整体数据质量的函数

  • 使用管道进行数据预处理:一个简单的例子

  • 使用管道进行数据预处理:一个更复杂的例子

技术要求

完成本章中的教程,你将需要 pandas、NumPy 和 Matplotlib。我使用的是 pandas 2.1.4,但代码也能在 pandas 1.5.3 或更高版本上运行。

本章中的代码可以从本书的 GitHub 仓库下载,github.com/PacktPublishing/Python-Data-Cleaning-Cookbook-Second-Edition

获取数据初步信息的函数

我们在将数据导入 pandas DataFrame 后,所进行的最初几步操作基本上是相同的,不论数据的特点如何。我们几乎总是想知道列数和行数、列的数据类型,并查看前几行数据。我们也可能想查看索引,并检查是否存在唯一标识符来区分 DataFrame 的行。这些离散且容易重复的任务是很好的候选项,可以将其组织成一个模块中的函数。

在这个教程中,我们将创建一个包含函数的模块,这些函数可以让我们快速了解任何 pandas DataFrame 的基本情况。模块只是一个 Python 代码的集合,我们可以将其导入到其他 Python 程序中。模块便于重用,因为任何能够访问存储模块文件夹的程序都可以引用它。

准备工作

在这个示例中,我们创建两个文件:一个包含我们将用来查看数据的函数,另一个用来调用该函数。让我们将包含我们将使用的函数的文件命名为 basicdesciptives.py,并将其放在名为 helperfunctions 的子文件夹中。

在这个示例中,我们使用国家纵向调查NLS)数据。

数据注释

NLS 由美国劳工统计局管理,是一个关于在 1997 年高中毕业的个人的纵向调查集合。参与者每年接受调查直到 2023 年。这些调查可供公众在 nlsinfo.org 上使用。

如何做…

我们将创建一个函数来初步查看 DataFrame。

  1. 创建包含我们想要的函数的 basicdescriptives.py 文件。

getfirstlook 函数将返回一个包含 DataFrame 摘要信息的字典。将文件保存在 helperfunctions 子文件夹中,文件名为 basicdescriptives.py。(你也可以直接从 GitHub 仓库下载代码。)另外,创建一个函数(displaydict)来美化字典的显示:

import pandas as pd
def getfirstlook(df, nrows=5, uniqueids=None):
...   out = {}
...   out['head'] = df.head(nrows)
...   out['dtypes'] = df.dtypes
...   out['nrows'] = df.shape[0]
...   out['ncols'] = df.shape[1]
...   out['index'] = df.index
...   if (uniqueids is not None):
...     out['uniqueids'] = df[uniqueids].nunique()
...   return out
def displaydict(dicttodisplay):
...   print(*(': '.join(map(str, x)) \
...     for x in dicttodisplay.items()), sep='\n\n') 
  1. 创建一个单独的文件 firstlook.py,用来调用 getfirstlook 函数。

导入 pandasossys 库,并加载 NLS 数据:

import pandas as pd
import os
import sys
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True) 
  1. 导入 basicdescriptives 模块。

首先将 helperfunctions 子文件夹添加到 Python 路径中。然后我们可以导入 basicdescriptives。我们使用与要导入的模块文件名相同的名称。我们创建一个别名 bd,以便稍后更容易访问模块中的函数。(如果我们需要重新加载 basicdescriptives,可以使用 importlib,这里被注释掉,因为我们在该模块中进行了一些更改。)

sys.path.append(os.getcwd() + "/helperfunctions")
import basicdescriptives as bd
# import importlib
# importlib.reload(bd) 
  1. 首先查看 NLS 数据。

我们可以将 DataFrame 直接传递给 basicdescriptives 模块中的 getfirstlook 函数,以快速摘要 NLS 数据。displaydict 函数为我们提供了字典的更漂亮打印:

dfinfo = bd.getfirstlook(nls97)
bd.displaydict(dfinfo) 
head:       gender    birthmonth    birthyear    ...    parentincome  \
personid                                         ...               
135335     Female             9       1981       ...              -3  
999406       Male             7       1982       ...              -4  
151672     Female             9       1983       ...           63000  
750699     Female             2       1981       ...           11700  
781297       Male            10       1982       ...              -3  
          fatherhighgrade   motherhighgrade 
personid                                  
135335                16                  8 
999406                17                 15 
151672                -3                 12 
750699                12                 12 
781297                12                 12 
[5 rows x 110 columns]
dtypes: gender      object
birthmonth          int64
birthyear           int64
sampletype         object
ethnicity          object
originalid          int64
motherage           int64
parentincome        int64
fatherhighgrade     int64
motherhighgrade     int64
Length: 110, dtype: object
nrows: 8984
ncols: 110
index: Index([135335, 999406, 151672, 750699, 781297, 613800, 403743,
       474817, 530234, 351406,
       ...
       290800, 209909, 756325, 543646, 411195, 505861, 368078,
       215605, 643085, 713757],
      dtype='int64', name='personid', length=8984) 
  1. 将值传递给 getfirstlooknrowsuniqueids 参数。

这两个参数默认值分别为 5 和 None,除非我们提供值:

dfinfo = bd.getfirstlook(nls97,2,'originalid')
bd.displaydict(dfinfo) 
head:       gender    birthmonth    birthyear    ...    parentincome  \
personid                                         ...               
135335      Female             9         1981    ...              -3  
999406        Male             7         1982    ...              -4  
         fatherhighgrade  motherhighgrade 
personid                                  
135335                16                8 
999406                17               15 
[2 rows x 110 columns]
dtypes: gender      object
birthmonth          int64
birthyear           int64
sampletype         object
ethnicity          object
originalid          int64
motherage           int64
parentincome        int64
fatherhighgrade     int64
motherhighgrade     int64
Length: 110, dtype: object
nrows: 8984
ncols: 110
index: Index([135335, 999406, 151672, 750699, 781297, 613800, 403743,
       474817, 530234, 351406,
       ...
       290800, 209909, 756325, 543646, 411195, 505861, 368078,
       215605, 643085, 713757],
      dtype='int64', name='personid', length=8984)
uniqueids: 8984 
  1. 使用一些返回的字典键和值。

我们还可以显示从 getfirstlook 返回的字典中选择的关键值。显示行数和数据类型,并检查每行是否具有 uniqueid 实例(dfinfo['nrows'] == dfinfo['uniqueids']):

dfinfo['nrows'] 
8984 
dfinfo['dtypes'] 
gender             object
birthmonth          int64
birthyear           int64
sampletype         object
ethnicity          object
originalid          int64
motherage           int64
parentincome        int64
fatherhighgrade     int64
motherhighgrade     int64
Length: 110, dtype: object 
dfinfo['nrows'] == dfinfo['uniqueids'] 
True 

让我们更仔细地看看函数的工作原理以及如何调用它。

工作原理…

在这个示例中,几乎所有的操作都在 getfirstlook 函数中,我们将在步骤 1中查看。我们将 getfirstlook 函数放在一个单独的文件中,命名为 basicdescriptives.py,我们可以将其作为模块导入,名称为该名称(去掉扩展名)。

我们本可以将函数键入到我们正在使用的文件中,并从那里调用它。但是,将其放入一个模块中,我们可以从任何具有对模块保存的文件夹的访问权限的文件中调用它。当我们在步骤 3中导入 basicdescriptives 模块时,我们加载了 basicdescriptives 中的所有代码,从而可以调用该模块中的所有函数。

getfirstlook 函数返回一个关于传递给它的 DataFrame 的有用信息的字典。我们看到前五行、列数和行数、数据类型和索引。通过向 uniqueid 参数传递一个值,我们还可以得到该列的唯一值数。

通过添加具有默认值的关键字参数(nrowsuniqueid),我们提高了 getfirstlook 的灵活性,而不会增加在不需要额外功能时调用函数所需的工作量。

在第一次调用中,在步骤 4中,我们没有为 nrowsuniqueid 传递值,保持默认值。在步骤 5中,我们指示只显示两行,并且要检查 originalid 的唯一值。

还有更多…

这个示例及其后续示例的重点不是提供可以下载并在自己的数据上运行的代码,尽管您当然可以这样做。我主要是想演示如何将您喜欢的数据清理方法收集到方便的模块中,以及如何通过这种方式实现轻松的代码重用。这里的具体代码只是一种供参考的建议。

每当我们使用位置参数和关键字参数的组合时,位置参数必须首先出现。

用于显示摘要统计和频率的函数

在与 DataFrame 工作的头几天,我们尝试对连续变量的分布和分类变量的计数有一个良好的了解。我们经常按选定的组进行计数。虽然 pandas 和 NumPy 有许多内置方法用于这些目的——describemeanvaluecountscrosstab 等等——数据分析师通常对如何使用这些工具有自己的偏好。例如,如果分析师发现他们通常需要看到比describe生成的更多的百分位数,他们可以使用自己的函数代替。我们将在这个示例中创建用于显示摘要统计和频率的用户定义函数。

准备工作

在这个示例中,我们将再次使用 basicdescriptives 模块。我们将定义的所有函数都保存在该模块中。我们将继续使用 NLS 数据。

如何做…

我们将使用我们创建的函数生成摘要统计和计数:

  1. basicdescriptives 模块中创建 gettots 函数。

该函数接受一个 pandas DataFrame,并创建一个包含选定摘要统计的字典。它返回一个 pandas DataFrame:

def gettots(df):
...   out = {}
...   out['min'] = df.min()
...   out['per15'] = df.quantile(0.15)
...   out['qr1'] = df.quantile(0.25)
...   out['med'] = df.median()
...   out['qr3'] = df.quantile(0.75)
...   out['per85'] = df.quantile(0.85)
...   out['max'] = df.max()
...   out['count'] = df.count()
...   out['mean'] = df.mean()
...   out['iqr'] = out['qr3']-out['qr1']
...   return pd.DataFrame(out) 
  1. 导入 pandasossys 库。

从一个不同的文件中执行此操作,您可以将其命名为 taking_measure.py

import pandas as pd
import os
import sys
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True) 
  1. 导入basicdescriptives模块:

    sys.path.append(os.getcwd() + "/helperfunctions")
    import basicdescriptives as bd 
    
  2. 显示连续变量的汇总统计。

使用我们在步骤 1中创建的basicdescriptives模块中的gettots函数:

bd.gettots(nls97[['satverbal','satmath']]).T 
 satverbal  satmath
min           14        7
per15        390      390
qr1          430      430
med          500      500
qr3          570      580
per85        620      621
max          800      800
count      1,406    1,407
mean         500      501
iqr          140      150 
bd.gettots(nls97.filter(like="weeksworked")) 
 min  per15  qr1  ...  count  mean  iqr
weeksworked00    0      0    5  ...   8626    26   45
weeksworked01    0      0   10  ...   8591    30   41
weeksworked02    0      0   13  ...   8591    32   39
weeksworked03    0      0   14  ...   8535    34   38
weeksworked04    0      1   17  ...   8513    35   35
weeksworked05    0      5   22  ...   8468    37   31
weeksworked06    0      9   27  ...   8419    38   25
weeksworked07    0     10   30  ...   8360    39   22
weeksworked08    0      9   30  ...   8292    39   22
weeksworked09    0      0   22  ...   8267    38   30
weeksworked10    0      0   21  ...   8198    37   31
weeksworked11    0      0   22  ...   8123    38   31
weeksworked12    0      0   23  ...   7988    38   29
weeksworked13    0      0   28  ...   7942    39   24
weeksworked14    0      0   26  ...   7896    39   26
weeksworked15    0      0   33  ...   7767    40   19
weeksworked16    0      0   31  ...   7654    40   22
weeksworked17    0      0   38  ...   7496    40   14
weeksworked18    0      0   35  ...   7435    40   17
weeksworked19    0      4   42  ...   7237    41   10
weeksworked20    0      0   21  ...   6971    38   31
weeksworked21    0      0   35  ...   6627    36   15
weeksworked22    0      0    2  ...   2202    11   17
[23 rows x 10 columns] 
  1. 创建一个函数,通过列和行来计算缺失值的数量。

getmissings函数将接受一个 DataFrame 和一个显示百分比或计数的参数。它返回两个 Series,一个显示每列的缺失值,另一个显示每行的缺失值。将该函数保存到basicdescriptives模块中:

def getmissings(df, byrowperc=False):
  return df.isnull().sum(),\
    df.isnull().sum(axis=1).\
    value_counts(normalize=byrowperc).\
    sort_index() 
  1. 调用getmissings函数。

首先调用它,将byrowperc(第二个参数)设置为True。这样可以显示每行缺失值数量的百分比。例如,missingbyrows值显示weeksworked20weeksworked21的 73%的行没有缺失值。然后再次调用它,保持byrowperc为默认值False,以获取计数:

missingsbycols, missingsbyrows = \
  bd.getmissings(nls97[['weeksworked20',
  'weeksworked21']], True)
missingsbycols 
weeksworked20    2013
weeksworked21    2357
dtype: int64 
missingsbyrows 
0   0.73
1   0.05
2   0.22
Name: proportion, dtype: float64 
missingsbycols, missingsbyrows = \
  bd.getmissings(nls97[['weeksworked20',
  'weeksworked21']])
missingsbyrows 
0    6594
1     410
2    1980
Name: count, dtype: int64 
  1. 创建一个函数,计算所有类别变量的频率。

makefreqs函数遍历传递的 DataFrame 中所有类别数据类型的列,并对每一列运行value_counts。频率将保存到由outfile指定的文件中:

def makefreqs(df, outfile):
...   freqout = open(outfile, 'w')
...   for col in df.\
...     select_dtypes(include=["category"]):
...       print(col, "----------------------",
...         "frequencies",
...         df[col].value_counts().sort_index(),
...         "percentages",
...         df[col].value_counts(normalize=True).\
...         sort_index(),
...         sep="\n\n", end="\n\n\n",
...         file=freqout)
...   freqout.close() 
  1. 调用makefreqs函数。

首先,将每个对象列的数据类型更改为category。此调用会对 NLS DataFrame 中的类别数据列运行value_counts,并将频率保存到当前文件夹中的views子文件夹下的nlsfreqs.txt文件中。

nls97.loc[:, nls97.dtypes == 'object'] = \
...   nls97.select_dtypes(['object']). \
...   apply(lambda x: x.astype('category'))
bd.makefreqs(nls97, "views/nlsfreqs.txt") 
  1. 创建一个函数,根据组来获取计数。

getcnts函数计算cats(一个列名列表)中每一组合列值的行数。它还计算排除cats中最后一列后的每一组合列值的行数。这将提供最后一列所有值的总数。(接下来的步骤会展示它的效果。)

def getcnts(df, cats, rowsel=None):
...   tots = cats[:-1]
...   catcnt = df.groupby(cats, dropna=False).size().\
...     reset_index(name='catcnt')
...   totcnt = df.groupby(tots, dropna=False).size().\
...     reset_index(name='totcnt')
...   percs = pd.merge(catcnt, totcnt, left_on=tots,
...     right_on=tots, how="left")
...   percs['percent'] = percs.catcnt / percs.totcnt
...   if (rowsel is not None):
...     percs = percs.loc[eval("percs." + rowsel)]
...   return percs 
  1. maritalstatuscolenroct00列传递给getcnts函数。

这将返回一个 DataFrame,包含每个列值组合的计数,以及排除最后一列后的所有列值组合的计数。此结果用于计算组内的百分比。例如,有 669 名受访者已离婚,其中 560 人(即 84%)在 2000 年 10 月时没有入学:

bd.getcnts(nls97,
  ['maritalstatus','colenroct00']) 
 maritalstatus           colenroct00   catcnt   totcnt   percent
0        Divorced       1\. Not enrolled      560      669      0.84
1        Divorced     2\. 2-year college       50      669      0.07
2        Divorced     3\. 4-year college       59      669      0.09
3         Married       1\. Not enrolled     2264     3068      0.74
4         Married     2\. 2-year college      236     3068      0.08
5         Married     3\. 4-year college      568     3068      0.19
6   Never-married       1\. Not enrolled     2363     2767      0.85
7   Never-married     2\. 2-year college      131     2767      0.05
8   Never-married     3\. 4-year college      273     2767      0.10
9       Separated       1\. Not enrolled      127      148      0.86
10      Separated     2\. 2-year college       13      148      0.09
11      Separated     3\. 4-year college        8      148      0.05
12        Widowed       1\. Not enrolled       19       23      0.83
13        Widowed     2\. 2-year college        1       23      0.04
14        Widowed     3\. 4-year college        3       23      0.13
15            NaN       1\. Not enrolled     1745     2309      0.76
16            NaN     2\. 2-year college      153     2309      0.07
17            NaN     3\. 4-year college      261     2309      0.11
18            NaN                   NaN     150      2309      0.06 
  1. 使用getcntsrowsel参数限制输出为特定行。这将仅显示未入学的行:

    bd.getcnts(nls97,
      ['maritalstatus','colenroct20'],
      "colenroct20.str[0:1]=='1'") 
    
     maritalstatus       colenroct00   catcnt    totcnt    percent
    0         Divorced   1\. Not enrolled      560       669       0.84
    3          Married   1\. Not enrolled     2264      3068       0.74
    6    Never-married   1\. Not enrolled     2363      2767       0.85
    9        Separated   1\. Not enrolled      127       148       0.86
    12         Widowed   1\. Not enrolled       19        23       0.83
    15             NaN   1\. Not enrolled     1745      2309       0.76 
    

这些步骤展示了如何创建函数并使用它们生成汇总统计和频率。

它是如何工作的…

步骤 1中,我们创建了一个名为gettots的函数,用于计算 DataFrame 中所有列的描述性统计,并将这些结果返回为一个汇总的 DataFrame。大多数统计量可以通过describe方法生成,但我们添加了一些额外的统计量——第 15 百分位数、第 85 百分位数和四分位距。我们在步骤 4中调用了这个函数两次,第一次是针对 SAT 语言和数学成绩,第二次是针对所有工作周数的列。

步骤 5步骤 6 创建并调用一个函数,显示传递给 DataFrame 的每列的缺失值数量。该函数还计算每行的缺失值数量,并显示缺失值的频率。通过将True传递给byrowperc参数,还可以将每行的缺失值频率显示为所有行的百分比。

步骤 7步骤 8 会生成一个文本文件,其中包含传递给 DataFrame 的所有分类变量的频率。我们只需循环遍历所有类别数据类型的列,并运行value_counts。由于输出通常很长,我们将其保存到文件中。将频率保存在某处以供以后参考也是很有用的。

我们在 步骤 9 中创建并在 步骤 10步骤 11 中调用的getcnts函数有点特殊。pandas 有一个非常有用的crosstab函数,我经常使用。但我经常需要一种简单的方法来查看组内子组的组计数和百分比。getcnts函数可以做到这一点。

还有更多…

即使一个函数没有做很多事情,它也可能非常有帮助。getmissings函数中的代码并不多,但我经常检查缺失值,所以小小的时间节省在累积起来时是显著的。它还提醒我按列和按行检查缺失值。

另请参阅

我们将在 第三章测量数据中探索 pandas 用于生成摘要统计信息和频率的工具。

用于识别异常值和意外值的函数

如果我必须选择一个数据清理领域,我发现可重复使用的代码最有益处的领域,那就是识别异常值和意外值。这是因为我们的先前假设通常导致我们关注分布的中心趋势,而不是极端值。快速想象一只猫——除非你在想你生活中的某只特定猫,否则你脑海中可能会浮现一只体重在 8 到 10 磅之间的普通猫;而不是一只体重为 6 磅或 22 磅的猫。

我们经常需要更有意识地提升极端值到意识中。在这里,拥有一套标准的诊断函数来运行我们的数据非常有帮助。即使没有特别的触发条件,我们也可以运行这些函数。这个示例提供了我们可以定期使用的函数示例,用于识别异常值和意外值。

准备工作

在这个示例中,我们将创建两个文件,一个包含我们将用来检查异常值的函数,另一个包含我们将用来调用这些函数的代码。让我们把包含我们将使用的函数的文件命名为outliers.py,并将其放在一个名为helperfunctions的子文件夹中。

为了运行本示例中的代码,你需要matplotlibscipy库,除了 pandas。你可以通过在终端客户端或 Windows PowerShell 中输入pip install matplotlibpip install scipy来安装matplotlibscipy。你还需要pprint实用程序,你可以通过pip install pprint来安装。

在本示例中,我们将处理 NLS 和 COVID-19 数据。COVID-19 数据中每一行代表一个国家,包含该国的累计病例和死亡数。

数据说明

Our World in Data 提供了 COVID-19 公共使用数据,网址:ourworldindata.org/covid-cases。该数据集包括各国的累计病例和死亡数、已进行的测试、医院床位以及一些人口统计数据,如中位年龄、国内生产总值和糖尿病患病率。本示例使用的数据集是在 2024 年 3 月 3 日下载的。

如何操作…

我们创建并调用函数来检查变量的分布、列出极端值并可视化分布:

  1. 导入pandasossyspprint库。

同时,加载 NLS 和 COVID-19 数据:

import pandas as pd
import os
import sys
import pprint
nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97.set_index('personid', inplace=True)
covidtotals = pd.read_csv("data/covidtotals.csv") 
  1. 创建一个函数来展示分布的一些重要属性。

getdistprops函数接受一个 Series 并生成中心趋势、形状和分布的度量。该函数返回一个包含这些度量的字典。它还处理 Shapiro 正态性检验未返回值的情况。若发生这种情况,将不会为normstatnormpvalue添加键。将该函数保存在当前目录下helperfunctions子文件夹中的一个名为outliers.py的文件中。(同时加载我们在此模块中其他函数需要的pandasmatplotlibscipymath库。)

import pandas as pd
import matplotlib.pyplot as plt
import scipy.stats as scistat
import math
def getdistprops(seriestotest):
...   out = {}
...   normstat, normpvalue = scistat.shapiro(seriestotest)
...   if (not math.isnan(normstat)):
...     out['normstat'] = normstat
...     if (normpvalue>=0.05):
...       out['normpvalue'] = str(round(normpvalue, 2)) + ": Accept Normal"
...     elif (normpvalue<0.05):
...       out['normpvalue'] = str(round(normpvalue, 2)) + ": Reject Normal"
...   out['mean'] = seriestotest.mean()
...   out['median'] = seriestotest.median()
...   out['std'] = seriestotest.std()
...   out['kurtosis'] = seriestotest.kurtosis()
...   out['skew'] = seriestotest.skew()
...   out['count'] = seriestotest.count()
...   return out 
  1. 将每百万总病例数的 Series 传递给getdistprops函数。

skewkurtosis值表明total_cases_pm的分布有正偏态且尾部比正态分布的变量更短。Shapiro 正态性检验(normpvalue)证实了这一点。(使用pprint改进getdistprops返回的字典的显示效果。)

dist = ol.getdistprops(covidtotals.total_cases_pm)
pprint.pprint(dist) 
{'count': 231,
 'kurtosis': -0.4280595203351645,
 'mean': 206177.79462337663,
 'median': 133946.251,
 'normpvalue': '0.0: Reject Normal',
 'normstat': 0.8750641345977783,
 'skew': 0.8349032460009967,
 'std': 203858.09625231632} 
  1. 创建一个函数来列出 DataFrame 中的异常值。

getoutliers函数遍历sumvars中的所有列。它为这些列确定异常值阈值,设置阈值为第一四分位数下方或第三四分位数上方 1.5 倍四分位间距的值。然后,它选择所有超出高阈值或低阈值的行。它还会添加表示检查的变量(varname)的列以及阈值水平的列。它还会包括othervars列表中的列,并将其返回为 DataFrame:

def getoutliers(dfin, sumvars, othervars):
...   dfin = dfin[sumvars + othervars]
...   dfout = pd.DataFrame(columns=dfin.columns, data=None)
...   dfsums = dfin[sumvars]
...   for col in dfsums.columns:
...     thirdq, firstq = dfsums[col].quantile(0.75),\
...       dfsums[col].quantile(0.25)
...     interquartilerange = 1.5*(thirdq-firstq)
...     outlierhigh, outlierlow = interquartilerange+thirdq,\
...       firstq-interquartilerange
...     df = dfin.loc[(dfin[col]>outlierhigh) | \
...       (dfin[col]<outlierlow)]
...     df = df.assign(varname = col, threshlow = outlierlow,\
...       threshhigh = outlierhigh)
...     dfout = pd.concat([dfout, df])
...   return dfout 
  1. 调用getoutlier函数。

将检查异常值的列的列表(sumvars)和要包含在返回的 DataFrame 中的列的另一个列表(othervars)传递给函数。展示每个变量的异常值计数,并查看 SAT 数学的异常值:

sumvars = ['satmath','wageincome20']
othervars = ['originalid','highestdegree','gender','maritalstatus']
outliers = ol.getoutliers(nls97, sumvars, othervars)
outliers.varname.value_counts(sort=False) 
varname
satmath          10
wageincome20    234
Name: count, dtype: int64 
outliers.loc[outliers.varname=='satmath', othervars + sumvars] 
 originalid      highestdegree   ...    satmath    wageincome20
337438         159     2\. High School   ...     200.00       32,000.00
448463         326       4\. Bachelors   ...      47.00             NaN
799095         535         5\. Masters   ...      59.00             NaN
267254        1622     2\. High School   ...      48.00             NaN
955430        2547     2\. High School   ...     200.00             NaN
748274        3394       4\. Bachelors   ...      42.00             NaN
399109        3883     2\. High School   ...      36.00       37,000.00
223058        6696            0\. None   ...      46.00             NaN
291029        7088     2\. High School   ...      51.00             NaN
738290        7705     2\. High School   ...       7.00       50,000.00
[10 rows x 6 columns] 
outliers.to_excel("views/nlsoutliers.xlsx") 
  1. 创建一个生成直方图和箱型图的函数。

makeplot函数接受一个 Series、标题和x-轴的标签。默认绘图类型为直方图:

def makeplot(seriestoplot, title, xlabel, plottype="hist"):
...   if (plottype=="hist"):
...     plt.hist(seriestoplot)
...     plt.axvline(seriestoplot.mean(), color='red',\
...       linestyle='dashed', linewidth=1)
...     plt.xlabel(xlabel)
...     plt.ylabel("Frequency")
...   elif (plottype=="box"):
...     plt.boxplot(seriestoplot.dropna(), labels=[xlabel])
...   plt.title(title)
...   plt.show() 
  1. 调用makeplot函数来创建直方图:

    ol.makeplot(nls97.satmath, "Histogram of SAT Math", "SAT Math") 
    

这将生成以下直方图:

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

图 12.1:SAT 数学值的频率分布

  1. 使用makeplot函数创建一个箱线图:

    ol.makeplot(nls97.satmath, "Boxplot of SAT Math", "SAT Math", "box") 
    

这将生成以下箱线图:

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

图 12.2:使用箱线图显示中位数、四分位距和异常值阈值

前面的步骤展示了我们如何开发可重复使用的代码来检查异常值和意外值。

工作原理…

我们首先通过将 Series 传递给步骤 3中的getdistprop函数来获取分布的关键属性,包括均值、中位数、标准差、偏度和峰度。我们得到一个包含这些度量值的字典。

步骤 4中的函数选择了sumvars中某一列具有异常值的行。它还包括了othervars列的值和返回的 DataFrame 中的阈值金额。

我们在步骤 6中创建了一个函数,使得创建简单直方图或箱线图变得更加容易。matplotlib的功能很强大,但是当我们只想创建一个简单的直方图或箱线图时,可能需要花一点时间来回想语法。我们可以通过定义一个带有几个常规参数的函数来避免这种情况:Series、标题和x-label。我们在步骤 78中调用该函数。

还有更多…

在对连续变量进行过多处理之前,我们需要先了解其数值分布情况;中心趋势和分布形状是什么?如果我们对关键连续变量运行类似本示例中的函数,那么我们就会有一个良好的起点。

Python 模块的相对轻松可移植性使得这变得相当容易。如果我们想使用本示例中使用的outliers模块,只需将outliers.py文件保存到我们的程序可以访问的文件夹中,将该文件夹添加到 Python 路径中,并导入它。

通常,当我们检查极端值时,我们希望更好地了解其他变量的背景,这些变量可能解释为什么该值是极端的。例如,178 厘米的身高对于成年男性来说不是异常值,但对于 9 岁的孩子来说绝对是异常值。步骤 45生成的 DataFrame 为我们提供了异常值以及可能相关的其他数据。将数据保存到 Excel 文件中使得以后检查异常行或与他人分享数据变得更加容易。

另请参阅

我们在第四章中详细讨论了如何检测异常值和意外值,在数据子集中识别异常值。我们在第五章中研究了直方图、箱线图和许多其他可视化方法,使用可视化方法识别意外值

用于聚合或合并数据的函数

大多数数据分析项目都需要对数据进行某种形状的调整。我们可能需要按组聚合数据,或者纵向或横向合并数据。在准备数据以进行这些形状调整时,我们每次都会进行类似的任务。我们可以通过函数将其中一些任务标准化,从而提高代码的可靠性和完成工作的效率。有时我们需要在合并之前检查按值合并的列是否匹配,检查面板数据在一个周期到下一个周期之间的值是否发生了意外变化,或者一次性连接多个文件并验证数据是否已正确合并。

这些只是数据聚合和组合任务的一些示例,这些任务可能更适合使用更通用的编码解决方案。在这个示例中,我们定义了可以帮助完成这些任务的函数。

准备工作

在这个示例中,我们将使用 COVID-19 每日数据。该数据包括按天计算的每个国家的新病例和新死亡人数。我们还将使用 2023 年多个国家的土地温度数据。每个国家的数据在单独的文件中,并且每个月的每个气象站有一行数据。

数据说明

土地温度数据框包含来自全球超过 12,000 个站点的 2023 年平均温度数据(单位:°C),尽管大多数站点位于美国。原始数据来自全球历史气候学网络集成数据库。美国国家海洋和大气管理局在www.ncei.noaa.gov/products/land-based-station/global-historical-climatology-network-monthly上为公众提供了这些数据。

如何实现…

我们将使用函数来聚合数据,纵向合并数据,并检查按值合并:

  1. 导入pandasossys库:

    import pandas as pd
    import os
    import sys 
    
  2. 创建一个函数(adjmeans)来按组汇总每个周期的值。

按组(byvar)和然后按period对传入的数据框的值进行排序。将数据框的值转换为 NumPy 数组。循环遍历这些值,按var列做一个累加,并在遇到byvar的新值时将累加值重置为 0。在进行聚合之前,检查每个周期之间值的极端变化。changeexclude参数表示从一个周期到下一个周期应该视为极端变化的大小。excludetype参数表示changeexclude值是var列均值的绝对值还是百分比。在helperfunctions子文件夹中将该函数保存在名为combineagg.py的文件中:

def adjmeans(df, byvar, var, period, changeexclude=None, excludetype=None):
...   df = df.sort_values([byvar, period])
...   df = df.dropna(subset=[var])
...   # iterate using numpy arrays
...   prevbyvar = 'ZZZ'
...   prevvarvalue = 0
...   rowlist = []
...   varvalues = df[[byvar, var]].values
...   # convert exclusion ratio to absolute number
...   if (excludetype=="ratio" and changeexclude is not None):
...     changeexclude = df[var].mean()*changeexclude
...   # loop through variable values
...   for j in range(len(varvalues)):
...     byvar = varvalues[j][0]
...     varvalue = varvalues[j][1]
...     if (prevbyvar!=byvar):
...       if (prevbyvar!='ZZZ'):
...         rowlist.append({'byvar':prevbyvar, 'avgvar':varsum/byvarcnt,\
...           'sumvar':varsum, 'byvarcnt':byvarcnt})
...       varsum = 0
...       byvarcnt = 0
...       prevbyvar = byvar
...     # exclude extreme changes in variable value
...     if ((changeexclude is None) or (0 <= abs(varvalue-prevvarvalue) \
...       <= changeexclude) or (byvarcnt==0)):
...       varsum += varvalue
...       byvarcnt += 1
...     prevvarvalue = varvalue
...   rowlist.append({'byvar':prevbyvar, 'avgvar':varsum/byvarcnt, \
...     'sumvar':varsum, 'byvarcnt':byvarcnt})
...   return pd.DataFrame(rowlist) 
  1. 导入combineagg模块:

    sys.path.append(os.getcwd() + "/helperfunctions")
    import combineagg as ca 
    
  2. 加载数据框(DataFrames):

    coviddaily = pd.read_csv("data/coviddaily.csv")
    ltbrazil = pd.read_csv("data/ltbrazil.csv")
    countries = pd.read_csv("data/ltcountries.csv")
    locations = pd.read_csv("data/ltlocations.csv") 
    
  3. 调用adjmeans函数按组和时间周期汇总面板数据。

指明我们希望按locationnew_cases进行汇总:

ca.adjmeans(coviddaily, 'location','new_cases','casedate') 
 byvar     avgvar        sumvar     byvarcnt
0             Afghanistan      1,129       231,539          205
1                 Albania      1,914       334,863          175
2                 Algeria      1,439       272,010          189
3          American Samoa        144         8,359           58
4                 Andorra        304        48,015          158
..                    ...        ...           ...          ...
226               Vietnam     60,542    11,624,000          192
227     Wallis and Futuna        154         3,550           23
228                 Yemen         98        11,945          122
229                Zambia      2,019       349,304          173
230              Zimbabwe      1,358       266,266          196
[231 rows x 4 columns] 
  1. 再次调用adjmeans函数,这次排除new_cases从一天到下一天的变化超过 5,000 的值。注意,一些国家的计数有所减少:

    ca.adjmeans(coviddaily, 'location','new_cases','casedate', 5000) 
    
     byvar     avgvar     sumvar     byvarcnt
    0             Afghanistan      1,129    231,539          205
    1                 Albania      1,855    322,772          174
    2                 Algeria      1,290    239,896          186
    3          American Samoa        144      8,359           58
    4                 Andorra        304     48,015          158
    ..                    ...        ...        ...          ...
    226               Vietnam      6,410    967,910          151
    227     Wallis and Futuna        154      3,550           23
    228                 Yemen         98     11,945          122
    229                Zambia      1,555    259,768          167
    230              Zimbabwe      1,112    214,526          193
    [231 rows x 4 columns] 
    
  2. 创建一个函数来检查一个文件中合并列的值,但在另一个文件中没有这些值。

checkmerge函数对传入的两个 DataFrame 进行外连接,使用第三个和第四个参数作为第一个和第二个 DataFrame 的合并列。然后,它生成一个交叉表,显示在两个 DataFrame 中都存在的合并列值的行数,以及只出现在一个 DataFrame 中而另一个 DataFrame 中不存在的行数。它还会显示最多 20 行只在一个文件中找到的合并列值的数据:

def checkmerge(dfleft, dfright, mergebyleft, mergebyright):
...   dfleft['inleft'] = "Y"
...   dfright['inright'] = "Y"
...   dfboth = pd.merge(dfleft[[mergebyleft,'inleft']],\
...     dfright[[mergebyright,'inright']], left_on=[mergebyleft],\
...     right_on=[mergebyright], how="outer")
...   dfboth.fillna('N', inplace=True)
...   print(pd.crosstab(dfboth.inleft, dfboth.inright))
...   print(dfboth.loc[(dfboth.inleft=='N') | (dfboth.inright=='N')].head(20)) 
  1. 调用checkmerge函数。

检查countries土地温度 DataFrame(每个国家一行)和locations DataFrame(每个国家每个气象站一行)之间的合并。交叉表显示,27,472 个合并列值同时存在于两个 DataFrame 中,两个值只在countries文件中,而不在locations文件中,一个值只在locations文件中,而不在countries文件中:

ca.checkmerge(countries.copy(), locations.copy(),\
...   "countryid", "countryid") 
inright    N        Y
inleft          
N          0        1
Y          2    27472
        countryid    inleft    inright
7363           FO         N          Y
9716           LQ         Y          N
13104          ST         Y          N 
  1. 创建一个函数,用于连接文件夹中的所有 CSV 文件。

该函数遍历指定文件夹中的所有文件名。它使用endswith方法检查文件名是否具有 CSV 文件扩展名。然后,它加载 DataFrame 并打印出行数。最后,它使用concat将新 DataFrame 的行追加到已追加的行中。如果文件中的列名不同,它会打印出这些列名:

def addfiles(directory):
...   dfout = pd.DataFrame()
...   columnsmatched = True
...   # loop through the files
...   for filename in os.listdir(directory):
...     if filename.endswith(".csv"):
...       fileloc = os.path.join(directory, filename)
...       # open the next file
...       with open(fileloc) as f:
...         dfnew = pd.read_csv(fileloc)
...         print(filename + " has " + str(dfnew.shape[0]) + " rows.")
...         dfout = pd.concat([dfout, dfnew])
...         # check if current file has any different columns
...         columndiff = dfout.columns.symmetric_difference(dfnew.columns)
...         if (not columndiff.empty):
...           print("", "Different column names for:", filename,\
...             columndiff, "", sep="\n")
...           columnsmatched = False
...   print("Columns Matched:", columnsmatched)
...   return dfout 
  1. 使用addfiles函数连接所有countries土地温度文件。

看起来阿曼(ltoman)的文件稍有不同。它没有latabs列。注意,合并后的 DataFrame 中每个国家的行数与每个国家文件中的行数相匹配:

landtemps = ca.addfiles("data/ltcountry") 
ltpoland.csv has 120 rows.
ltcameroon.csv has 48 rows.
ltmexico.csv has 852 rows.
ltjapan.csv has 1800 rows.
ltindia.csv has 1116 rows.
ltoman.csv has 288 rows.
Different column names for:
ltoman.csv
Index(['latabs'], dtype='object')
ltbrazil.csv has 1008 rows.
Columns Matched: False 
landtemps.country.value_counts() 
country
Japan       1800
India       1116
Brazil      1008
Mexico       852
Oman         288
Poland       120
Cameroon      48
Name: count, dtype: int64 

上述步骤演示了我们如何将一些杂乱的数据重塑工作系统化。我相信你可以想到许多其他可能有用的函数。

它是如何工作的……

你可能注意到,在步骤 2中定义的adjmeans函数中,我们实际上并不会在到达下一个byvar列值之前追加var列值的汇总。这是因为,直到我们到达下一个byvar列值时,无法知道我们是否已经到了某个byvar值的最后一行。这不是问题,因为我们会在重置为0之前将汇总追加到rowlist中。这也意味着,我们需要做一些特别的处理来输出最后一个byvar值的总计,因为没有下一个byvar值。这是通过在循环结束后进行最终的追加操作来实现的。

步骤 5中,我们调用了步骤 2中定义的adjmeans函数。由于我们没有为changeexclude参数设置值,该函数会将所有值包含在聚合中。这将给我们与使用groupby和聚合函数相同的结果。然而,当我们传递一个参数给changeexclude时,我们就能确定从聚合中排除哪些行。在步骤 6中,调用adjmeans时的第五个参数表示我们应该排除新案例值与前一天的值相差超过 5000 个案例的行。

步骤 9中的函数在要合并的数据文件结构相同或几乎相同时效果很好。当列名不同时,我们会打印出警告,正如步骤 10所示。latabs列在阿曼文件中不存在。这意味着在合并后的文件中,阿曼的所有行将缺少latabs这一列。

还有更多…

adjmeans函数在将每个新值加入总数之前会进行相对简单的检查。但我们也可以想象更加复杂的检查。我们甚至可以在adjmeans函数中调用另一个函数,用来决定是否包含某行数据。

另见

我们将在第十章《合并数据框时处理数据问题》中讨论垂直和水平合并 DataFrame。

包含更新 Series 值逻辑的类

我们有时会长时间处理一个特定的数据集,甚至可能是几年。数据可能会定期更新,添加新的月份或年份,或者增加额外的个体,但数据结构可能相对稳定。如果该数据集还包含大量列,我们通过实现类,可能会提高代码的可靠性和可读性。

当我们创建类时,会定义对象的属性和方法。在我的数据清理工作中,我倾向于将类概念化为代表我的分析单元。所以,如果我的分析单元是学生,那么我会创建一个学生类。由该类创建的每个学生实例可能具有出生日期和性别属性,以及课程注册方法。我还可以为校友创建一个子类,继承学生类的方法和属性。

NLS DataFrame 的数据清理可以通过类来很好地实现。该数据集在过去 25 年里相对稳定,无论是变量还是每个变量的允许值。我们在本食谱中探索如何为 NLS 调查响应创建一个 respondent 类。

准备工作

为了运行本食谱中的代码,你需要在当前目录中创建一个名为helperfunctions的子文件夹。我们将把新类的文件(respondent.py)保存在这个子文件夹中。

如何操作…

我们将定义一个 respondent 类,根据 NLS 数据创建几个新的 Series:

  1. 导入pandasossyspprint库。

我们将这段代码存储在一个不同于保存响应者类的文件中。我们将这个文件命名为class_cleaning.py。我们将在这个文件中实例化响应者对象:

import pandas as pd
import os
import sys
import pprint 
  1. 创建一个Respondent类,并将其保存到helperfunctions子文件夹中的respondent.py文件中。

当我们调用我们的类(实例化类对象)时,__init__方法会自动运行。(init前后都有双下划线。)__init__方法的第一个参数是self,正如任何实例方法一样。此类的__init__方法还有一个respdict参数,它期望一个来自 NLS 数据的字典值。在后续步骤中,我们将为 NLS DataFrame 中的每一行数据实例化一个响应者对象。

__init__方法将传递的respdict值赋给self.respdict,以创建一个实例变量,我们可以在其他方法中引用它。最后,我们递增一个计数器respondentcnt。稍后我们可以用它来确认我们创建的respondent实例的数量。我们还导入了mathdatetime模块,因为稍后会需要它们。(请注意,类名通常是大写的。)

import math
import datetime as dt
class Respondent:
...   respondentcnt = 0
...   def __init__(self, respdict):
...     self.respdict = respdict
...     Respondent.respondentcnt+=1 
  1. 添加一个方法,用于计算孩子的数量。

这是一个非常简单的方法,它将与响应者同住的孩子数和不与响应者同住的孩子数相加,得到孩子的总数。它使用self.respdict字典中的childathomechildnotathome键值:

def childnum(self):
...   return self.respdict['childathome'] + self.respdict['childnotathome'] 
  1. 添加一个方法,用于计算调查中 25 年期间的平均工作周数。

使用字典推导式创建一个字典(workdict),其中包含没有缺失值的工作周数键。将workdict中的值相加并除以workdict的长度:

def avgweeksworked(self):
...   workdict = {k: v for k, v in self.respdict.items() \
...     if k.startswith('weeksworked') and not math.isnan(v)}
...   nweeks = len(workdict)
...   if (nweeks>0):
...     avgww = sum(workdict.values())/nweeks
...   else:
...     avgww = 0
...   return avgww 
  1. 添加一个方法,用于计算某个特定日期的年龄。

此方法采用一个日期字符串(bydatestring)作为年龄计算的结束日期。我们使用datetime模块将date字符串转换为datetime对象bydate。我们从self.respdict中减去出生年份值,并从该计算中减去 1,如果出生日期在该年还没有发生。(我们在 NLS 数据中只有出生月和出生年,所以我们选择 15 作为中间点。)

def ageby(self, bydatestring):
...   bydate = dt.datetime.strptime(bydatestring, '%Y%m%d')
...   birthyear = self.respdict['birthyear']
...   birthmonth = self.respdict['birthmonth']
...   age = bydate.year - birthyear
...   if (bydate.month<birthmonth or (bydate.month==birthmonth \
...       and bydate.day<15)):
...     age = age -1
...   return age 
  1. 添加一个方法,如果响应者曾经在 4 年制大学注册过,则创建一个标志。

使用字典推导式检查是否有任何大学注册值为 4 年制大学:

def baenrollment(self):
...   colenrdict = {k: v for k, v in self.respdict.items() \
...     if k.startswith('colenr') and v=="3\. 4-year college"}
...   if (len(colenrdict)>0):
...     return "Y"
...   else:
...     return "N" 
  1. 导入响应者类。

现在我们已经准备好实例化一些Respondent对象了!我们从步骤 1开始的class_cleaning.py文件中进行操作。首先,我们导入响应者类。(此步骤假设respondent.py文件位于helperfunctions子文件夹中。)

sys.path.append(os.getcwd() + "/helperfunctions")
import respondent as rp 
  1. 加载 NLS 数据并创建字典列表。

使用to_dict方法创建字典列表(nls97list)。DataFrame 中的每一行将是一个字典,列名作为键。显示第一个字典的一部分(第一行):

nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
nls97list = nls97.to_dict('records')
nls97.shape 
(8984, 111) 
len(nls97list) 
8984 
pprint.pprint(nls97list[0:1]) 
[{'birthmonth': 9,
  'birthyear': 1981,
  'childathome': nan,
  'childnotathome': nan,
  'colenrfeb00': '3\. 4-year college',
  'colenrfeb01': '3\. 4-year college',
  ...
  'weeksworked21': nan,
  'weeksworked22': nan}] 
  1. 遍历列表,每次创建一个respondent实例。

我们将每个字典传递给rp.Respondent(respdict)响应者类。一旦我们创建了一个响应者对象(resp),我们就可以使用所有实例方法来获取我们需要的值。我们使用这些方法返回的值创建一个新的字典,然后将该字典追加到analysisdict中:

analysislist = []
for respdict in nls97list:
...   resp = rp.Respondent(respdict)
...   newdict = dict(originalid=respdict['originalid'],
...     childnum=resp.childnum(),
...     avgweeksworked=resp.avgweeksworked(),
...     age=resp.ageby('20201015'),
...     baenrollment=resp.baenrollment())
...   analysislist.append(newdict) 
  1. 将字典传递给 pandas DataFrame方法。

首先,检查analysislist中的项数和创建的实例数:

len(analysislist) 
8984 
resp.respondentcnt 
8984 
pprint.pprint(analysislist[0:2]) 
[{'age': 39,
  'avgweeksworked': 48.4375,
  'baenrollment': 'Y',
  'childnum': nan,
  'originalid': 1},
 {'age': 38,
  'avgweeksworked': 49.90909090909091,
  'baenrollment': 'Y',
  'childnum': nan,
  'originalid': 2}] 
analysis = pd.DataFrame(analysislist)
analysis.head(2) 
 originalid    childnum   avgweeksworked    age    baenrollment
0            1         NaN               48     39               Y
1            2         NaN               50     38               Y 

这些步骤展示了如何在 Python 中创建一个类,如何向类传递数据,如何创建类的实例,以及如何调用类的方法来更新变量值。

它是如何工作的…

这个配方中的关键工作在第 2 步中完成。它创建了响应者类,并为剩余的步骤做好了准备。我们将每行的值传递给该类的__init__方法。__init__方法将该字典分配给一个实例变量,该变量对所有类的方法都可用(self.respdict = respdict)。

第 3 步第 6 步使用该字典来计算子女数量、每年平均工作周数、年龄和大学入学情况。第 4 步第 6 步展示了字典推导式在我们需要对多个键测试相同值时的有用性。字典推导式选择相关的键(weeksworked##colenroct##colenrfeb##),并允许我们检查这些键的值。当我们的数据以这种方式杂乱无章时,这非常有用,正如调查数据常常表现出来的那样。

第 8 步中,我们使用to_dict方法创建一个字典列表。它包含预期数量的列表项,8,984,与 DataFrame 中的行数相同。我们使用pprint来显示字典在第一个列表项中的样子。该字典具有列名作为键,列值作为值。

我们在第 9 步中遍历列表,创建一个新的响应者对象并传递列表项。我们调用方法来获取我们想要的值,除了originalid,它可以直接从字典中提取。我们用这些值创建一个字典(newdict),并将其追加到列表(analysislist)中。

第 10 步中,我们从在第 9 步创建的列表(analysislist)中创建一个 pandas DataFrame。我们通过将列表传递给 pandas DataFrame 方法来完成这一操作。

还有更多…

我们传递字典给类,而不是数据行,这也是一种选择。我们这么做是因为,相比于通过 itertuplesiterrows 遍历 DataFrame,遍历 NumPy 数组更高效。当我们使用字典而非 DataFrame 行时,我们并没有失去类所需的太多功能。我们依然可以使用诸如 summean 等函数,并计算符合特定条件的值的数量。

在这种响应者类的概念化中,很难避免必须遍历数据。这个响应者类与我们对分析单元——调查响应者——的理解是一致的。这也正是数据呈现给我们的方式。但即使是更高效的 NumPy 数组,逐行遍历数据也是资源密集型的。

然而,我认为,在处理具有许多列且结构在时间上变化不大的数据时,通过构建像这样的类,你获得的好处超过了失去的。最重要的优势在于它符合我们对数据的直观理解,并将我们的工作集中在理解每个响应者的数据上。我还认为,当我们构建类时,通常会比否则情况减少很多遍历数据的次数。

另见

我们在 第九章聚合时修复杂乱数据 中,探讨了如何遍历 DataFrame 行和 NumPy 数组。

这是一篇关于在 Python 中使用类的简要介绍。如果你想深入了解 Python 中的面向对象编程,我推荐 Dusty Phillips 编写的 Python 3 面向对象编程第三版

处理非表格数据结构的类

数据科学家越来越多地接收到非表格数据,通常是 JSON 或 XML 文件。JSON 和 XML 的灵活性使得组织能够在一个文件中捕捉数据项之间复杂的关系。在企业数据系统中存储在两个表中的一对多关系,可以通过 JSON 通过一个父节点来表示一方,多个子节点来表示多方数据,来很好地表示。

当我们接收 JSON 数据时,我们通常会尝试对其进行规范化。事实上,在本书的一些实例中,我们就是这么做的。我们尝试恢复由于 JSON 灵活性而混淆的数据中的一对一和一对多关系。但也有另一种处理这种数据的方式,它有许多优势。

我们可以创建一个类,在适当的分析单元上实例化对象,并使用类的方法来导航一对多关系的多个方面,而不是规范化数据。例如,如果我们获取一个包含学生节点的 JSON 文件,并且每个学生所修课程都有多个子节点,我们通常会通过创建一个学生文件和一个课程文件来规范化数据,学生 ID 作为两个文件的合并列。在本例中,我们将探讨另一种方法:保持数据原样,创建一个学生类,并创建方法对子节点进行计算,例如计算总学分。

让我们通过这个食谱来尝试,使用来自克利夫兰艺术博物馆的数据,其中包含了收藏项目、每个项目的一个或多个媒体引文节点,以及每个项目的一个或多个创作者节点。

准备工作

本示例假设你已经安装了requestspprint库。如果没有安装,可以通过pip进行安装。在终端或 PowerShell(Windows 系统中)输入pip install requestspip install pprint

这里展示了使用克利夫兰艺术博物馆(Cleveland Museum of Art)collections API 时创建的 JSON 文件结构(我已将 JSON 文件缩短以节省空间)。

{
"id": 165157,
"title": "Fulton and Nostrand",
"creation_date": "1958",
"citations": [
  {
   "citation": "Annual Exhibition: Sculpture, Paintings, Watercolors, Drawings, 
   "page_number": "Unpaginated, [8],[12]",
   "url": null
   },
  {
   "citation": "\"Moscow to See Modern U.S. Art,\"<em> New York Times</em> (May 31, 1959).",  
   "page_number": "P. 60",
   "url": null
  }]
"creators": [
      {
     "description": "Jacob Lawrence (American, 1917-2000)",
     "role": "artist",
     "birth_year": "1917",
     "death_year": "2000"
     }
  ]
 } 

数据说明

克利夫兰艺术博物馆提供了一个 API,允许公众访问这些数据:openaccess-api.clevelandart.org/。通过 API 可以访问的内容远远超过本例中使用的引文和创作者数据。

如何实现…

我们创建了一个收藏项目类,用于汇总创作者和媒体引文的数据:

  1. 导入pandasjsonpprintrequests库。

首先,我们创建一个文件,用来实例化收藏项目对象,命名为class_cleaning_json.py

import pandas as pd
import json
import pprint
import requests 
  1. 创建一个Collectionitem类。

我们将每个收藏项目的字典传递给类的__init__方法,该方法在类的实例化时会自动运行。我们将收藏项目字典分配给实例变量。将该类保存为collectionitem.py文件,并放置在helperfunctions文件夹中:

class Collectionitem:
...   collectionitemcnt = 0
...   def __init__(self, colldict):
...     self.colldict = colldict
...     Collectionitem.collectionitemcnt+=1 
  1. 创建一个方法来获取每个收藏项目的第一个创作者的出生年份。

请记住,收藏项目可能有多个创作者。这意味着creators键的值可能是一个或多个列表项,这些项本身是字典。要获取第一个创作者的出生年份,我们需要['creators'][0]['birth_year']。我们还需要考虑出生年份键可能缺失的情况,因此首先要进行检查:

def birthyearcreator1(self):
...   if ("birth_year" in self.colldict['creators'][0]):
...     byear = self.colldict['creators'][0]['birth_year']
...   else:
...     byear = "Unknown"
...   return byear 
  1. 创建一个方法来获取所有创作者的出生年份。

使用列表推导式循环遍历所有creators项。这将返回一个包含出生年份的列表:

def birthyearsall(self):
...   byearlist = [item.get('birth_year') for item in \
...     self.colldict['creators']]
...   return byearlist 
  1. 创建一个方法来统计创作者的数量:

    def ncreators(self):
    ...   return len(self.colldict['creators']) 
    
  2. 创建一个方法来统计媒体引文的数量:

    def ncitations(self):
    ...   return len(self.colldict['citations']) 
    
  3. 导入collectionitem模块。

我们从步骤 1创建的class_cleaning_json.py文件中执行此操作:

sys.path.append(os.getcwd() + "/helperfunctions")
import collectionitem as ci 
  1. 加载艺术博物馆的收藏数据。

这返回的是一个字典列表。我们只提取了带有非裔美国艺术家数据的博物馆收藏子集:

response = requests.get("https://openaccess-api.clevelandart.org/api/artworks/?african_american_artists")
camcollections = json.loads(response.text)
camcollections = camcollections['data'] 
  1. 遍历camcollections列表。

camcollections中的每个项目创建一个集合项实例。将每个项目(即包含集合、创作者和引用键的字典)传递给类。调用我们刚刚创建的方法,并将它们返回的值分配给一个新的字典(newdict)。将该字典附加到一个列表(analysislist)中。(一些值可以直接从字典中提取,如title=colldict['title'],因为我们不需要以任何方式更改其值。)

analysislist = []
for colldict in camcollections:
...   coll = ci.Collectionitem(colldict)
...   newdict = dict(id=colldict['id'],
...     title=colldict['title'],
...     type=colldict['type'],
...     creationdate=colldict['creation_date'],
...     ncreators=coll.ncreators(),
...     ncitations=coll.ncitations(),
...     birthyearsall=coll.birthyearsall(),
...     birthyear=coll.birthyearcreator1())
...   analysislist.append(newdict) 
  1. 使用新的字典列表创建一个分析 DataFrame。

确认我们获得了正确的计数,并打印第一个项目的字典:

len(camcollections) 
1000 
len(analysislist) 
1000 
pprint.pprint(analysislist[0:1]) 
[{'birthyear': '1917',
  'birthyearsall': ['1917'],
  'creationdate': '1958',
  'id': 165157,
  'ncitations': 30,
  'ncreators': 1,
  'title': 'Fulton and Nostrand',
  'type': 'Painting'}] 
analysis = pd.DataFrame(analysislist)
analysis.birthyearsall.value_counts().head() 
birthyearsall
[1951]          283
[1953]          119
[1961, None]    105
[1937]           55
[1922]           41
Name: count, dtype: int64 
analysis.head(2).T 
 0              1
id                          165157         163769
title          Fulton and Nostrand  Go Down Death
type                      Painting       Painting
creationdate                  1958           1934
ncreators                        1              1
ncitations                      30             18
birthyearsall               [1917]         [1899]
birthyear                     1917           1899 

这些步骤展示了我们如何使用类来处理非表格数据。

它是如何工作的……

本食谱展示了如何直接处理 JSON 文件,或任何包含隐式一对多或多对多关系的文件。我们在分析单元(本例中为一个集合项)中创建了一个类,然后创建了方法来汇总每个集合项的多个数据节点。

我们在步骤 3步骤 6中创建的方法非常简洁。当我们第一次查看数据的结构时,它在本食谱的准备工作部分中展示,确实很难不觉得它会非常难以清理。看起来似乎什么都行。但事实证明,它有一个相对可靠的结构。我们可以依赖creatorscitations中的一个或多个子节点。每个creatorscitations节点也有子节点,这些子节点是键值对。这些键不总是存在,所以我们需要先检查它们是否存在,然后再尝试获取它们的值。这就是我们在步骤 3中所做的。

还有更多……

我在第二章《在处理 HTML、JSON 和 Spark 数据时预见数据清理问题》中详细讨论了直接处理 JSON 文件的优势。我认为博物馆的收藏数据是一个很好的例子,说明了为什么如果可能的话,我们可能更愿意坚持使用 JSON 格式。即使数据的形式非常不同,它的结构实际上是有意义的。当我们试图将其规范化时,始终存在一个风险,那就是我们可能会遗漏其结构的某些方面。

用于检查整体数据质量的函数

我们可以通过更明确地说明我们正在评估的内容来加强数据质量检查。我们在数据分析项目的初期,可能已经对变量值的分布、允许值的范围以及缺失值的数量有了一些预期。这些预期可能来自文档、我们对数据所代表的基础现实世界过程的理解,或者我们对统计学的理解。建立一个常规流程,用于明确这些初步假设、测试它们并在项目过程中修订假设是个不错的主意。本节将演示这个过程可能是什么样的。

我们为每个感兴趣的变量设定了数据质量目标。这包括类别变量的允许值和缺失值的阈值,也包括数值的取值范围、缺失值、偏度和峰度阈值,并检查异常值。我们将检查唯一标识符变量是否存在重复和缺失值。我们从这个 CSV 文件中的假设开始,关于 NLS 文件中的变量:

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

图 12.3:对选定 NLS 列的数据检查

图 12.3展示了我们的初步假设。例如,对于maritalstatus,我们假设类别值为离婚|已婚|从未结婚|分居|寡妇,且不超过 20%的值将缺失。对于nightlyhrssleep(一个数值变量),我们假设值将在 3 到 9 之间,不超过 30%的值将缺失,且其偏度和峰度接近正态分布。

我们还指明了我们想要检查异常值。最后一列是一个标志,如果我们只想对某些变量进行数据检查,可以使用它。在这里,我们指出要对maritalstatusoriginalidhighestgradecompletedgpaenglishnightlyhrssleep进行检查。

准备工作

我们将在本节中再次使用 NLS 数据。

如何操作…

我们使用预定义的数据检查目标来分析选定的 NLS 数据变量。

  1. 创建我们需要的函数进行数据检查,并将其保存在helperfunctions子文件夹中,命名为runchecks.py。以下两个函数,checkcatscheckoutliers,将分别用于测试列表中的值和异常值。我们将在接下来的步骤中看到它是如何工作的:

    def checkcats(cat1,cat2):
      missingcats = \
       set(cat1).symmetric_difference(set(cat2))
      return missingcats
    def checkoutliers(values):
      thirdq, firstq = values.\
        quantile(0.75),values.\
        quantile(0.25)
      interquartilerange = 1.5*(thirdq-firstq)
      outlierhigh, outlierlow = \
        interquartilerange+thirdq, \
        firstq-interquartilerange
      return outlierhigh, outlierlow 
    
  2. 然后我们定义一个函数来运行所有的检查,runchecks,它将接受一个 DataFrame(df)、我们的数据目标(dc)、一个数值列列表(numvars)、一个类别列列表(catvars)和一个标识符列列表(idvars):

    def runchecks(df,dc,numvars,catvars,idvars): 
    
  3. runchecks函数中,我们对数据检查中的分类变量列进行循环。我们通过dcvals = dc.loc[col]获取该变量的所有目标值。我们从类别值中创建一个 NumPy 数组compcat,然后将该数组与传入 DataFrame 中该列的所有值进行比较(df[col].dropna().str.strip().unique())。如果一个数组中有而另一个数组中没有某个类别(即valuediff),我们会将其打印到控制台。我们还计算缺失值百分比。如果超出了我们指定的阈值,我们会打印一条消息:

     for col in df[catvars]:
        dcvals = dc.loc[col]
        print("\n\nChecks for categorical variable", col)
        compcat = list(dcvals.categories.split('|'))
        valuediff = checkcats(compcat,df[col].dropna().\
          str.strip().unique())
        if len(valuediff) > 0:
          print("at least one non-matching category:",
            valuediff)
    
        missingper = df[col].isnull().sum()/df.shape[0]
        if missingper > dcvals.missingthreshold:
          print("missing percent beyond threshold of",
          dcvals.missingthreshold, "is", missingper) 
    
  4. 现在让我们看一下检查数值变量的循环。我们从数据检查目标中的范围值创建一个 NumPy 数组,range = np.fromstring(dcvals.range, sep='|')range的第一个元素是范围的下限,第二个元素是上限。然后,我们从 DataFrame 中获取变量的最小值和最大值,并将其与目标文件中指示的范围进行比较。

我们计算缺失值百分比,并在超过数据检查目标文件中设置的阈值时打印出来。

如果showoutliers标志被设置为Y,我们将显示离群值。我们使用之前设置的checkoutliers函数,该函数通过简单的四分位距计算来确定离群值。最后,我们检查偏度和峰度,以便了解该变量与正态分布的差距:

 for col in df[numvars]:
    dcvals = dc.loc[col]
    print("\n\nChecks for numeric variable", col)

    range = np.fromstring(dcvals.range, sep='|')
    min = df[col].min()
    max = df[col].max()
    if min < range[0]:
      print("at least one record below range starting at ",
       range[0], "min value is", min)
    if max > range[1]:
      print("at least one record above range ending at ",
       range[1], "max value is", max)
    missingper = df[col].isnull().sum()/df.shape[0]
    if missingper > dcvals.missingthreshold:
      print("missing percent beyond threshold of",
       dcvals.missingthreshold, "is", missingper)

    if dcvals.showoutliers == "Y":
      outlierhigh, outlierlow = checkoutliers(df[col])
      print("\nvalues less than", outlierlow, "\n",
        df.loc[df[col]<outlierlow,col].\
        agg(["min",'max','count']), end="\n")
      print("\nvalues greater than", outlierhigh,
        "\n", df.loc[df[col]>outlierhigh,col].\
        agg(["min",'max','count']), end="\n")
    skewcol = df[col].skew()
    if abs(skewcol-dcvals.skewtarget)>1.2:
      print("skew substantially different from target of",
        dcvals.skewtarget, "is", skewcol)

    kurtosiscol = df[col].kurtosis()
    if abs(kurtosiscol-dcvals.kurtosistarget)>1.2:
      print("kurtosis substantially different from target of",
        dcvals.kurtosistarget, "is", kurtosiscol) 
  1. 对于目标文件中标识为 id 变量的变量,我们进行一些简单的检查。我们检查该变量是否有重复值,并检查是否有缺失值:

     for col in df[idvars]:
        print("\n\nChecks for id variable", col)
    
        uniquevals = df[col].nunique()
        nrows = df.shape[0]
        if uniquevals != nrows:
          print("not unique identifier", uniquevals,
            "unique values not equal to", nrows, "rows.")
    
        missingvals = df[col].isnull().sum()
        if missingvals > 0:
          print("unique value has", missingvals,
            "missing values") 
    
  2. 现在我们准备开始运行数据检查。我们首先加载 NLS DataFrame 和数据检查目标。

    import pandas as pd
    import numpy as np
    import os
    import sys
    nls97 = pd.read_csv("data/nls97g.csv", low_memory=False)
    dc = pd.read_csv("data/datacheckingtargets.csv")
    dc.set_index('varname', inplace=True) 
    
  3. 我们导入刚刚创建的runchecks模块。

    sys.path.append(os.getcwd() + "/helperfunctions")
    import runchecks as rc 
    
  4. 让我们故意破坏一些 id 变量值来测试代码。我们还修复了highestgradecompleted的逻辑缺失值,将其设置为实际缺失值。

    nls97.originalid.head(7) 
    
    0    1
    1    2
    2    3
    3    4
    4    5
    5    6
    6    7
    Name: originalid, dtype: int64 
    
    nls97.loc[nls97.originalid==2,"originalid"] = 1
    nls97.loc[nls97.originalid.between(3,7), "originalid"] = np.nan
    nls97.originalid.head(7) 
    
    0    1.0
    1    1.0
    2    NaN
    3    NaN
    4    NaN
    5    NaN
    6    NaN
    Name: originalid, dtype: float64 
    
    nls97["highestgradecompleted"] = nls97.highestgradecompleted.replace(95, np.nan) 
    
  5. 我们只选择那些被标记为包含的目标。然后,我们根据数据检查目标文件创建分类变量、数值变量和 id 变量列表:

    dc = dc.loc[dc.include=="Y"]
    numvars = dc.loc[dc.type=="numeric"].index.to_list()
    catvars = dc.loc[dc.type=="categorical"].index.to_list()
    idvars = dc.loc[dc.type=="unique"].index.to_list() 
    
  6. 现在,我们准备开始运行检查。

    rc.runchecks(nls97,dc,numvars,catvars,idvars) 
    

这将产生以下输出:

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

图 12.4:运行检查

我们发现maritalstatus的缺失值比我们设置的 20%的阈值(26%)多。highestgradecompletedgpaoverall的值超出了预期范围。两个变量的峰度较低。nightlyhrssleep有显著低于和高于四分位距的离群值。31 名受访者的nightlyhrssleep为 2 小时或更少。27 名受访者的nightlyhrssleep非常高,达到 12 小时或更多。

这些步骤展示了我们如何利用之前的领域知识和统计学理解,更好地针对数据质量问题进行调查。

它是如何工作的…

我们创建了一个包含数据检查目标的 CSV 文件,并在检查 NLS 数据时使用了该文件。我们通过将 NLS DataFrame 和数据检查目标一起传递给runchecks来完成此操作。runchecks中的代码遍历数据检查文件中的列名,并根据变量类型进行检查。

每个变量的目标由dcvals = dc.loc[col]定义,它抓取目标文件中该行的所有目标值。然后我们可以引用dcvals.missingthreshold来获取缺失值阈值,例如。接着,我们将缺失值的百分比(df[col].isnull().sum()/df.shape[0])与缺失阈值进行比较,如果缺失值百分比大于阈值,就打印一条消息。我们对值的范围、偏斜、异常值等进行相同类型的检查,具体取决于变量的类型。

我们可以在不更改runchecks代码的情况下,向数据检查目标文件添加新变量。我们还可以更改目标值。

还有更多……

有时候,我们需要主动进行数据检查。展示一些样本统计数据和频率分布以大致了解数据,和动用我们领域知识和统计理解来仔细检查数据质量是不同的。一个更有目的的方法可能要求我们偶尔从 Python 开发环境中抽离片刻,反思我们对数据值及其分布的预期。设置初始的数据质量目标,并定期回顾这些目标,可以帮助我们做到这一点。

使用管道进行数据预处理:一个简单的示例

在进行预测分析时,我们通常需要将所有的预处理和特征工程折叠进管道中,包括缩放、编码以及处理异常值和缺失值。我们在第八章编码、转换和缩放特征中讨论了为什么我们可能需要将所有数据准备工作纳入数据管道。那一章的主要观点是,当我们构建解释性模型并且需要避免数据泄漏时,管道至关重要。尤其是当我们使用k-折交叉验证进行模型验证时,这一问题更加复杂,因为在评估过程中,测试和训练的 DataFrame 会发生变化。交叉验证已成为构建预测模型的标准做法。

注意

k-折交叉验证通过对所有的k折(或部分)进行训练,留下一个折用于测试。这会重复k次,每次排除不同的折进行测试。性能度量是基于k折的平均得分。

管道的另一个好处是,它们有助于确保结果的可重复性,因为它们通常旨在将我们的分析从原始数据带到模型评估。

虽然这个配方演示了如何通过管道来进行模型评估,但我们不会在此详细讨论。关于模型评估和使用 scikit-learn 工具的管道,一个很好的资源是我写的书《数据清理与机器学习探索》。

我们从一个相对简单的例子开始,这里有一个包含两个数值特征和一个数值目标的模型。在下一个配方中,我们将处理一个更加复杂的例子。

准备工作

在这个配方中,我们将使用 scikit-learn 的管道工具,并结合其他一些模块来对数据进行编码、缩放,以及填补缺失值。我们将再次使用土地温度数据。

如何操作…

  1. 我们首先加载本配方中将要使用的scikit-learn模块,用于转换我们的数据。我们将使用StandardScaler来标准化特征,使用SimpleImputer填补缺失值,并使用make_pipeline将所有预处理步骤组合在一起。我们还使用train_test_split来创建训练和测试数据框。其他模块将在使用时进行讲解:

    import pandas as pd
    from sklearn.model_selection import train_test_split
    from sklearn.preprocessing import StandardScaler
    from sklearn.linear_model import LinearRegression
    from sklearn.impute import SimpleImputer
    from sklearn.pipeline import make_pipeline
    from sklearn.model_selection import cross_validate
    from sklearn.model_selection import KFold 
    
  2. 我们加载土地温度数据并创建训练和测试数据框。我们将尝试将温度建模为纬度和海拔的函数。考虑到latabselevation变量的范围差异,数据缩放会变得非常重要:

    landtemps = pd.read_csv("data/landtemps2023avgs.csv")
    feature_cols = ['latabs','elevation']
    X_train, X_test, y_train, y_test =  \
      train_test_split(landtemps[feature_cols],\
      landtemps[['avgtemp']], test_size=0.1, random_state=0) 
    

有关train_test_split的介绍,请参见第八章特征编码、转换和缩放

  1. 我们设置了k-折交叉验证。我们指定希望有五个折叠,并且数据需要被打乱:

    kf = KFold(n_splits=5, shuffle=True, random_state=0) 
    
  2. 现在,我们准备好设置管道了。管道将进行标准缩放,当值缺失时填补均值,然后运行线性回归模型。两个特征将以相同的方式处理。

    pipeline = \
      make_pipeline(StandardScaler(),
      SimpleImputer(strategy="mean"),LinearRegression()) 
    
  3. 在构建好管道并实例化k-折交叉验证对象后,我们准备好执行预处理、估算模型并生成评估指标。我们将管道传递给cross_validate函数,并传入我们的训练数据。我们还传递了在步骤 3中创建的Kfold对象。我们得到了一个相当不错的R平方值。

    scores = \
      cross_validate(pipeline, X=X_train, y=y_train.values,
      cv=kf, scoring=['r2','neg_mean_absolute_error'],
      n_jobs=1)
    print("Mean Absolute Error: %.2f, R-squared: %.2f" %
      (scores['test_neg_mean_absolute_error'].mean(),
      scores['test_r2'].mean())) 
    
    Mean Absolute Error: -2.53, R-squared: 0.82 
    

它是如何工作的…

我们使用了 scikit-learn 的make_pipeline来创建一个仅包含三步的管道:应用标准缩放、基于该变量的均值填补缺失值,并拟合一个线性回归模型。管道的一个很有用的地方是它们会自动将一个步骤的转换结果传递给下一个步骤。一旦我们掌握了这项操作,尽管有k-折交叉验证的复杂性,这个过程仍然很简单。

我们可以想象一下,当训练和测试数据框不断变化时(比如k-折交叉验证中那样),如果我们自己编写代码来处理这些数据将会有多么复杂。即使是使用均值进行填补这样简单的操作也是棘手的。每次训练数据发生变化时,我们都需要重新计算训练数据的均值。我们的管道会自动处理这些问题。

那是一个相对简单的例子,只有几个我们可以用相同方式处理的特征。我们也没有检查离群值或对目标变量进行缩放。在下一个例子中,我们将使用管道处理一个更复杂的建模项目。

使用管道进行数据预处理:一个更复杂的例子

如果你曾经构建过数据管道,你就会知道,当你处理多个不同的数据类型时,事情可能会有些混乱。例如,我们可能需要对连续特征的缺失值进行中位数插补,对于类别特征则使用最频繁的值。我们可能还需要转换我们的目标变量。在这个例子中,我们将探讨如何对不同变量应用不同的预处理。

准备开始

在这个例子中,我们将使用相当多的 scikit-learn 模块。虽然一开始可能会有些混乱,但你会很快感激 scikit-learn 提供的几乎可以做任何你需要的工具。如果需要,scikit-learn 还允许我们将自定义的转换器添加到管道中。我将在这个例子中演示如何构建我们自己的转换器。

我们将使用来自 NLS 的工资和就业数据。

如何做…

  1. 我们首先加载在前一个例子中使用的库。然后我们添加 ColumnTransformerTransformedTargetRegressor 类。我们将使用这些类分别转换我们的特征和目标变量。

    import pandas as pd
    from sklearn.model_selection import train_test_split
    from sklearn.preprocessing import StandardScaler
    from sklearn.linear_model import LinearRegression
    from sklearn.impute import SimpleImputer
    from sklearn.pipeline import make_pipeline
    from feature_engine.encoding import OneHotEncoder
    from sklearn.impute import KNNImputer
    from sklearn.model_selection import cross_validate, KFold
    from sklearn.compose import ColumnTransformer
    from sklearn.compose import TransformedTargetRegressor 
    
  2. 列转换器非常灵活。我们甚至可以使用它与我们自己定义的预处理函数。下面的代码块从 helperfunctions 子文件夹中的 preprocfunc 模块导入 OutlierTrans 类。

    import os
    import sys
    sys.path.append(os.getcwd() + "/helperfunctions")
    from preprocfunc import OutlierTrans 
    
  3. OutlierTrans 类通过与四分位距的距离来识别缺失值。这是一种我们在第四章,“识别数据子集中的离群值”中演示过的技术,并且在本章中多次使用。

为了在 scikit-learn 管道中使用,我们的类必须具有 fittransform 方法。我们还需要继承 BaseEstimatorTransformerMixin 类。

在这个类中,几乎所有的操作都发生在 transform 方法中。任何高于第三四分位数或低于第一四分位数超过 1.5 倍四分位距的值都会被标记为缺失,尽管这个阈值可以更改:

class OutlierTrans(BaseEstimator,TransformerMixin):
  def __init__(self,threshold=1.5):
    self.threshold = threshold

  def fit(self,X,y=None):
    return self

  def transform(self,X,y=None):
    Xnew = X.copy()
    for col in Xnew.columns:
      thirdq, firstq = Xnew[col].quantile(0.75),\
        Xnew[col].quantile(0.25)
      inlierrange = self.threshold*(thirdq-firstq)
      outlierhigh, outlierlow = inlierrange+thirdq,\
        firstq-inlierrange
      Xnew.loc[(Xnew[col]>outlierhigh) | \
        (Xnew[col]<outlierlow),col] = np.nan
    return Xnew.values 

我们的 OutlierTrans 类可以像使用 StandardScaler 和其他转换器一样,在我们的管道中使用。我们稍后会这样做。

  1. 现在我们准备加载需要处理的数据。我们将使用 NLS 工资数据。工资收入将作为我们的目标变量,而我们将使用高中 GPA、母亲和父亲的最高学历、父母收入、性别、工作周数以及个人是否完成本科学位作为特征。

我们在这里创建了处理不同方式的特征列表。稍后在我们指示管道对数值型、类别型和二进制特征进行不同操作时,这将非常有用。

nls97wages = pd.read_csv("data/nls97wages.csv" , low_memory=False)
nls97wages.set_index("personid", inplace=True)
num_cols = ['gpascience','gpaenglish','gpamath',
  'gpaoverall','motherhighgrade','fatherhighgrade',
  'parentincome','weeksworked20']
cat_cols = ['gender']
bin_cols = ['completedba']
target = nls97wages[['wageincome20']]
features = nls97wages[num_cols + cat_cols + bin_cols]
X_train, X_test, y_train, y_test =  \
  train_test_split(features,\
  target, test_size=0.2, random_state=0) 
  1. 现在我们可以设置列转换器。我们首先创建用于处理数值数据(standtrans)、分类数据和二元数据的管道。

对于数值数据(连续型数据),我们希望将异常值设为缺失值。我们将值 2 传递给OutlierTransthreshold参数,表示我们希望将超出四分位距 2 倍范围以上或以下的值设置为缺失值。请记住,通常使用 1.5,所以我们这里比较保守。

我们对gender列进行了独热编码,本质上创建了一个虚拟变量。我们丢弃了最后一个类别,以避免虚拟变量陷阱,正如第八章《编码、转换和缩放特征》中讨论的那样。

然后,我们创建一个ColumnTransformer对象,将刚刚创建的三个管道传递给它,并指明每个管道应该应用于哪些特征。

对于数值变量,我们暂时不担心缺失值。我们稍后会处理它们:

standtrans = make_pipeline(OutlierTrans(2),
  StandardScaler())
cattrans = \
  make_pipeline(SimpleImputer(strategy=\
  "most_frequent"),OneHotEncoder(drop_last=True))
bintrans = \
  make_pipeline(SimpleImputer(strategy=\
  "most_frequent"))
coltrans = ColumnTransformer(
  transformers=[
    ("stand", standtrans, num_cols),
    ("cat", cattrans, ['gender']),
    ("bin", bintrans, ['completedba'])
  ]
) 
  1. 现在,我们可以将列转换器添加到一个包含我们想要运行的线性模型的管道中。我们将 KNN 插补添加到管道中,以处理数值数据的缺失值。对于分类变量的缺失值,我们已经处理过了。

我们还需要对目标进行缩放,这不能在我们的管道中完成。我们使用 scikit-learn 的TransformedTargetRegressor来完成这项工作。我们将刚才创建的管道传递给目标回归器的regressor参数。

lr = LinearRegression()
pipe1 = make_pipeline(coltrans,
  KNNImputer(n_neighbors=5), lr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler()) 
  1. 让我们使用这个管道进行k-折交叉验证。我们可以通过目标回归器ttr将我们的管道传递给cross_validate函数。

    kf = KFold(n_splits=10, shuffle=True, random_state=0)
    scores = cross_validate(ttr, X=X_train, y=y_train,
      cv=kf, scoring=('r2', 'neg_mean_absolute_error'),
      n_jobs=1)
    print("Mean Absolute Error: %.2f, R-squared: %.2f" %
      (scores['test_neg_mean_absolute_error'].mean(),
      scores['test_r2'].mean())) 
    
    Mean Absolute Error: -32899.64, R-squared: 0.16 
    

这些得分不是很好,尽管这并不是这次练习的重点。这里的关键是,我们通常希望将大部分预处理工作纳入管道中进行处理。这是避免数据泄露的最佳方法。列转换器是一个极其灵活的工具,允许我们对不同的特征应用不同的转换。

它是如何工作的…

我们创建了几个不同的管道来预处理数据,在拟合模型之前,一个用于数值数据,一个用于分类数据,一个用于二元数据。列转换器通过允许我们将不同的管道应用于不同的列来帮助我们。我们在步骤 5中设置了列转换器。

我们在步骤 6中创建了另一个管道。这个管道实际上从列转换器开始。然后,列转换器预处理后的数据集将传递给 KNN 插补器,以处理数值列中的缺失值,接着传递给线性回归模型。

值得注意的是,我们可以向 scikit-learn 管道中添加转换操作,即使是我们自己设计的转换操作,因为它们继承了BaseEstimatorTransformerMixin类,正如我们在步骤 3中看到的那样。

还有更多…

有一件关于管道的事非常酷而且实用,而在这个示例中并未展示。如果你曾经需要根据经过缩放或转化的变量生成预测,你可能会记得那是多么麻烦。好消息是,管道帮我们处理了这个问题,生成了适当单位的预测结果。

另见

这只是管道技术的一小部分内容。如果你想深入了解,请参阅我写的书《使用机器学习进行数据清理和探索》。

摘要

本章包含了不少内容,介绍了几种自动化数据清理的方式。我们创建了用于显示数据结构和生成描述性统计的函数。我们还创建了用于重构和聚合数据的函数。此外,我们还开发了用于处理大量变量的数据清理的 Python 类,这些变量需要非常不同的处理方式。我们还展示了如何利用 Python 类简化直接操作 JSON 文件的过程。我们还探讨了通过将数据与预定义目标进行对比,使数据清理更有针对性。最后,我们探讨了如何通过管道自动化数据清理。

留下评论!

喜欢这本书吗?帮助像你一样的读者,通过在亚马逊上留下评论。扫描下方二维码,获得你选择的免费电子书。

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

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

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推动职业生涯发展。欲了解更多信息,请访问我们的网站。

为什么要订阅?

  • 用来自 4,000 多位行业专家的实用电子书和视频,减少学习时间,增加编程时间

  • 用专门为你打造的技能规划提升你的学习

  • 每月获得一本免费电子书或视频

  • 完全可搜索,便于获取关键信息

  • 复制、粘贴、打印和书签内容

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费电子报,并获得 Packt 书籍和电子书的独家折扣和优惠。

你可能喜欢的其他书籍

如果你喜欢这本书,你可能会对 Packt 出版的以下其他书籍感兴趣:

https://www.packtpub.com/product/azure-data-factory-cookbook-second-edition/9781803246598

Azure 数据工厂实用指南(第二版)

Dmitry Foshin

Dimtry Anoshin

Tonya Chernyshova

Xenia Ireton

ISBN: 978-1-80324-659-8

  • 在 ADF 中创建编排和转换作业

  • 使用 Azure Synapse 开发、执行和监控数据流

  • 使用 Databricks 和 Delta 表创建大数据管道

  • 使用 Spark 池在 Azure Data Lake 中处理大数据

  • 将本地 SSIS 作业迁移到 ADF

  • 将 ADF 与常用的 Azure 服务(如 Azure ML、Azure Logic Apps 和 Azure Functions)集成

  • 在 HDInsight 和 Azure Databricks 中运行大数据计算任务

  • 使用 ADF 的内置连接器,将数据从 AWS S3 和 Google Cloud Storage 复制到 Azure Storage

https://www.packtpub.com/product/data-engineering-with-aws-second-edition/9781804614426

AWS 数据工程 - 第二版

Gareth Eagar

ISBN: 978-1-80461-442-6

  • 无缝地摄取流数据,使用 Amazon Kinesis Data Firehose

  • 使用 AWS Glue Studio 优化、反规范化并连接数据集

  • 使用 Amazon S3 事件触发 Lambda 进程来转换文件

  • 将数据加载到 Redshift 数据仓库中,并轻松运行查询

  • 使用 Amazon QuickSight 可视化和探索数据

  • 使用 Amazon Comprehend 从数据集中提取情感数据

  • 使用 Apache Iceberg 与 Amazon Athena 构建事务性数据湖

  • 了解如何在 AWS 上实现数据网格方法

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天就申请。我们与成千上万的开发者和技术专业人士合作,帮助他们与全球技术社区分享他们的见解。你可以提交一般申请,申请我们正在招聘作者的特定热门话题,或提交你自己的想法。

分享你的想法

现在你已经完成了Python 数据清理食谱,第二版,我们很想听听你的想法!如果你是在亚马逊购买的这本书,请点击这里直接前往亚马逊书评页面,分享你的反馈或在购买网站上留下评论。

你的评论对我们和技术社区非常重要,将帮助我们确保提供卓越的内容质量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值