Python-数据科学手册第二版-四-

69 阅读53分钟

Python 数据科学手册第二版(四)

原文:zh.annas-archive.org/md5/051facaf2908ae8198253e3a14b09ec1

译者:飞龙

协议:CC BY-NC-SA 4.0

第二十章:聚合和分组

许多数据分析任务的基本组成部分是高效的汇总:计算summeanmedianminmax等聚合,其中单个数字总结了可能有很多数据集的各个方面。在本章中,我们将探索 Pandas 中的聚合,从类似于我们在 NumPy 数组上看到的简单操作到基于groupby概念的更复杂的操作。

为了方便起见,我们将使用与前几章中相同的display魔术函数:

In [1]: import numpy as np
        import pandas as pd

        class display(object):
            """Display HTML representation of multiple objects"""
            template = """<div style="float: left; padding: 10px;">
 <p style='font-family:"Courier New", Courier, monospace'>{0}{1}
 """
            def __init__(self, *args):
                self.args = args

            def _repr_html_(self):
                return '\n'.join(self.template.format(a, eval(a)._repr_html_())
                                 for a in self.args)

            def __repr__(self):
                return '\n\n'.join(a + '\n' + repr(eval(a))
                                   for a in self.args)

行星数据

在这里,我们将使用通过 Seaborn package(参见第三十六章)提供的 Planets 数据集。它提供了天文学家在其他恒星周围发现的行星的信息(被称为太阳系外行星外行星)。可以通过简单的 Seaborn 命令下载:

In [2]: import seaborn as sns
        planets = sns.load_dataset('planets')
        planets.shape
Out[2]: (1035, 6)
In [3]: planets.head()
Out[3]:             method  number  orbital_period   mass  distance  year
        0  Radial Velocity       1         269.300   7.10     77.40  2006
        1  Radial Velocity       1         874.774   2.21     56.95  2008
        2  Radial Velocity       1         763.000   2.60     19.84  2011
        3  Radial Velocity       1         326.030  19.40    110.62  2007
        4  Radial Velocity       1         516.220  10.50    119.47  2009

这些方法详细介绍了截止到 2014 年发现的一千多个太阳系外行星的情况。

Pandas 中的简单聚合

在第七章中,我们探讨了 NumPy 数组可用的一些数据聚合。对于 Pandas 的 Series,聚合返回一个单一值:

In [4]: rng = np.random.RandomState(42)
        ser = pd.Series(rng.rand(5))
        ser
Out[4]: 0    0.374540
        1    0.950714
        2    0.731994
        3    0.598658
        4    0.156019
        dtype: float64
In [5]: ser.sum()
Out[5]: 2.811925491708157
In [6]: ser.mean()
Out[6]: 0.5623850983416314

对于DataFrame,默认情况下,聚合返回每列的结果:

In [7]: df = pd.DataFrame({'A': rng.rand(5),
                           'B': rng.rand(5)})
        df
Out[7]:           A         B
        0  0.155995  0.020584
        1  0.058084  0.969910
        2  0.866176  0.832443
        3  0.601115  0.212339
        4  0.708073  0.181825
In [8]: df.mean()
Out[8]: A    0.477888
        B    0.443420
        dtype: float64

通过指定axis参数,您可以在每行内进行聚合:

In [9]: df.mean(axis='columns')
Out[9]: 0    0.088290
        1    0.513997
        2    0.849309
        3    0.406727
        4    0.444949
        dtype: float64

Pandas 的 Series 和 DataFrame 对象包含了第七章中提到的所有常见聚合;此外,还有一个方便的方法describe,它为每列计算了几个常见聚合并返回结果。让我们在 Planets 数据上使用它,目前删除具有缺失值的行:

In [10]: planets.dropna().describe()
Out[10]:           number  orbital_period        mass    distance         year
         count  498.00000      498.000000  498.000000  498.000000   498.000000
         mean     1.73494      835.778671    2.509320   52.068213  2007.377510
         std      1.17572     1469.128259    3.636274   46.596041     4.167284
         min      1.00000        1.328300    0.003600    1.350000  1989.000000
         25%      1.00000       38.272250    0.212500   24.497500  2005.000000
         50%      1.00000      357.000000    1.245000   39.940000  2009.000000
         75%      2.00000      999.600000    2.867500   59.332500  2011.000000
         max      6.00000    17337.500000   25.000000  354.000000  2014.000000

这种方法帮助我们了解数据集的整体属性。例如,在year列中,我们可以看到尽管有外行星发现的年份可以追溯到 1989 年,但数据集中一半以上的行星直到 2010 年或之后才被发现。这在很大程度上要归功于开普勒任务,其目标是使用专门设计的空间望远镜在其他恒星周围寻找凌日行星。

表 20-1 总结了一些其他内置的 Pandas 聚合。

表 20-1。Pandas 聚合方法列表

聚合返回
count项目总数
firstlast第一个和最后一个项目
meanmedian平均值和中位数
minmax最小和最大
stdvar标准差和方差
mad平均绝对偏差
prod所有项目的乘积
sum所有项目的和

这些都是DataFrameSeries对象的方法。

然而,要深入了解数据,简单的聚合通常是不够的。数据汇总的下一级是groupby操作,它允许您快速高效地在数据子集上计算聚合。

groupby:分割、应用、组合

简单的聚合可以让你了解数据集的特征,但通常我们更希望在某些标签或索引上进行条件聚合:这在所谓的groupby操作中实现。这个名字“group by”来自 SQL 数据库语言中的一个命令,但也许更具启发性的是,我们可以根据 Rstats 名人哈德利·维克姆首次提出的术语来思考它:分割、应用、组合

分割、应用、组合

这个分割-应用-组合操作的典型示例,其中“应用”是一个求和聚合,如图 20-1 所示。

图 20-1 展示了groupby操作的完成情况:

  • 分割步骤涉及根据指定键的值拆分和分组DataFrame

  • 应用步骤涉及在各个组内计算某个函数,通常是一个聚合、转换或筛选。

  • 合并步骤将这些操作的结果合并到输出数组中。

03.08 split apply combine

图 20-1. groupby操作的视觉表示^(1)

虽然这当然可以通过一些组合使用先前介绍的掩码、聚合和合并命令来手动完成,但重要的认识是中间的分割不需要显式实例化。相反,groupby可以(通常)在数据的单次遍历中执行此操作,沿途更新每个组的总和、平均值、计数、最小值或其他聚合。groupby的威力在于它抽象出了这些步骤:用户不需要考虑计算在幕后是如何进行的,而是可以将操作作为一个整体来思考。

作为一个具体的例子,让我们看看如何使用 Pandas 来计算下表中所示的计算。我们将从创建输入DataFrame开始:

In [11]: df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
                            'data': range(6)}, columns=['key', 'data'])
         df
Out[11]:  key  data
         0   A     0
         1   B     1
         2   C     2
         3   A     3
         4   B     4
         5   C     5

最基本的分割-应用-组合操作可以使用DataFramegroupby方法来计算,传递所需键列的名称:

In [12]: df.groupby('key')
Out[12]: <pandas.core.groupby.generic.DataFrameGroupBy object at 0x11d241e20>

注意返回的是一个DataFrameGroupBy对象,而不是一组DataFrame对象。这个对象是魔术所在:你可以将它看作是DataFrame的一个特殊视图,它准备好深入研究组,但在应用聚合之前不进行任何实际计算。这种“延迟评估”方法意味着常见的聚合可以以几乎对用户透明的方式高效实现。

要生成结果,我们可以对这个DataFrameGroupBy对象应用聚合函数,它将执行适当的应用/合并步骤以生成所需的结果:

In [13]: df.groupby('key').sum()
Out[13]:      data
         key
         A       3
         B       5
         C       7

这里sum方法只是一种可能性;你可以应用大多数 Pandas 或 NumPy 聚合函数,以及大多数DataFrame操作,正如你将在以下讨论中看到的那样。

GroupBy 对象

GroupBy对象是一个灵活的抽象:在许多情况下,它可以简单地被视为DataFrame的集合,尽管在内部执行更复杂的操作。让我们看一些使用行星数据的示例。

GroupBy提供的可能是最重要的操作是aggregatefiltertransformapply。我们将在下一节更详细地讨论每一个,但在此之前,让我们看一些可以与基本GroupBy操作一起使用的其他功能。

列索引

GroupBy对象支持与DataFrame相同的列索引,返回一个修改过的GroupBy对象。例如:

In [14]: planets.groupby('method')
Out[14]: <pandas.core.groupby.generic.DataFrameGroupBy object at 0x11d1bc820>
In [15]: planets.groupby('method')['orbital_period']
Out[15]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x11d1bcd60>

在这里,我们通过引用其列名从原始的DataFrame组中选择了一个特定的Series组。与GroupBy对象一样,直到我们对对象调用某些聚合函数之前,都不会进行任何计算:

In [16]: planets.groupby('method')['orbital_period'].median()
Out[16]: method
         Astrometry                         631.180000
         Eclipse Timing Variations         4343.500000
         Imaging                          27500.000000
         Microlensing                      3300.000000
         Orbital Brightness Modulation        0.342887
         Pulsar Timing                       66.541900
         Pulsation Timing Variations       1170.000000
         Radial Velocity                    360.200000
         Transit                              5.714932
         Transit Timing Variations           57.011000
         Name: orbital_period, dtype: float64

这给出了每种方法对轨道周期(以天计)的一般尺度的概念。

对组进行迭代

GroupBy对象支持直接在组上进行迭代,返回每个组作为SeriesDataFrame

In [17]: for (method, group) in planets.groupby('method'):
             print("{0:30s} shape={1}".format(method, group.shape))
Out[17]: Astrometry                     shape=(2, 6)
         Eclipse Timing Variations      shape=(9, 6)
         Imaging                        shape=(38, 6)
         Microlensing                   shape=(23, 6)
         Orbital Brightness Modulation  shape=(3, 6)
         Pulsar Timing                  shape=(5, 6)
         Pulsation Timing Variations    shape=(1, 6)
         Radial Velocity                shape=(553, 6)
         Transit                        shape=(397, 6)
         Transit Timing Variations      shape=(4, 6)

这对于手动检查组以进行调试非常有用,但通常使用内置的apply功能会更快,我们稍后将讨论此功能。

分派方法

通过一些 Python 类魔术,任何未明确由GroupBy对象实现的方法都将被传递并在组上调用,无论它们是DataFrame还是Series对象。例如,使用describe方法等效于在表示每个组的DataFrame上调用describe

In [18]: planets.groupby('method')['year'].describe().unstack()
Out[18]:        method
         count  Astrometry                          2.0
                Eclipse Timing Variations           9.0
                Imaging                            38.0
                Microlensing                       23.0
                Orbital Brightness Modulation       3.0
                                                  ...
         max    Pulsar Timing                    2011.0
                Pulsation Timing Variations      2007.0
                Radial Velocity                  2014.0
                Transit                          2014.0
                Transit Timing Variations        2014.0
         Length: 80, dtype: float64

查看这张表有助于我们更好地理解数据:例如,直到 2014 年,绝大多数行星是通过径向速度和凌日法发现的,尽管后者方法近年来变得更为普遍。最新的方法似乎是凌时差变化和轨道亮度调制,直到 2011 年才用于发现新行星。

注意,这些分派方法是应用在每个单独的组上的,并且结果然后在GroupBy内组合并返回。同样地,任何有效的DataFrame/Series方法都可以在对应的GroupBy对象上类似地调用。

聚合(Aggregate)、筛选(Filter)、转换(Transform)、应用(Apply)

前面的讨论侧重于合并操作的聚合,但还有更多可用选项。特别是,GroupBy对象具有aggregatefiltertransformapply方法,可以在组合并分组数据之前有效地实现多种有用的操作。

出于以下各小节的目的,我们将使用这个DataFrame

In [19]: rng = np.random.RandomState(0)
         df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
                            'data1': range(6),
                            'data2': rng.randint(0, 10, 6)},
                            columns = ['key', 'data1', 'data2'])
         df
Out[19]:   key  data1  data2
         0   A      0      5
         1   B      1      0
         2   C      2      3
         3   A      3      3
         4   B      4      7
         5   C      5      9

聚合

现在你已经熟悉了使用summedian等方法的GroupBy聚合,但aggregate方法允许更加灵活。它可以接受字符串、函数或函数列表,并一次计算所有聚合。以下是一个快速示例,结合了所有这些内容:

In [20]: df.groupby('key').aggregate(['min', np.median, max])
Out[20]:     data1            data2
               min median max   min median max
         key
         A       0    1.5   3     3    4.0   5
         B       1    2.5   4     0    3.5   7
         C       2    3.5   5     3    6.0   9

另一种常见的模式是传递一个将列名映射到要应用于该列的操作的字典:

In [21]: df.groupby('key').aggregate({'data1': 'min',
                                      'data2': 'max'})
Out[21]:      data1  data2
         key
         A        0      5
         B        1      7
         C        2      9

过滤

过滤操作允许您根据组属性丢弃数据。例如,我们可能希望保留所有标准偏差大于某个临界值的组:

In [22]: def filter_func(x):
             return x['data2'].std() > 4

         display('df', "df.groupby('key').std()",
                 "df.groupby('key').filter(filter_func)")
Out[22]: df                         df.groupby('key').std()
           key  data1  data2           data1     data2
         0   A      0      5        key
         1   B      1      0        A    2.12132  1.414214
         2   C      2      3        B    2.12132  4.949747
         3   A      3      3        C    2.12132  4.242641
         4   B      4      7
         5   C      5      9

         df.groupby('key').filter(filter_func)
           key  data1  data2
         1   B      1      0
         2   C      2      3
         4   B      4      7
         5   C      5      9

过滤函数应返回一个布尔值,指定组是否通过过滤。在这里,因为 A 组的标准偏差不大于 4,所以它从结果中被删除。

变换

虽然聚合必须返回数据的减少版本,但变换可以返回一些经过转换的完整数据以重新组合。对于这种转换,输出与输入的形状相同。一个常见的例子是通过减去组内均值来使数据居中:

In [23]: def center(x):
             return x - x.mean()
         df.groupby('key').transform(center)
Out[23]:    data1  data2
         0   -1.5    1.0
         1   -1.5   -3.5
         2   -1.5   -3.0
         3    1.5   -1.0
         4    1.5    3.5
         5    1.5    3.0

应用方法

apply方法允许您将任意函数应用于组结果。该函数应接受一个DataFrame,并返回一个 Pandas 对象(例如DataFrameSeries)或一个标量;合并步骤的行为将根据返回的输出类型进行调整。

例如,这里是一个通过第一列的总和来归一化的apply操作:

In [24]: def norm_by_data2(x):
             # x is a DataFrame of group values
             x['data1'] /= x['data2'].sum()
             return x

         df.groupby('key').apply(norm_by_data2)
Out[24]:   key     data1  data2
         0   A  0.000000      5
         1   B  0.142857      0
         2   C  0.166667      3
         3   A  0.375000      3
         4   B  0.571429      7
         5   C  0.416667      9

GroupBy中的apply非常灵活:唯一的标准是函数接受DataFrame并返回 Pandas 对象或标量。在中间您可以做任何事情!

指定分割密钥

在之前展示的简单示例中,我们根据单个列名拆分了DataFrame。这只是定义组的许多选项之一,我们将在这里介绍一些其他的组规范选项。

提供分组键的列表、数组、系列或索引

密钥可以是与DataFrame长度匹配的任何系列或列表。例如:

In [25]: L = [0, 1, 0, 1, 2, 0]
         df.groupby(L).sum()
Out[25]:    data1  data2
         0      7     17
         1      4      3
         2      4      7

当然,这意味着还有另一种更冗长的方法来实现df.groupby('key')

In [26]: df.groupby(df['key']).sum()
Out[26]:      data1  data2
         key
         A        3      8
         B        5      7
         C        7     12

映射索引到组的字典或系列

另一种方法是提供一个将索引值映射到组键的字典:

In [27]: df2 = df.set_index('key')
         mapping = {'A': 'vowel', 'B': 'consonant', 'C': 'consonant'}
         display('df2', 'df2.groupby(mapping).sum()')
Out[27]: df2                    df2.groupby(mapping).sum()
             data1  data2                  data1  data2
         key                    key
         A        0      5      consonant     12     19
         B        1      0      vowel          3      8
         C        2      3
         A        3      3
         B        4      7
         C        5      9

任何 Python 函数

类似于映射,您可以传递任何 Python 函数,该函数将输入索引值并输出组:

In [28]: df2.groupby(str.lower).mean()
Out[28]:      data1  data2
         key
         a      1.5    4.0
         b      2.5    3.5
         c      3.5    6.0

有效密钥列表

此外,可以将任何前述密钥选择组合以在多索引上进行分组:

In [29]: df2.groupby([str.lower, mapping]).mean()
Out[29]:                data1  data2
         key key
         a   vowel        1.5    4.0
         b   consonant    2.5    3.5
         c   consonant    3.5    6.0

分组示例

举例来说,在几行 Python 代码中,我们可以将所有这些组合在一起,并按方法和十年计数发现的行星:

In [30]: decade = 10 * (planets['year'] // 10)
         decade = decade.astype(str) + 's'
         decade.name = 'decade'
         planets.groupby(['method', decade])['number'].sum().unstack().fillna(0)
Out[30]: decade                         1980s  1990s  2000s  2010s
         method
         Astrometry                       0.0    0.0    0.0    2.0
         Eclipse Timing Variations        0.0    0.0    5.0   10.0
         Imaging                          0.0    0.0   29.0   21.0
         Microlensing                     0.0    0.0   12.0   15.0
         Orbital Brightness Modulation    0.0    0.0    0.0    5.0
         Pulsar Timing                    0.0    9.0    1.0    1.0
         Pulsation Timing Variations      0.0    0.0    1.0    0.0
         Radial Velocity                  1.0   52.0  475.0  424.0
         Transit                          0.0    0.0   64.0  712.0
         Transit Timing Variations        0.0    0.0    0.0    9.0

这显示了在查看现实数据集时结合许多我们到目前为止讨论过的操作的力量:我们很快就能粗略地了解在首次发现后的几年内如何检测到系外行星。

我建议深入研究这几行代码,并评估每个步骤,以确保您完全理解它们对结果的影响。这当然是一个有些复杂的例子,但理解这些部分将使您能够类似地探索自己的数据。

^(1) 生成此图的代码可以在在线附录中找到。

第二十一章:透视表

我们已经看到groupby抽象可以帮助我们探索数据集中的关系。透视表是一种类似的操作,在电子表格和其他操作表格数据的程序中经常见到。透视表以简单的列数据作为输入,并将条目分组成二维表,提供数据的多维总结。透视表与groupby之间的区别有时会引起混淆;我认为透视表基本上是groupby聚合的多维版本。也就是说,你进行分割-应用-组合,但分割和组合发生在二维网格上,而不是一维索引上。

激励透视表

在这一部分的示例中,我们将使用泰坦尼克号乘客的数据库,该数据库可通过 Seaborn 库获取(见第三十六章):

In [1]: import numpy as np
        import pandas as pd
        import seaborn as sns
        titanic = sns.load_dataset('titanic')
In [2]: titanic.head()
Out[2]:    survived  pclass     sex   age  sibsp  parch     fare embarked  class  \
        0         0       3 male  22.0      1      0   7.2500        S  Third
        1         1       1  female  38.0      1      0  71.2833        C  First
        2         1       3  female  26.0      0      0   7.9250        S  Third
        3         1       1  female  35.0      1      0  53.1000        S  First
        4         0       3    male  35.0      0      0   8.0500        S  Third
             who  adult_male deck  embark_town alive  alone
        0    man        True  NaN  Southampton    no  False
        1  woman       False    C    Cherbourg   yes  False
        2  woman       False  NaN  Southampton   yes   True
        3  woman       False    C  Southampton   yes  False
        4    man        True  NaN  Southampton    no   True

正如输出所示,这包含了每位乘客的多个数据点,包括性别、年龄、阶级、支付的票价等等。

手动透视表

要开始了解更多关于这些数据的信息,我们可以根据性别、生存状态或两者的某种组合进行分组。如果你已经阅读了前一章节,你可能会想应用一个groupby操作——例如,让我们来看看按性别划分的生存率:

In [3]: titanic.groupby('sex')[['survived']].mean()
Out[3]:         survived
        sex
        female  0.742038
        male    0.188908

这给了我们一些初步的见解:总体而言,船上四分之三的女性幸存下来,而只有五分之一的男性幸存!

这很有用,但我们可能希望再深入一步,查看性别和阶级的生存率。使用groupby的术语,我们可以按以下过程进行操作:首先按阶级和性别进行分组,然后选择生存,应用均值聚合,组合结果组,并最后展开层次索引以显示隐藏的多维特性。在代码中:

In [4]: titanic.groupby(['sex', 'class'])['survived'].aggregate('mean').unstack()
Out[4]: class      First    Second     Third
        sex
        female  0.968085  0.921053  0.500000
        male    0.368852  0.157407  0.135447

这样我们可以更好地了解性别和阶级如何影响生存,但是代码看起来有点凌乱。虽然我们之前讨论的每个步骤在工具的背景下都是合理的,但是这长串代码并不特别易于阅读或使用。这种二维的groupby很常见,因此 Pandas 包含了一个方便的例程pivot_table,它简洁地处理这种多维聚合类型。

透视表语法

下面是使用DataFrame.pivot_table方法进行的等效操作:

In [5]: titanic.pivot_table('survived', index='sex', columns='class', aggfunc='mean')
Out[5]: class      First    Second     Third
        sex
        female  0.968085  0.921053  0.500000
        male    0.368852  0.157407  0.135447

这比手动的groupby方法更易读,并产生相同的结果。正如你可能期待的那样,在 20 世纪初的大西洋横渡邮轮上,生存率偏向于更高阶级和数据中记录为女性的人群。头等舱的女性几乎百分之百幸存下来(嗨,Rose!),而仅约五分之一的三等舱男性幸存下来(抱歉,Jack!)。

多级透视表

就像在groupby中一样,透视表中的分组可以通过多个级别和多种选项来指定。例如,我们可能对年龄作为第三维度感兴趣。我们将使用pd.cut函数对年龄进行分箱:

In [6]: age = pd.cut(titanic['age'], [0, 18, 80])
        titanic.pivot_table('survived', ['sex', age], 'class')
Out[6]: class               First    Second     Third
        sex    age
        female (0, 18]   0.909091  1.000000  0.511628
               (18, 80]  0.972973  0.900000  0.423729
        male   (0, 18]   0.800000  0.600000  0.215686
               (18, 80]  0.375000  0.071429  0.133663

在处理列时我们可以应用相同的策略;让我们添加有关支付费用的信息,使用pd.qcut自动计算分位数:

In [7]: fare = pd.qcut(titanic['fare'], 2)
        titanic.pivot_table('survived', ['sex', age], [fare, 'class'])
Out[7]: fare            (-0.001, 14.454]                     (14.454, 512.329]  \
        class                      First    Second     Third             First
        sex    age
        female (0, 18]               NaN  1.000000  0.714286          0.909091
               (18, 80]              NaN  0.880000  0.444444          0.972973
        male   (0, 18]               NaN  0.000000  0.260870          0.800000
               (18, 80]              0.0  0.098039  0.125000          0.391304

        fare
        class              Second     Third
        sex    age
        female (0, 18]   1.000000  0.318182
               (18, 80]  0.914286  0.391304
        male   (0, 18]   0.818182  0.178571
               (18, 80]  0.030303  0.192308

结果是具有分层索引的四维聚合(见第 17 章),显示了展示值之间关系的网格。

额外的透视表选项

DataFrame.pivot_table方法的完整调用签名如下:

# call signature as of Pandas 1.3.5
DataFrame.pivot_table(data, values=None, index=None, columns=None,
                      aggfunc='mean', fill_value=None, margins=False,
                      dropna=True, margins_name='All', observed=False,
                      sort=True)

我们已经看到了前三个参数的示例;这里我们将看一些其余的选项。两个选项fill_valuedropna与缺失数据有关,非常直观;我不会在这里展示它们的示例。

aggfunc关键字控制应用的聚合类型,默认为均值。与groupby一样,聚合规范可以是表示几种常见选择之一的字符串('sum''mean''count''min''max'等)或实现聚合的函数(例如,np.sum()min()sum()等)。此外,它可以被指定为将列映射到任何所需选项之一的字典:

In [8]: titanic.pivot_table(index='sex', columns='class',
                            aggfunc={'survived':sum, 'fare':'mean'})
Out[8]:               fare                       survived
        class        First     Second      Third    First Second Third
        sex
        female  106.125798  21.970121  16.118810       91     70    72
        male     67.226127  19.741782  12.661633       45     17    47

这里还要注意,我们省略了values关键字;在为aggfunc指定映射时,这将自动确定。

有时计算每个分组的总计是有用的。这可以通过margins关键字来完成:

In [9]: titanic.pivot_table('survived', index='sex', columns='class', margins=True)
Out[9]: class      First    Second     Third       All
        sex
        female  0.968085  0.921053  0.500000  0.742038
        male    0.368852  0.157407  0.135447  0.188908
        All     0.629630  0.472826  0.242363  0.383838

在这里,这自动为我们提供了关于性别不可知的无关生存率、类别不可知的生存率以及 38%的总体生存率信息。边缘标签可以通过margins_name关键字指定,默认为"All"

示例:出生率数据

另一个例子,让我们看看美国出生数据的免费可用数据(提供者为美国疾病控制中心(CDC)):data on births in the US,这些数据已经被安德鲁·格尔曼及其团队进行了相当深入的分析;例如,参见使用高斯过程进行信号处理的博客文章:^(1)

In [10]: # shell command to download the data:
         # !cd data && curl -O \
         # https://raw.githubusercontent.com/jakevdp/data-CDCbirths/master/births.csv
In [11]: births = pd.read_csv('data/births.csv')

看一下数据,我们可以看到它相对简单——它包含按日期和性别分组的出生数量:

In [12]: births.head()
Out[12]:    year  month  day gender  births
         0  1969      1  1.0      F    4046
         1  1969      1  1.0      M    4440
         2  1969      1  2.0      F    4454
         3  1969      1  2.0      M    4548
         4  1969      1  3.0      F    4548

我们可以通过使用透视表来开始更深入地理解这些数据。让我们添加一个decade列,然后看看男性和女性出生人数如何随着十年变化:

In [13]: births['decade'] = 10 * (births['year'] // 10)
         births.pivot_table('births', index='decade', columns='gender',
                            aggfunc='sum')
Out[13]: gender         F         M
         decade
         1960     1753634   1846572
         1970    16263075  17121550
         1980    18310351  19243452
         1990    19479454  20420553
         2000    18229309  19106428

我们看到每个十年男性出生人数超过女性。为了更清楚地看到这一趋势,我们可以使用 Pandas 中的内置绘图工具来可视化每年的总出生人数,如图 21-1 所示(参见第 IV 部分关于使用 Matplotlib 绘图的讨论):

In [14]: %matplotlib inline
         import matplotlib.pyplot as plt
         plt.style.use('seaborn-whitegrid')
         births.pivot_table(
             'births', index='year', columns='gender', aggfunc='sum').plot()
         plt.ylabel('total births per year');

output 33 0

图 21-1. 美国各年度出生总数及性别分布^(2)

通过一个简单的数据透视表和plot方法,我们可以立即看到性别出生的年度趋势。通过肉眼观察,过去 50 年男性出生人数大约比女性出生人数多约 5%。

虽然这与数据透视表不一定有关,但我们可以利用到目前为止介绍的 Pandas 工具从这个数据集中提取一些更有趣的特征。我们必须首先稍微清理数据,删除由于输入错误的日期(例如 6 月 31 日)或缺失值(例如 6 月 99 日)引起的异常值。一种一次性删除所有这些异常值的简单方法是通过强大的 sigma 剪切操作:

In [15]: quartiles = np.percentile(births['births'], [25, 50, 75])
         mu = quartiles[1]
         sig = 0.74 * (quartiles[2] - quartiles[0])

这最后一行是样本标准差的健壮估计,其中的 0.74 来自高斯分布的四分位距(您可以在我与Željko Ivezić、Andrew J. Connolly 和 Alexander Gray 共同撰写的书籍Statistics, Data Mining, and Machine Learning in Astronomy(普林斯顿大学出版社)中了解更多有关 sigma 剪切操作的信息)。

使用query方法(在第二十四章中进一步讨论)来过滤出超出这些值范围的出生行:

In [16]: births = births.query('(births > @mu - 5 * @sig) &
                                (births < @mu + 5 * @sig)')

接下来,我们将day列设置为整数;此前它是一个字符串列,因为数据集中的一些列包含值'null'

In [17]: # set 'day' column to integer; it originally was a string due to nulls
         births['day'] = births['day'].astype(int)

最后,我们可以结合日、月和年创建一个日期索引(见第二十三章)。这使得我们可以快速计算每一行对应的工作日:

In [18]: # create a datetime index from the year, month, day
         births.index = pd.to_datetime(10000 * births.year +
                                       100 * births.month +
                                       births.day, format='%Y%m%d')

         births['dayofweek'] = births.index.dayofweek

利用这一点,我们可以绘制几十年来每周的出生情况(见图 21-2)。

In [19]: import matplotlib.pyplot as plt
         import matplotlib as mpl

         births.pivot_table('births', index='dayofweek',
                             columns='decade', aggfunc='mean').plot()
         plt.gca().set(xticks=range(7),
                       xticklabels=['Mon', 'Tues', 'Wed', 'Thurs',
                                    'Fri', 'Sat', 'Sun'])
         plt.ylabel('mean births by day');

显然,周末的出生率略低于工作日!请注意,1990 年代和 2000 年代缺失,因为从 1989 年开始,CDC 的数据仅包含出生月份。

另一个有趣的视图是按年中日期平均出生数量的图表。让我们首先分别按月份和日期分组数据:

In [20]: births_by_date = births.pivot_table('births',
                                             [births.index.month, births.index.day])
         births_by_date.head()
Out[20]:        births
         1 1  4009.225
           2  4247.400
           3  4500.900
           4  4571.350
           5  4603.625

output 44 0

图 21-2. 每周平均出生量按工作日和十年间隔^(3)

结果是一个月份和日期的多重索引。为了使其可视化,让我们通过将它们与一个虚拟的年份变量关联起来(确保选择一个闰年,以正确处理 2 月 29 日)将这些月份和日期转换为日期:

In [21]: from datetime import datetime
         births_by_date.index = [datetime(2012, month, day)
                                 for (month, day) in births_by_date.index]
         births_by_date.head()
Out[21]:               births
         2012-01-01  4009.225
         2012-01-02  4247.400
         2012-01-03  4500.900
         2012-01-04  4571.350
         2012-01-05  4603.625

焦点放在月份和日期上,我们现在有一个反映每年平均出生数量的时间序列。从这个序列,我们可以使用plot方法绘制数据。正如您可以在图 21-3 中看到的那样,它显示了一些有趣的趋势。

In [22]: # Plot the results
         fig, ax = plt.subplots(figsize=(12, 4))
         births_by_date.plot(ax=ax);

output 50 0

图 21-3. 每日平均出生量按日期^(4)

特别是,这张图的显著特征是美国假日(例如独立日、劳动节、感恩节、圣诞节、元旦)出生率的下降,尽管这可能反映了计划/引发出生的趋势,而不是对自然出生产生深刻的心理效应。关于这一趋势的更多讨论,请参见安德鲁·格尔曼的博客文章。我们将在第三十二章中使用 Matplotlib 的工具对这个图进行注释。

查看这个简短的例子,你可以看到到目前为止我们见过的许多 Python 和 Pandas 工具可以结合使用,从各种数据集中获取洞察力。我们将在后续章节中看到这些数据操作的更复杂应用!

^(1) 本节中使用的 CDC 数据集使用了出生时分配的性别,称之为“性别”,并将数据限制在男性和女性之间。尽管性别是独立于生物学的光谱,但为了一致性和清晰度,在讨论这个数据集时我将使用相同的术语。

^(2) 这个图的全彩版本可以在GitHub找到。

^(3) 这个图的全彩版本可以在GitHub找到。

^(4) 这个图的全尺寸版本可以在GitHub找到。

第二十二章:向量化字符串操作

Python 的一个优点是相对容易处理和操作字符串数据。Pandas 在此基础上构建,并提供了一套全面的向量化字符串操作,这是处理(即:清理)现实世界数据时必不可少的部分。在本章中,我们将逐步介绍一些 Pandas 字符串操作,然后看看如何使用它们部分清理从互联网收集的非常混乱的食谱数据集。

引入 Pandas 字符串操作

在之前的章节中,我们看到工具如 NumPy 和 Pandas 如何将算术操作泛化,以便我们可以轻松快速地在许多数组元素上执行相同的操作。例如:

In [1]: import numpy as np
        x = np.array([2, 3, 5, 7, 11, 13])
        x * 2
Out[1]: array([ 4,  6, 10, 14, 22, 26])

这种操作的向量化简化了操作数组数据的语法:我们不再需要担心数组的大小或形状,而只需关注我们想要进行的操作。对于字符串数组,NumPy 没有提供如此简单的访问方式,因此你只能使用更冗长的循环语法:

In [2]: data = ['peter', 'Paul', 'MARY', 'gUIDO']
        [s.capitalize() for s in data]
Out[2]: ['Peter', 'Paul', 'Mary', 'Guido']

这可能足以处理一些数据,但如果有任何缺失值,它将会出错,因此这种方法需要额外的检查:

In [3]: data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
        [s if s is None else s.capitalize() for s in data]
Out[3]: ['Peter', 'Paul', None, 'Mary', 'Guido']

这种手动方法不仅冗长且不方便,还容易出错。

Pandas 包括功能来同时解决对向量化字符串操作的需求以及通过 Pandas SeriesIndex对象的str属性正确处理缺失数据的需求。因此,例如,如果我们创建一个包含这些数据的 Pandas Series,我们可以直接调用str.capitalize方法,其中内置了缺失值处理:

In [4]: import pandas as pd
        names = pd.Series(data)
        names.str.capitalize()
Out[4]: 0    Peter
        1     Paul
        2     None
        3     Mary
        4    Guido
        dtype: object

Pandas 字符串方法表

如果你对 Python 中的字符串操作有很好的理解,大部分 Pandas 字符串语法都足够直观,可能只需列出可用的方法就足够了。我们先从这里开始,然后深入探讨一些细微之处。本节的示例使用以下Series对象:

In [5]: monte = pd.Series(['Graham Chapman', 'John Cleese', 'Terry Gilliam',
                           'Eric Idle', 'Terry Jones', 'Michael Palin'])

类似于 Python 字符串方法的方法

几乎所有 Python 内置的字符串方法都有与之对应的 Pandas 向量化字符串方法。以下 Pandas str方法与 Python 字符串方法相对应:

lenlowertranslateislowerljust
upperstartswithisupperrjustfind
endswithisnumericcenterrfindisalnum
isdecimalzfillindexisalphasplit
striprindexisdigitrsplitrstrip
capitalizeisspacepartitionlstripswapcase

注意这些具有不同的返回值。一些像lower这样的方法返回一系列字符串:

In [6]: monte.str.lower()
Out[6]: 0    graham chapman
        1       john cleese
        2     terry gilliam
        3         eric idle
        4       terry jones
        5     michael palin
        dtype: object

但有些返回数字:

In [7]: monte.str.len()
Out[7]: 0    14
        1    11
        2    13
        3     9
        4    11
        5    13
        dtype: int64

或者布尔值:

In [8]: monte.str.startswith('T')
Out[8]: 0    False
        1    False
        2     True
        3    False
        4     True
        5    False
        dtype: bool

还有一些方法返回每个元素的列表或其他复合值:

In [9]: monte.str.split()
Out[9]: 0    [Graham, Chapman]
        1       [John, Cleese]
        2     [Terry, Gilliam]
        3         [Eric, Idle]
        4       [Terry, Jones]
        5     [Michael, Palin]
        dtype: object

当我们继续讨论时,我们将看到这种系列列表对象的进一步操作。

使用正则表达式的方法

此外,还有几种方法接受正则表达式(regexps)来检查每个字符串元素的内容,并遵循 Python 内置 re 模块的一些 API 约定(参见 Table 22-1)。

Table 22-1. Pandas 方法与 Python re 模块函数的映射关系

方法描述
match对每个元素调用 re.match,返回布尔值。
extract对每个元素调用 re.match,返回匹配的字符串组。
findall对每个元素调用 re.findall
replace用其他字符串替换模式的出现
contains对每个元素调用 re.search,返回布尔值
count计算模式的出现次数
split等同于 str.split,但接受正则表达式
rsplit等同于 str.rsplit,但接受正则表达式

使用这些方法,我们可以进行各种操作。例如,通过请求每个元素开头的一组连续字符,我们可以从中提取每个元素的名字:

In [10]: monte.str.extract('([A-Za-z]+)', expand=False)
Out[10]: 0     Graham
         1       John
         2      Terry
         3       Eric
         4      Terry
         5    Michael
         dtype: object

或者我们可以做一些更复杂的事情,比如找出所有以辅音开头和结尾的名字,利用正则表达式的开头(^)和结尾($)字符:

In [11]: monte.str.findall(r'^[^AEIOU].*[^aeiou]$')
Out[11]: 0    [Graham Chapman]
         1                  []
         2     [Terry Gilliam]
         3                  []
         4       [Terry Jones]
         5     [Michael Palin]
         dtype: object

能够简洁地应用正则表达式于 SeriesDataFrame 条目之上,为数据的分析和清理开辟了许多可能性。

杂项方法

最后,Table 22-2 列出了使其他便捷操作得以实现的杂项方法。

Table 22-2. 其他 Pandas 字符串方法

方法描述
get对每个元素进行索引
slice对每个元素进行切片
slice_replace用传递的值替换每个元素中的片段
cat连接字符串
repeat重复值
normalize返回字符串的 Unicode 形式
pad在字符串的左侧、右侧或两侧添加空格
wrap将长字符串分割成长度小于给定宽度的行
joinSeries 中每个元素的字符串用指定分隔符连接起来
get_dummies提取作为 DataFrame 的虚拟变量

向量化项访问和切片

特别是 getslice 操作,使得可以从每个数组中进行向量化元素访问。例如,我们可以使用 str.slice(0, 3) 获取每个数组的前三个字符的片段。这种行为也可以通过 Python 的正常索引语法实现;例如,df.str.slice(0, 3) 相当于 df.str[0:3]

In [12]: monte.str[0:3]
Out[12]: 0    Gra
         1    Joh
         2    Ter
         3    Eri
         4    Ter
         5    Mic
         dtype: object

通过 df.str.get(i)df.str[i] 进行的索引与之类似。

这些索引方法还允许您访问由 split 返回的数组的元素。例如,结合 splitstr 索引,可以提取每个条目的姓氏:

In [13]: monte.str.split().str[-1]
Out[13]: 0    Chapman
         1     Cleese
         2    Gilliam
         3       Idle
         4      Jones
         5      Palin
         dtype: object

指标变量

另一种需要额外解释的方法是get_dummies方法。当你的数据包含某种编码指示器时,这将非常有用。例如,我们可能有一个数据集,其中包含以代码形式的信息,比如 A = “出生在美国”,B = “出生在英国”,C = “喜欢奶酪”,D = “喜欢午餐肉”:

In [14]: full_monte = pd.DataFrame({'name': monte,
                                    'info': ['B|C|D', 'B|D', 'A|C',
                                             'B|D', 'B|C', 'B|C|D']})
         full_monte
Out[14]:              name   info
         0  Graham Chapman  B|C|D
         1     John Cleese    B|D
         2   Terry Gilliam    A|C
         3       Eric Idle    B|D
         4     Terry Jones    B|C
         5   Michael Palin  B|C|D

get_dummies例程允许我们将这些指示变量拆分成一个DataFrame

In [15]: full_monte['info'].str.get_dummies('|')
Out[15]:    A  B  C  D
         0  0  1  1  1
         1  0  1  0  1
         2  1  0  1  0
         3  0  1  0  1
         4  0  1  1  0
         5  0  1  1  1

借助这些操作作为构建块,您可以在清理数据时构建各种无穷无尽的字符串处理过程。

我们在这里不会进一步深入这些方法,但我鼓励您阅读 “处理文本数据” 在 Pandas 在线文档中,或参考 “进一步资源” 中列出的资源。

示例:食谱数据库

在清理混乱的现实世界数据时,这些向量化的字符串操作变得非常有用。这里我将通过一个例子详细介绍这一点,使用从网上各种来源编译的开放食谱数据库。我们的目标是将食谱数据解析成成分列表,以便我们可以根据手头上的一些成分快速找到一个食谱。用于编译这些脚本的代码可以在 GitHub 找到,并且数据库的最新版本链接也可以在那里找到。

这个数据库大小约为 30 MB,可以使用以下命令下载并解压:

In [16]: # repo = "https://raw.githubusercontent.com/jakevdp/open-recipe-data/master"
         # !cd data && curl -O {repo}/recipeitems.json.gz
         # !gunzip data/recipeitems.json.gz

数据库以 JSON 格式存在,因此我们将使用pd.read_json来读取它(对于这个数据集,需要使用lines=True,因为文件的每一行都是一个 JSON 条目):

In [17]: recipes = pd.read_json('data/recipeitems.json', lines=True)
         recipes.shape
Out[17]: (173278, 17)

我们看到有将近 175,000 个食谱和 17 列。让我们看看一行,看看我们有什么:

In [18]: recipes.iloc[0]
Out[18]: _id                                {'$oid': '5160756b96cc62079cc2db15'}
         name                                    Drop Biscuits and Sausage Gravy
         ingredients           Biscuits\n3 cups All-purpose Flour\n2 Tablespo...
         url                   http://thepioneerwoman.com/cooking/2013/03/dro...
         image                 http://static.thepioneerwoman.com/cooking/file...
         ts                                             {'$date': 1365276011104}
         cookTime                                                          PT30M
         source                                                  thepioneerwoman
         recipeYield                                                          12
         datePublished                                                2013-03-11
         prepTime                                                          PT10M
         description           Late Saturday afternoon, after Marlboro Man ha...
         totalTime                                                           NaN
         creator                                                             NaN
         recipeCategory                                                      NaN
         dateModified                                                        NaN
         recipeInstructions                                                  NaN
         Name: 0, dtype: object

那里有很多信息,但其中大部分都是以非常混乱的形式存在的,这是从网上爬取数据的典型情况。特别是成分列表以字符串格式存在;我们需要仔细提取我们感兴趣的信息。让我们先仔细查看一下这些成分:

In [19]: recipes.ingredients.str.len().describe()
Out[19]: count    173278.000000
         mean        244.617926
         std         146.705285
         min           0.000000
         25%         147.000000
         50%         221.000000
         75%         314.000000
         max        9067.000000
         Name: ingredients, dtype: float64

成分列表平均长度为 250 个字符,最小为 0,最大接近 10,000 个字符!

出于好奇,让我们看看哪个食谱的成分列表最长:

In [20]: recipes.name[np.argmax(recipes.ingredients.str.len())]
Out[20]: 'Carrot Pineapple Spice &amp; Brownie Layer Cake with Whipped Cream &amp;
          > Cream Cheese Frosting and Marzipan Carrots'

我们可以进行其他聚合探索;例如,我们可以查看有多少食谱是早餐食品(使用正则表达式语法匹配小写和大写字母):

In [21]: recipes.description.str.contains('[Bb]reakfast').sum()
Out[21]: 3524

或者有多少食谱将肉桂列为成分:

In [22]: recipes.ingredients.str.contains('[Cc]innamon').sum()
Out[22]: 10526

我们甚至可以查看是否有任何食谱将成分拼错为“cinamon”:

In [23]: recipes.ingredients.str.contains('[Cc]inamon').sum()
Out[23]: 11

这是 Pandas 字符串工具可以实现的数据探索类型。Python 在这类数据整理方面表现得非常出色。

一个简单的食谱推荐器

让我们再进一步,开始制作一个简单的菜谱推荐系统:给定一系列食材,我们希望找到使用所有这些食材的任何菜谱。虽然概念上很简单,但由于数据的异构性而变得复杂:例如,从每行提取一个干净的食材列表并不容易。因此,我们会稍微作弊一点:我们将从常见食材列表开始,然后简单地搜索是否在每个菜谱的食材列表中。为简单起见,我们暂时只使用香草和香料:

In [24]: spice_list = ['salt', 'pepper', 'oregano', 'sage', 'parsley',
                       'rosemary', 'tarragon', 'thyme', 'paprika', 'cumin']

然后,我们可以构建一个由TrueFalse值组成的布尔DataFrame,指示每种食材是否出现在列表中:

In [25]: import re
         spice_df = pd.DataFrame({
             spice: recipes.ingredients.str.contains(spice, re.IGNORECASE)
             for spice in spice_list})
         spice_df.head()
Out[25]:     salt  pepper  oregano   sage  parsley  rosemary  tarragon  thyme   \
         0  False   False    False   True    False     False     False  False
         1  False   False    False  False    False     False     False  False
         2   True    True    False  False    False     False     False  False
         3  False   False    False  False    False     False     False  False
         4  False   False    False  False    False     False     False  False

            paprika   cumin
         0    False   False
         1    False   False
         2    False    True
         3    False   False
         4    False   False

现在,举个例子,假设我们想找到使用欧芹、辣椒粉和龙蒿的菜谱。我们可以使用DataFramequery方法快速计算这一点,有关详细信息,请参阅 第二十四章:

In [26]: selection = spice_df.query('parsley & paprika & tarragon')
         len(selection)
Out[26]: 10

我们只找到了这种组合的 10 个菜谱。让我们使用此选择返回的索引来发现这些菜谱的名称:

In [27]: recipes.name[selection.index]
Out[27]: 2069      All cremat with a Little Gem, dandelion and wa...
         74964                         Lobster with Thermidor butter
         93768      Burton's Southern Fried Chicken with White Gravy
         113926                     Mijo's Slow Cooker Shredded Beef
         137686                     Asparagus Soup with Poached Eggs
         140530                                 Fried Oyster Po’boys
         158475                Lamb shank tagine with herb tabbouleh
         158486                 Southern fried chicken in buttermilk
         163175            Fried Chicken Sliders with Pickles + Slaw
         165243                        Bar Tartine Cauliflower Salad
         Name: name, dtype: object

现在我们已经将菜谱选择从 175,000 缩减到了 10,我们可以更加明智地决定晚餐要做什么了。

进一步探索菜谱

希望这个例子给了你一点关于 Pandas 字符串方法能够高效实现的数据清理操作类型的味道(嘿)。当然,构建一个健壮的菜谱推荐系统需要 很多 工作!从每个菜谱中提取完整的食材列表将是任务的重要部分;不幸的是,使用的各种格式的广泛变化使得这成为一个相对耗时的过程。这表明在数据科学中,清理和整理真实世界数据通常占据了大部分工作量—而 Pandas 提供了可以帮助您高效完成这项工作的工具。

第二十三章: 与时间序列一起工作

Pandas 最初是在财务建模的背景下开发的,因此您可能期望它包含大量用于处理日期、时间和时间索引数据的工具。 日期和时间数据有几种不同的形式,我们将在这里进行讨论:

时间戳

特定的时间点(例如,2021 年 7 月 4 日上午 7:00)。

时间间隔周期

特定开始和结束点之间的一段时间;例如,2021 年 6 月份。 周期通常指每个间隔具有统一长度且不重叠的特殊时间间隔的情况(例如,由一天组成的 24 小时周期)。

时间差持续时间

精确的时间长度(例如,22.56 秒的持续时间)。

本章将介绍如何在 Pandas 中处理这些类型的日期/时间数据。 这并不是 Python 或 Pandas 中可用的时间序列工具的完整指南,而是旨在作为用户如何处理时间序列的广泛概述。 我们将首先简要讨论 Python 中处理日期和时间的工具,然后更详细地讨论 Pandas 提供的工具。 最后,我们将回顾一些在 Pandas 中处理时间序列数据的简短示例。

Python 中的日期和时间

Python 世界中有许多可用于表示日期、时间、时间差和时间跨度的表示方法。 虽然 Pandas 提供的时间序列工具对数据科学应用最有用,但了解它们与 Python 中其他工具的关系是有帮助的。

本机 Python 日期和时间:datetime 和 dateutil

Python 用于处理日期和时间的基本对象位于内置的datetime模块中。 除了第三方的dateutil模块之外,您还可以使用此功能快速执行许多有用的日期和时间功能。 例如,您可以使用datetime类型手动构建日期:

In [1]: from datetime import datetime
        datetime(year=2021, month=7, day=4)
Out[1]: datetime.datetime(2021, 7, 4, 0, 0)

或者,使用dateutil模块,您可以从各种字符串格式中解析日期:

In [2]: from dateutil import parser
        date = parser.parse("4th of July, 2021")
        date
Out[2]: datetime.datetime(2021, 7, 4, 0, 0)

一旦您有了datetime对象,您可以执行一些操作,比如打印星期几:

In [3]: date.strftime('%A')
Out[3]: 'Sunday'

这里我们使用了用于打印日期的标准字符串格式代码之一('%A'),您可以在 Python 的datetime文档的strftime部分中了解相关信息。 有关其他有用日期工具的文档,请查看dateutil的在线文档。 还有一个相关的软件包需要注意,那就是pytz,它包含了处理时间序列数据中最令人头疼的元素:时区。

datetimedateutil的威力在于它们的灵活性和简单的语法:你可以使用这些对象及其内置方法轻松地执行几乎任何你感兴趣的操作。它们的局限性在于当你希望处理大量的日期和时间时:正如 Python 数值变量的列表与 NumPy 风格的类型化数值数组相比是次优的一样,Python datetime对象的列表与编码日期的类型化数组相比也是次优的。

时间数组:NumPy 的 datetime64

NumPy 的datetime64数据类型将日期编码为 64 位整数,因此允许以紧凑的方式表示日期数组并以有效的方式对其进行操作。datetime64需要特定的输入格式:

In [4]: import numpy as np
        date = np.array('2021-07-04', dtype=np.datetime64)
        date
Out[4]: array('2021-07-04', dtype='datetime64[D]')

一旦我们将日期转换为这种形式,就可以快速对其进行向量化操作:

In [5]: date + np.arange(12)
Out[5]: array(['2021-07-04', '2021-07-05', '2021-07-06', '2021-07-07',
               '2021-07-08', '2021-07-09', '2021-07-10', '2021-07-11',
               '2021-07-12', '2021-07-13', '2021-07-14', '2021-07-15'],
              dtype='datetime64[D]')

由于 NumPy datetime64数组中的统一类型,这种操作比直接使用 Python 的datetime对象要快得多,特别是在数组变大时(我们在第六章中介绍了这种向量化类型)。

datetime64和相关的timedelta64对象的一个细节是它们是建立在基本时间单位上的。因为datetime64对象的精度限制为 64 位,所以可编码时间的范围是基本单位的2 64倍。换句话说,datetime64时间分辨率最大时间跨度之间存在权衡。

例如,如果您想要 1 纳秒的时间分辨率,您只有足够的信息来编码2 64纳秒范围内的时间,或者不到 600 年。NumPy 将从输入中推断出所需的单位;例如,这里是一个基于天的datetime

In [6]: np.datetime64('2021-07-04')
Out[6]: numpy.datetime64('2021-07-04')

这是一个基于分钟的 datetime:

In [7]: np.datetime64('2021-07-04 12:00')
Out[7]: numpy.datetime64('2021-07-04T12:00')

您可以使用许多格式代码强制使用任何所需的基本单位;例如,这里我们将强制使用基于纳秒的时间:

In [8]: np.datetime64('2021-07-04 12:59:59.50', 'ns')
Out[8]: numpy.datetime64('2021-07-04T12:59:59.500000000')

表 23-1,摘自 NumPy datetime64 文档,列出了可用的格式代码及其可以编码的相对和绝对时间跨度。

表 23-1. 日期和时间代码描述

代码意义时间跨度(相对)时间跨度(绝对)
Y± 9.2e18 年[9.2e18 BC, 9.2e18 AD]
M± 7.6e17 年[7.6e17 BC, 7.6e17 AD]
W± 1.7e17 年[1.7e17 BC, 1.7e17 AD]
D± 2.5e16 年[2.5e16 BC, 2.5e16 AD]
h小时± 1.0e15 年[1.0e15 BC, 1.0e15 AD]
m分钟± 1.7e13 年[1.7e13 BC, 1.7e13 AD]
s± 2.9e12 年[2.9e9 BC, 2.9e9 AD]
ms毫秒± 2.9e9 年[2.9e6 BC, 2.9e6 AD]
us微秒± 2.9e6 年[290301 BC, 294241 AD]
ns纳秒± 292 年[1678 AD, 2262 AD]
ps皮秒± 106 天[ 1969 年, 1970 年]
fs飞秒± 2.6 小时[ 1969 年, 1970 年]
as阿秒± 9.2 秒[ 1969 年, 1970 年]

对于我们在现实世界中看到的数据类型,一个有用的默认值是datetime64[ns],因为它可以用适当的精度编码一系列现代日期。

最后,请注意,虽然datetime64数据类型解决了内置 Python datetime 类型的一些不足之处,但它缺少许多datetime和尤其是dateutil提供的便利方法和函数。更多信息可以在NumPy 的datetime64文档中找到。

Pandas 中的日期和时间:两者兼得

Pandas 在刚讨论的所有工具基础上构建了一个Timestamp对象,它结合了datetimedateutil的易用性以及numpy.datetime64的高效存储和向量化接口。从这些Timestamp对象中,Pandas 可以构建一个DatetimeIndex,用于索引SeriesDataFrame中的数据。

例如,我们可以使用 Pandas 工具重复之前的演示。我们可以解析一个灵活格式的字符串日期,并使用格式代码输出星期几,如下所示:

In [9]: import pandas as pd
        date = pd.to_datetime("4th of July, 2021")
        date
Out[9]: Timestamp('2021-07-04 00:00:00')
In [10]: date.strftime('%A')
Out[10]: 'Sunday'

另外,我们可以直接在同一个对象上进行 NumPy 风格的向量化操作:

In [11]: date + pd.to_timedelta(np.arange(12), 'D')
Out[11]: DatetimeIndex(['2021-07-04', '2021-07-05', '2021-07-06', '2021-07-07',
                        '2021-07-08', '2021-07-09', '2021-07-10', '2021-07-11',
                        '2021-07-12', '2021-07-13', '2021-07-14', '2021-07-15'],
                       dtype='datetime64[ns]', freq=None)

在接下来的章节中,我们将更仔细地学习使用 Pandas 提供的工具操作时间序列数据。

Pandas 时间序列:按时间索引

当您开始按时间戳索引数据时,Pandas 的时间序列工具就会变得非常有用。例如,我们可以构建一个具有时间索引数据的Series对象:

In [12]: index = pd.DatetimeIndex(['2020-07-04', '2020-08-04',
                                   '2021-07-04', '2021-08-04'])
         data = pd.Series([0, 1, 2, 3], index=index)
         data
Out[12]: 2020-07-04    0
         2020-08-04    1
         2021-07-04    2
         2021-08-04    3
         dtype: int64

现在我们已经将这些数据放入了一个Series中,我们可以利用我们在之前章节中讨论过的任何Series索引模式,传递可以强制转换为日期的值:

In [13]: data['2020-07-04':'2021-07-04']
Out[13]: 2020-07-04    0
         2020-08-04    1
         2021-07-04    2
         dtype: int64

还有其他特殊的仅限日期索引操作,比如传递一个年份以获得该年份所有数据的切片:

In [14]: data['2021']
Out[14]: 2021-07-04    2
         2021-08-04    3
         dtype: int64

后面,我们将看到更多关于日期作为索引的便利性的例子。但首先,让我们更仔细地看一下可用的时间序列数据结构。

Pandas 时间序列数据结构

本节将介绍处理时间序列数据的基本 Pandas 数据结构:

  • 对于时间戳,Pandas 提供了Timestamp类型。如前所述,这实际上是 Python 原生datetime的替代品,但它基于更高效的numpy.datetime64数据类型。相关的索引结构是DatetimeIndex

  • 对于时间段,Pandas 提供了Period类型。这个类型基于numpy.datetime64,用于编码固定频率间隔。相关的索引结构是PeriodIndex

  • 对于时间差持续时间,Pandas 提供了Timedelta类型。Timedelta是 Python 原生的datetime.timedelta类型的更高效替代品,基于numpy.timedelta64。相关的索引结构是TimedeltaIndex

这些日期/时间对象中最基础的是TimestampDatetime​In⁠dex对象。虽然可以直接调用这些类对象,但更常见的是使用pd.to_datetime函数,该函数可以解析各种格式。将单个日期传递给pd.to_datetime将产生一个Timestamp;默认情况下传递一系列日期将产生一个DatetimeIndex,正如你在这里看到的:

In [15]: dates = pd.to_datetime([datetime(2021, 7, 3), '4th of July, 2021',
                                '2021-Jul-6', '07-07-2021', '20210708'])
         dates
Out[15]: DatetimeIndex(['2021-07-03', '2021-07-04', '2021-07-06', '2021-07-07',
                        '2021-07-08'],
                       dtype='datetime64[ns]', freq=None)

任何DatetimeIndex都可以通过to_period函数转换为PeriodIndex,并增加一个频率代码;这里我们将使用'D'表示每日频率:

In [16]: dates.to_period('D')
Out[16]: PeriodIndex(['2021-07-03', '2021-07-04', '2021-07-06', '2021-07-07',
                      '2021-07-08'],
                     dtype='period[D]')

当从一个日期中减去另一个日期时,会创建一个TimedeltaIndex

In [17]: dates - dates[0]
Out[17]: TimedeltaIndex(['0 days', '1 days', '3 days', '4 days', '5 days'],
          > dtype='timedelta64[ns]', freq=None)

常规序列:pd.date_range

为了更方便地创建常规日期序列,Pandas 提供了几个专用函数:pd.date_range用于时间戳,pd.period_range用于周期,pd.timedelta_range用于时间差。我们已经看到 Python 的range和 NumPy 的np.arange接受起始点、结束点和可选步长,并返回一个序列。类似地,pd.date_range接受起始日期、结束日期和可选频率代码,以创建一系列常规日期:

In [18]: pd.date_range('2015-07-03', '2015-07-10')
Out[18]: DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
                        '2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
                       dtype='datetime64[ns]', freq='D')

或者,日期范围可以不是起点和终点,而是起点和一定数量的周期:

In [19]: pd.date_range('2015-07-03', periods=8)
Out[19]: DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
                        '2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
                       dtype='datetime64[ns]', freq='D')

可以通过修改freq参数来调整间隔,默认为D。例如,这里我们构建一个小时时间戳的范围:

In [20]: pd.date_range('2015-07-03', periods=8, freq='H')
Out[20]: DatetimeIndex(['2015-07-03 00:00:00', '2015-07-03 01:00:00',
                        '2015-07-03 02:00:00', '2015-07-03 03:00:00',
                        '2015-07-03 04:00:00', '2015-07-03 05:00:00',
                        '2015-07-03 06:00:00', '2015-07-03 07:00:00'],
                       dtype='datetime64[ns]', freq='H')

要创建PeriodTimedelta值的常规序列,可以使用类似的pd.period_rangepd.timedelta_range函数。这里是一些月度周期:

In [21]: pd.period_range('2015-07', periods=8, freq='M')
Out[21]: PeriodIndex(['2015-07', '2015-08', '2015-09',
                      '2015-10', '2015-11', '2015-12',
                      '2016-01', '2016-02'],
                     dtype='period[M]')

以及一系列按小时增加的持续时间:

In [22]: pd.timedelta_range(0, periods=6, freq='H')
Out[22]: TimedeltaIndex(['0 days 00:00:00', '0 days 01:00:00', '0 days 02:00:00',
                         '0 days 03:00:00', '0 days 04:00:00', '0 days 05:00:00'],
                        dtype='timedelta64[ns]', freq='H')

所有这些都需要理解 Pandas 的频率代码,这些在下一节中总结。

频率和偏移量

Pandas 时间序列工具的基础是频率日期偏移的概念。下表总结了主要的可用代码;与前面章节展示的D(天)和H(小时)代码一样,我们可以使用这些代码来指定任何所需的频率间隔。表 23-2 总结了主要可用的代码。

表 23-2. Pandas 频率代码列表

代码描述代码描述
D日历日B工作日
W
M月末BM工作月末
Q季度末BQ商业季度末
A年末BA商业年末
H小时BH工作小时
T分钟
S
L毫秒
U微秒
N纳秒

月度、季度和年度频率均标记在指定期间的末尾。在任何这些频率代码后面添加S后缀会使它们标记在开始而不是末尾(参见表 23-3)。

表 23-3. 开始索引频率代码列表

代码描述代码描述
MS月开始BMS工作日月开始
QS季度开始BQS工作日季度开始
AS年度开始BAS工作日年度开始

此外,您可以通过添加三个字母的月份代码作为后缀来更改用于标记任何季度或年度代码的月份:

  • Q-JAN, BQ-FEB, QS-MAR, BQS-APR等。

  • A-JAN, BA-FEB, AS-MAR, BAS-APR等。

同样地,周频率的分割点可以通过添加三个字母的工作日代码进行修改:W-SUN, W-MON, W-TUE, W-WED等。

此外,代码可以与数字结合以指定其他频率。例如,对于 2 小时 30 分钟的频率,我们可以将小时(H)和分钟(T)代码组合如下:

In [23]: pd.timedelta_range(0, periods=6, freq="2H30T")
Out[23]: TimedeltaIndex(['0 days 00:00:00', '0 days 02:30:00', '0 days 05:00:00',
                         '0 days 07:30:00', '0 days 10:00:00', '0 days 12:30:00'],
                        dtype='timedelta64[ns]', freq='150T')

所有这些短代码都指向 Pandas 时间序列偏移的特定实例,这些可以在pd.tseries.offsets模块中找到。例如,我们可以直接创建工作日偏移量如下:

In [24]: from pandas.tseries.offsets import BDay
         pd.date_range('2015-07-01', periods=6, freq=BDay())
Out[24]: DatetimeIndex(['2015-07-01', '2015-07-02', '2015-07-03', '2015-07-06',
                        '2015-07-07', '2015-07-08'],
                       dtype='datetime64[ns]', freq='B')

欲了解更多频率和偏移使用的讨论,请参阅 Pandas 文档的DateOffset部分

重新采样、移位和窗口操作

使用日期和时间作为索引以直观地组织和访问数据的能力是 Pandas 时间序列工具的重要组成部分。总体上索引数据的好处(在操作期间自动对齐,直观的数据切片和访问等)仍然适用,并且 Pandas 提供了几个额外的时间序列特定操作。

我们将以一些股价数据为例,来看一些具体的内容。由于 Pandas 主要在金融环境中开发,因此它包含了一些非常具体的金融数据工具。例如,配套的pandas-datareader包(可通过pip install pandas-datareader安装)知道如何从各种在线源导入数据。这里我们将加载部分标准普尔 500 指数的价格历史:

In [25]: from pandas_datareader import data

         sp500 = data.DataReader('^GSPC', start='2018', end='2022',
                                 data_source='yahoo')
         sp500.head()
Out[25]:                    High          Low         Open        Close      Volume \
         Date
         2018-01-02  2695.889893  2682.360107  2683.729980  2695.810059  3367250000
         2018-01-03  2714.370117  2697.770020  2697.850098  2713.060059  3538660000
         2018-01-04  2729.290039  2719.070068  2719.310059  2723.989990  3695260000
         2018-01-05  2743.449951  2727.919922  2731.330078  2743.149902  3236620000
         2018-01-08  2748.510010  2737.600098  2742.669922  2747.709961  3242650000

                       Adj Close
         Date
         2018-01-02  2695.810059
         2018-01-03  2713.060059
         2018-01-04  2723.989990
         2018-01-05  2743.149902
         2018-01-08  2747.709961

简单起见,我们将仅使用收盘价:

In [26]: sp500 = sp500['Close']

我们可以使用plot方法来可视化这一点,在正常的 Matplotlib 设置样板之后(参见第四部分);结果显示在图 23-1 中。

In [27]: %matplotlib inline
         import matplotlib.pyplot as plt
         plt.style.use('seaborn-whitegrid')
         sp500.plot();

output 68 0

图 23-1. 标准普尔 500 指数随时间变化的收盘价

重新采样和转换频率

在处理时间序列数据时,一个常见的需求是在更高或更低的频率上重新采样。这可以使用resample方法完成,或者更简单的asfreq方法。两者之间的主要区别在于resample基本上是数据聚合,而asfreq基本上是数据选择

让我们比较当我们对标准普尔 500 指数的收盘价数据进行降采样时,这两者返回的结果。这里我们将数据重新采样到商业年度末;结果显示在图 23-2 中。

In [28]: sp500.plot(alpha=0.5, style='-')
         sp500.resample('BA').mean().plot(style=':')
         sp500.asfreq('BA').plot(style='--');
         plt.legend(['input', 'resample', 'asfreq'],
                    loc='upper left');

output 70 0

图 23-2. 标准普尔 500 指数收盘价的重新采样

注意区别:在每个点上,resample报告的是前一年的平均值,而asfreq报告的是年末的值

对于上采样,resampleasfreq基本上是等效的,尽管resample提供了更多的选项。在这种情况下,这两种方法的默认行为都是保留上采样点为空;即,填充为 NA 值。就像第十六章中讨论的pd.fillna函数一样,asfreq接受一个method参数来指定如何填补值。在这里,我们将业务日数据重新采样为每日频率(即包括周末);图 23-3 显示了结果。

In [29]: fig, ax = plt.subplots(2, sharex=True)
         data = sp500.iloc[:20]

         data.asfreq('D').plot(ax=ax[0], marker='o')

         data.asfreq('D', method='bfill').plot(ax=ax[1], style='-o')
         data.asfreq('D', method='ffill').plot(ax=ax[1], style='--o')
         ax[1].legend(["back-fill", "forward-fill"]);

output 73 0

图 23-3. 前向填充和后向填充插值的比较

因为 S&P 500 数据仅存在于工作日,顶部面板中的空白表示 NA 值。底部面板显示了填补空白的两种策略之间的差异:前向填充和后向填充。

时间偏移

另一个常见的时间序列特定操作是数据的时间偏移。为此,Pandas 提供了shift方法,可以将数据按给定的条目数进行偏移。对于以固定频率采样的时间序列数据,这可以为我们提供探索时间趋势的方法。

例如,在这里我们将数据重新采样为每日值,并将其向前偏移 364 天,以计算 S&P 500 的一年投资回报率(参见图 23-4)。

In [30]: sp500 = sp500.asfreq('D', method='pad')

         ROI = 100 * (sp500.shift(-365) - sp500) / sp500
         ROI.plot()
         plt.ylabel('% Return on Investment after 1 year');

output 76 0

图 23-4. 一年后的投资回报率

最糟糕的一年回报率约为 2019 年 3 月,随后的一年发生了与冠状病毒相关的市场崩盘。正如你所预料的,最佳的一年回报率出现在 2020 年 3 月,对于那些有足够远见或运气购买低位的人来说。

滚动窗口

计算滚动统计数据是 Pandas 实现的第三种时间序列特定操作。这可以通过SeriesDataFrame对象的rolling属性实现,它返回一个类似于groupby操作所见的视图(参见第二十章)。

例如,我们可以查看股票价格的一年居中滚动均值和标准差(参见图 23-5)。

In [31]: rolling = sp500.rolling(365, center=True)

         data = pd.DataFrame({'input': sp500,
                              'one-year rolling_mean': rolling.mean(),
                              'one-year rolling_median': rolling.median()})
         ax = data.plot(style=['-', '--', ':'])
         ax.lines[0].set_alpha(0.3)

output 80 0

图 23-5. S&P500 指数的滚动统计数据

groupby操作一样,aggregateapply方法可以用于自定义滚动计算。

示例:可视化西雅图自行车计数

作为处理时间序列数据的更深入的例子,让我们来看看西雅图Fremont Bridge的自行车计数。这些数据来自于 2012 年底安装的自动自行车计数器,该计数器在桥的东西侧人行道上有感应传感器。小时自行车计数可从http://data.seattle.gov下载;Fremont Bridge 自行车计数数据集在交通类别下可用。

用于本书的 CSV 可以按以下方式下载:

In [32]: # url = ('https://raw.githubusercontent.com/jakevdp/'
         #        'bicycle-data/main/FremontBridge.csv')
         # !curl -O {url}

下载了这个数据集之后,我们可以使用 Pandas 将 CSV 输出读入DataFrame。我们将指定Date列作为索引,并希望这些日期能够自动解析:

In [33]: data = pd.read_csv('FremontBridge.csv', index_col='Date', parse_dates=True)
         data.head()
Out[33]:                      Fremont Bridge Total  Fremont Bridge East Sidewalk  \
         Date
         2019-11-01 00:00:00                  12.0                           7.0
         2019-11-01 01:00:00                   7.0                           0.0
         2019-11-01 02:00:00                   1.0                           0.0
         2019-11-01 03:00:00                   6.0                           6.0
         2019-11-01 04:00:00                   6.0                           5.0

                              Fremont Bridge West Sidewalk
         Date
         2019-11-01 00:00:00                           5.0
         2019-11-01 01:00:00                           7.0
         2019-11-01 02:00:00                           1.0
         2019-11-01 03:00:00                           0.0
         2019-11-01 04:00:00                           1.0

为了方便起见,我们将缩短列名:

In [34]: data.columns = ['Total', 'East', 'West']

现在让我们来看看这些数据的摘要统计信息:

In [35]: data.dropna().describe()
Out[35]:                Total           East           West
         count  147255.000000  147255.000000  147255.000000
         mean      110.341462      50.077763      60.263699
         std       140.422051      64.634038      87.252147
         min         0.000000       0.000000       0.000000
         25%        14.000000       6.000000       7.000000
         50%        60.000000      28.000000      30.000000
         75%       145.000000      68.000000      74.000000
         max      1097.000000     698.000000     850.000000

数据可视化

我们可以通过可视化数据集来获得一些见解。让我们首先绘制原始数据(见图 23-6)。

In [36]: data.plot()
         plt.ylabel('Hourly Bicycle Count');

output 92 0

图 23-6. 西雅图 Fremont Bridge 的小时自行车计数

~150,000 小时样本过于密集,我们无法理解太多内容。我们可以通过将数据重新采样到更粗的网格来获得更多见解。让我们按周重新采样(见图 23-7)。

In [37]: weekly = data.resample('W').sum()
         weekly.plot(style=['-', ':', '--'])
         plt.ylabel('Weekly bicycle count');

这揭示了一些趋势:正如你所预料的,夏季人们骑自行车的次数比冬季多,即使在特定季节内,自行车使用量也会随着周而变化(可能取决于天气;参见第四十二章,我们将进一步探讨这一点)。此外,COVID-19 大流行对通勤模式的影响非常明显,始于 2020 年初。

output 94 0

图 23-7. 西雅图 Fremont Bridge 每周自行车过桥次数

另一个处理数据聚合的便捷选项是使用滚动均值,利用pd.rolling_mean函数。在这里,我们将研究数据的 30 天滚动均值,确保窗口居中(见图 23-8)。

In [38]: daily = data.resample('D').sum()
         daily.rolling(30, center=True).sum().plot(style=['-', ':', '--'])
         plt.ylabel('mean hourly count');

output 96 0

图 23-8. 每周自行车计数的滚动均值

结果的不平滑是由于窗口的硬截断造成的。我们可以使用窗口函数来获得更平滑的滚动均值,例如,使用高斯窗口,如图 23-9 所示。以下代码指定了窗口的宽度(这里是 50 天)和高斯窗口的宽度(这里是 10 天):

In [39]: daily.rolling(50, center=True,
                       win_type='gaussian').sum(std=10).plot(style=['-', ':', '--']);

output 98 0

图 23-9. 高斯平滑后的每周自行车计数

深入数据

尽管这些平滑的数据视图有助于了解数据的一般趋势,但它们隐藏了很多结构。例如,我们可能想要查看平均交通量随时间变化的情况。我们可以使用第 20 章中讨论的groupby功能来实现这一点(见图 23-10)。

In [40]: by_time = data.groupby(data.index.time).mean()
         hourly_ticks = 4 * 60 * 60 * np.arange(6)
         by_time.plot(xticks=hourly_ticks, style=['-', ':', '--']);

output 100 0

图 23-10. 平均每小时自行车计数

每小时的交通量呈现出明显的双峰分布,大约在上午 8 点和下午 5 点左右。这很可能是通勤交通的强烈组成部分的证据。还有一个方向性的组成部分:根据数据显示,东侧人行道在上午通勤时段更多被使用,而西侧人行道在下午通勤时段更多被使用。

我们也许会对一周中不同日期的情况有所好奇。同样,我们可以通过简单的groupby(见图 23-11)来做到这一点。

In [41]: by_weekday = data.groupby(data.index.dayofweek).mean()
         by_weekday.index = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun']
         by_weekday.plot(style=['-', ':', '--']);

output 102 0

图 23-11. 平均每日自行车计数

这显示了工作日和周末之间的明显区别,工作日每天穿过桥的平均骑行者大约是周六和周日的两倍。

鉴此,让我们进行复合groupby,并查看工作日与周末的小时趋势。我们将首先按周末标志和时间段进行分组:

In [42]: weekend = np.where(data.index.weekday < 5, 'Weekday', 'Weekend')
         by_time = data.groupby([weekend, data.index.time]).mean()

现在我们将使用一些 Matplotlib 工具,这些工具将在第 31 章中进行描述,以便将两个面板并排绘制,如图 23-12 所示。

In [43]: import matplotlib.pyplot as plt
         fig, ax = plt.subplots(1, 2, figsize=(14, 5))
         by_time.loc['Weekday'].plot(ax=ax[0], title='Weekdays',
                                     xticks=hourly_ticks, style=['-', ':', '--'])
         by_time.loc['Weekend'].plot(ax=ax[1], title='Weekends',
                                     xticks=hourly_ticks, style=['-', ':', '--']);

output 106 0

图 23-12. 每小时平均自行车计数(按工作日和周末划分)

结果显示了工作日的双峰通勤模式和周末的单峰休闲模式。深入挖掘这些数据并详细分析天气、温度、年份和其他因素对人们通勤模式的影响可能会很有趣;有关详细讨论,请参阅我的博客文章《西雅图真的看到了自行车使用率的上升吗?》,该文章使用了这些数据的子集。我们还将在第 42 章中探讨这些数据集的建模背景。

第二十四章:高性能 Pandas:eval 和 query

正如我们在之前的章节中已经看到的,PyData 栈的强大建立在 NumPy 和 Pandas 将基本操作推送到低级编译代码中的能力上,通过直观的高级语法:例如 NumPy 中的向量化/广播操作,以及 Pandas 中的分组类型操作。虽然这些抽象对许多常见用例是高效和有效的,但它们经常依赖于临时中间对象的创建,这可能会导致计算时间和内存使用的不必要开销。

为了解决这个问题,Pandas 包括一些方法,允许您直接访问 C 速度操作,而无需昂贵地分配中间数组:evalquery,这些方法依赖于 NumExpr 包

激励查询和 eval:复合表达式

我们之前已经看到,NumPy 和 Pandas 支持快速的向量化操作;例如,当添加两个数组的元素时:

In [1]: import numpy as np
        rng = np.random.default_rng(42)
        x = rng.random(1000000)
        y = rng.random(1000000)
        %timeit x + y
Out[1]: 2.21 ms ± 142 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

如在 第六章 中讨论的,这比通过 Python 循环或理解式添加要快得多:

In [2]: %timeit np.fromiter((xi + yi for xi, yi in zip(x, y)),
                            dtype=x.dtype, count=len(x))
Out[2]: 263 ms ± 43.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

但是当计算复合表达式时,这种抽象可能变得不那么高效。例如,考虑以下表达式:

In [3]: mask = (x > 0.5) & (y < 0.5)

因为 NumPy 评估每个子表达式,这大致等同于以下内容:

In [4]: tmp1 = (x > 0.5)
        tmp2 = (y < 0.5)
        mask = tmp1 & tmp2

换句话说,每个中间步骤都显式地分配在内存中。如果 xy 数组非常大,这可能导致显著的内存和计算开销。NumExpr 库使您能够逐个元素地计算这种复合表达式,而无需分配完整的中间数组。有关更多详细信息,请参阅 NumExpr 文档,但目前足以说,该库接受一个 字符串,该字符串给出您想计算的 NumPy 风格表达式:

In [5]: import numexpr
        mask_numexpr = numexpr.evaluate('(x > 0.5) & (y < 0.5)')
        np.all(mask == mask_numexpr)
Out[5]: True

这里的好处在于,NumExpr 以避免可能的临时数组方式评估表达式,因此对于长数组上的长序列计算比 NumPy 要高效得多。我们将在这里讨论的 Pandas evalquery 工具在概念上类似,并且本质上是 NumExpr 功能的 Pandas 特定包装。

pandas.eval 用于高效操作

Pandas 中的 eval 函数使用字符串表达式来高效地计算 DataFrame 对象上的操作。例如,考虑以下数据:

In [6]: import pandas as pd
        nrows, ncols = 100000, 100
        df1, df2, df3, df4 = (pd.DataFrame(rng.random((nrows, ncols)))
                              for i in range(4))

要使用典型的 Pandas 方法计算所有四个 DataFrame 的总和,我们只需写出总和:

In [7]: %timeit df1 + df2 + df3 + df4
Out[7]: 73.2 ms ± 6.72 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

可以通过构造字符串表达式来使用 pd.eval 计算相同的结果:

In [8]: %timeit pd.eval('df1 + df2 + df3 + df4')
Out[8]: 34 ms ± 4.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

eval 版本的这个表达式大约快 50%(并且使用的内存要少得多),同时给出相同的结果:

In [9]: np.allclose(df1 + df2 + df3 + df4,
                    pd.eval('df1 + df2 + df3 + df4'))
Out[9]: True

pd.eval支持广泛的操作。为了展示这些操作,我们将使用以下整数数据:

In [10]: df1, df2, df3, df4, df5 = (pd.DataFrame(rng.integers(0, 1000, (100, 3)))
                                    for i in range(5))

下面是pd.eval支持的操作的总结:

算术运算符

pd.eval支持所有算术运算符。例如:

In [11]: result1 = -df1 * df2 / (df3 + df4) - df5
         result2 = pd.eval('-df1 * df2 / (df3 + df4) - df5')
         np.allclose(result1, result2)
Out[11]: True

比较运算符

pd.eval支持所有比较运算符,包括链式表达式:

In [12]: result1 = (df1 < df2) & (df2 <= df3) & (df3 != df4)
         result2 = pd.eval('df1 < df2 <= df3 != df4')
         np.allclose(result1, result2)
Out[12]: True

位运算符

pd.eval支持&|位运算符:

In [13]: result1 = (df1 < 0.5) & (df2 < 0.5) | (df3 < df4)
         result2 = pd.eval('(df1 < 0.5) & (df2 < 0.5) | (df3 < df4)')
         np.allclose(result1, result2)
Out[13]: True

此外,它还支持在布尔表达式中使用字面量andor

In [14]: result3 = pd.eval('(df1 < 0.5) and (df2 < 0.5) or (df3 < df4)')
         np.allclose(result1, result3)
Out[14]: True

对象属性和索引

pd.eval支持通过obj.attr语法和obj[index]语法访问对象属性:

In [15]: result1 = df2.T[0] + df3.iloc[1]
         result2 = pd.eval('df2.T[0] + df3.iloc[1]')
         np.allclose(result1, result2)
Out[15]: True

其他操作

其他操作,例如函数调用、条件语句、循环和其他更复杂的构造,目前pd.eval中实现。如果你想执行这些更复杂的表达式类型,可以使用 NumExpr 库本身。

DataFrame.eval 进行按列操作

就像 Pandas 有一个顶级的pd.eval函数一样,DataFrame对象也有一个eval方法,功能类似。eval方法的好处是可以按名称引用列。我们将用这个带标签的数组作为示例:

In [16]: df = pd.DataFrame(rng.random((1000, 3)), columns=['A', 'B', 'C'])
         df.head()
Out[16]:           A         B         C
         0  0.850888  0.966709  0.958690
         1  0.820126  0.385686  0.061402
         2  0.059729  0.831768  0.652259
         3  0.244774  0.140322  0.041711
         4  0.818205  0.753384  0.578851

使用前面一节中的pd.eval,我们可以像这样计算三个列的表达式:

In [17]: result1 = (df['A'] + df['B']) / (df['C'] - 1)
         result2 = pd.eval("(df.A + df.B) / (df.C - 1)")
         np.allclose(result1, result2)
Out[17]: True

DataFrame.eval方法允许更简洁地评估列的表达式:

In [18]: result3 = df.eval('(A + B) / (C - 1)')
         np.allclose(result1, result3)
Out[18]: True

请注意,在这里我们将列名视为评估表达式中的变量,结果正是我们希望的。

在 DataFrame.eval 中的赋值

除了刚才讨论的选项之外,DataFrame.eval还允许对任何列进行赋值。让我们使用之前的DataFrame,它有列'A''B''C'

In [19]: df.head()
Out[19]:           A         B         C
         0  0.850888  0.966709  0.958690
         1  0.820126  0.385686  0.061402
         2  0.059729  0.831768  0.652259
         3  0.244774  0.140322  0.041711
         4  0.818205  0.753384  0.578851

我们可以使用df.eval创建一个新的列'D',并将其赋值为从其他列计算得到的值:

In [20]: df.eval('D = (A + B) / C', inplace=True)
         df.head()
Out[20]:           A         B         C          D
         0  0.850888  0.966709  0.958690   1.895916
         1  0.820126  0.385686  0.061402  19.638139
         2  0.059729  0.831768  0.652259   1.366782
         3  0.244774  0.140322  0.041711   9.232370
         4  0.818205  0.753384  0.578851   2.715013

以同样的方式,任何现有的列都可以被修改:

In [21]: df.eval('D = (A - B) / C', inplace=True)
         df.head()
Out[21]:           A         B         C         D
         0  0.850888  0.966709  0.958690 -0.120812
         1  0.820126  0.385686  0.061402  7.075399
         2  0.059729  0.831768  0.652259 -1.183638
         3  0.244774  0.140322  0.041711  2.504142
         4  0.818205  0.753384  0.578851  0.111982

DataFrame.eval 中的本地变量

DataFrame.eval方法支持一种额外的语法,使其能够与本地 Python 变量一起使用。考虑以下内容:

In [22]: column_mean = df.mean(1)
         result1 = df['A'] + column_mean
         result2 = df.eval('A + @column_mean')
         np.allclose(result1, result2)
Out[22]: True

这里的@字符标记的是变量名而不是列名,并且让你能够高效地评估涉及两个“命名空间”的表达式:列的命名空间和 Python 对象的命名空间。请注意,这个@字符只支持DataFrame.eval方法,而不支持pandas.eval函数,因为pandas.eval函数只能访问一个(Python)命名空间。

DataFrame.query方法

DataFrame还有一个基于评估字符串的方法,叫做query。考虑以下内容:

In [23]: result1 = df[(df.A < 0.5) & (df.B < 0.5)]
         result2 = pd.eval('df[(df.A < 0.5) & (df.B < 0.5)]')
         np.allclose(result1, result2)
Out[23]: True

正如我们在讨论DataFrame.eval时使用的示例一样,这是一个涉及DataFrame列的表达式。然而,它不能使用DataFrame.eval语法表示!相反,对于这种类型的筛选操作,你可以使用query方法:

In [24]: result2 = df.query('A < 0.5 and B < 0.5')
         np.allclose(result1, result2)
Out[24]: True

与掩码表达式相比,这不仅是更有效的计算,而且更易于阅读和理解。请注意,query方法还接受@标志来标记本地变量:

In [25]: Cmean = df['C'].mean()
         result1 = df[(df.A < Cmean) & (df.B < Cmean)]
         result2 = df.query('A < @Cmean and B < @Cmean')
         np.allclose(result1, result2)
Out[25]: True

性能:何时使用这些函数

在考虑是否使用evalquery时,有两个考虑因素:计算时间内存使用。内存使用是最可预测的方面。正如前面提到的,涉及 NumPy 数组或 Pandas DataFrame的每个复合表达式都会导致临时数组的隐式创建。例如,这个:

In [26]: x = df[(df.A < 0.5) & (df.B < 0.5)]

大致相当于这个:

In [27]: tmp1 = df.A < 0.5
         tmp2 = df.B < 0.5
         tmp3 = tmp1 & tmp2
         x = df[tmp3]

如果临时DataFrame的大小与您可用的系统内存(通常为几个千兆字节)相比显著,则使用evalquery表达式是个好主意。您可以使用以下命令检查数组的大约大小(以字节为单位):

In [28]: df.values.nbytes
Out[28]: 32000

就性能而言,即使您没有使用完系统内存,eval可能会更快。问题在于您的临时对象与系统的 L1 或 L2 CPU 缓存大小(通常为几兆字节)相比如何;如果它们要大得多,那么eval可以避免在不同内存缓存之间移动值时可能出现的某些潜在缓慢。实际上,我发现传统方法与eval/query方法之间的计算时间差异通常不显著——如果有什么的话,对于较小的数组来说,传统方法更快!eval/query的好处主要在于节省内存,以及它们有时提供的更清晰的语法。

我们在这里已经涵盖了关于evalquery的大部分细节;有关更多信息,请参阅 Pandas 文档。特别是,可以为运行这些查询指定不同的解析器和引擎;有关详细信息,请参阅文档中的“提升性能”部分

更多资源

在本书的这一部分中,我们已经涵盖了有效使用 Pandas 进行数据分析的许多基础知识。但我们的讨论还有很多内容未涉及。要了解更多关于 Pandas 的信息,我推荐以下资源:

Pandas 在线文档

这是完整文档的首选来源。虽然文档中的示例通常基于小型生成的数据集,但选项的描述是全面的,并且通常非常有助于理解各种函数的使用。

Python for Data Analysis

由 Pandas 的原始创建者 Wes McKinney 撰写,这本书包含了比我们在本章中有空间讨论的 Pandas 包更多的细节。特别是,McKinney 深入探讨了用于时间序列的工具,这些工具是他作为金融顾问的核心内容。这本书还包含许多将 Pandas 应用于从实际数据集中获得洞察的有趣例子。

Effective Pandas

Pandas 开发者 Tom Augspurger 的这本简短电子书,简洁地概述了如何有效和惯用地使用 Pandas 库的全部功能。

PyVideo 上的 Pandas

从 PyCon 到 SciPy 再到 PyData,许多会议都有 Pandas 开发者和高级用户提供的教程。特别是 PyCon 的教程通常由经过严格筛选的优秀演讲者提供。

结合这些资源,再加上这些章节中的详细介绍,我希望你能够准备好使用 Pandas 解决任何遇到的数据分析问题!