Python 数据分析(PYDA)第三版(五)

原文:wesmckinney.com/book/

译者:飞龙

协议:CC BY-NC-SA 4.0

十、数据聚合和组操作

原文:wesmckinney.com/book/data-aggregation

译者:飞龙

协议:CC BY-NC-SA 4.0
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。

如果您发现本书的在线版本有用,请考虑订购纸质版无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。

对数据集进行分类并对每个组应用函数,无论是聚合还是转换,都可能是数据分析工作流程的关键组成部分。加载、合并和准备数据集后,您可能需要计算组统计信息或可能需要为报告或可视化目的计算数据透视表 。pandas 提供了一个多功能的groupby接口,使您能够以自然的方式切片、切块和总结数据集。

关系数据库和 SQL(结构化查询语言)的流行原因之一是数据可以很容易地进行连接、过滤、转换和聚合。然而,像 SQL 这样的查询语言对可以执行的组操作类型施加了一定的限制。正如您将看到的,借助 Python 和 pandas 的表达力,我们可以通过将它们表达为自定义 Python 函数来执行相当复杂的组操作,这些函数操作与每个组相关联的数据。在本章中,您将学习如何:

  • 使用一个或多个键(以函数、数组或 DataFrame 列名的形式)将 pandas 对象分成片段

  • 计算组摘要统计信息,如计数、均值或标准差,或用户定义的函数

  • 应用组内转换或其他操作,如归一化、线性回归、排名或子集选择

  • 计算数据透视表和交叉制表

  • 执行分位数分析和其他统计组分析

注意

对时间序列数据进行基于时间的聚合,是groupby的一个特殊用例,在本书中被称为重新采样,将在第十一章:时间序列中单独处理。*与其他章节一样,我们首先导入 NumPy 和 pandas:

py 复制代码
In [12]: import numpy as np

In [13]: import pandas as pd

10.1 如何思考组操作

Hadley Wickham,R 编程语言许多流行包的作者,为描述组操作创造了术语split-apply-combine 。在过程的第一阶段中,包含在 pandas 对象中的数据,无论是 Series、DataFrame 还是其他形式,都根据您提供的一个或多个分割 成组。分割是在对象的特定轴上执行的。例如,DataFrame 可以根据其行(axis="index")或列(axis="columns")进行分组。完成此操作后,将应用 一个函数到每个组,生成一个新值。最后,所有这些函数应用的结果将合并成一个结果对象。结果对象的形式通常取决于对数据的操作。请参见图 10.1 以查看简单组聚合的模拟。

每个分组键可以采用多种形式,键不必是相同类型的:

  • 一个与被分组的轴长度相同的值列表或数组

  • DataFrame 中表示列名的值

  • 一个字典或 Series,给出了被分组的轴上的值与组名之间的对应关系

  • 要在轴索引或索引中的个别标签上调用的函数

图 10.1:组聚合的示例

请注意,后三种方法是用于生成用于拆分对象的值数组的快捷方式。如果这一切看起来很抽象,不要担心。在本章中,我将给出所有这些方法的许多示例。为了开始,这里是一个作为 DataFrame 的小表格数据集:

py 复制代码
In [14]: df = pd.DataFrame({"key1" : ["a", "a", None, "b", "b", "a", None],
 ....:                    "key2" : pd.Series([1, 2, 1, 2, 1, None, 1],
 ....:                                       dtype="Int64"),
 ....:                    "data1" : np.random.standard_normal(7),
 ....:                    "data2" : np.random.standard_normal(7)})

In [15]: df
Out[15]: 
 key1  key2     data1     data2
0     a     1 -0.204708  0.281746
1     a     2  0.478943  0.769023
2  None     1 -0.519439  1.246435
3     b     2 -0.555730  1.007189
4     b     1  1.965781 -1.296221
5     a  <NA>  1.393406  0.274992
6  None     1  0.092908  0.228913

假设你想使用 key1 标签计算 data1 列的均值。有多种方法可以做到这一点。一种方法是访问 data1 并使用 key1 列(一个 Series)调用 groupby

py 复制代码
In [16]: grouped = df["data1"].groupby(df["key1"])

In [17]: grouped
Out[17]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x17b7913f0>

这个 grouped 变量现在是一个特殊的 "GroupBy" 对象。除了一些关于组键 df["key1"] 的中间数据之外,它实际上还没有计算任何东西。这个对象的想法是它包含了对每个组应用某些操作所需的所有信息。例如,要计算组均值,我们可以调用 GroupBy 的 mean 方法:

py 复制代码
In [18]: grouped.mean()
Out[18]: 
key1
a    0.555881
b    0.705025
Name: data1, dtype: float64

稍后在 数据聚合 中,我将更详细地解释当你调用 .mean() 时会发生什么。这里重要的是,数据(一个 Series)已经通过在组键上拆分数据进行聚合,产生了一个新的 Series,现在由 key1 列中的唯一值进行索引。结果索引的名称是 "key1",因为 DataFrame 列 df["key1"] 是这样的。

如果我们传递了多个数组作为列表,将会得到不同的结果:

py 复制代码
In [19]: means = df["data1"].groupby([df["key1"], df["key2"]]).mean()

In [20]: means
Out[20]: 
key1  key2
a     1      -0.204708
 2       0.478943
b     1       1.965781
 2      -0.555730
Name: data1, dtype: float64

在这里,我们使用两个键对数据进行分组,结果 Series 现在具有由观察到的唯一键对组成的分层索引:

py 复制代码
In [21]: means.unstack()
Out[21]: 
key2         1         2
key1 
a    -0.204708  0.478943
b     1.965781 -0.555730

在这个例子中,组键都是 Series,尽管它们可以是任何正确长度的数组:

py 复制代码
In [22]: states = np.array(["OH", "CA", "CA", "OH", "OH", "CA", "OH"])

In [23]: years = [2005, 2005, 2006, 2005, 2006, 2005, 2006]

In [24]: df["data1"].groupby([states, years]).mean()
Out[24]: 
CA  2005    0.936175
 2006   -0.519439
OH  2005   -0.380219
 2006    1.029344
Name: data1, dtype: float64

通常,分组信息在与你要处理的数据相同的 DataFrame 中找到。在这种情况下,你可以将列名(无论是字符串、数字还是其他 Python 对象)作为组键传递:

py 复制代码
In [25]: df.groupby("key1").mean()
Out[25]: 
 key2     data1     data2
key1 
a      1.5  0.555881  0.441920
b      1.5  0.705025 -0.144516

In [26]: df.groupby("key2").mean(numeric_only=True)
Out[26]: 
 data1     data2
key2 
1     0.333636  0.115218
2    -0.038393  0.888106

In [27]: df.groupby(["key1", "key2"]).mean()
Out[27]: 
 data1     data2
key1 key2 
a    1    -0.204708  0.281746
 2     0.478943  0.769023
b    1     1.965781 -1.296221
 2    -0.555730  1.007189

你可能会注意到,在第二种情况下,有必要传递 numeric_only=True,因为 key1 列不是数值列,因此不能使用 mean() 进行聚合。

无论使用 groupby 的目的是什么,一个通常有用的 GroupBy 方法是 size,它返回一个包含组大小的 Series:

py 复制代码
In [28]: df.groupby(["key1", "key2"]).size()
Out[28]: 
key1  key2
a     1       1
 2       1
b     1       1
 2       1
dtype: int64

请注意,默认情况下,组键中的任何缺失值都会被排除在结果之外。通过将 dropna=False 传递给 groupby 可以禁用此行为:

py 复制代码
In [29]: df.groupby("key1", dropna=False).size()
Out[29]: 
key1
a      3
b      2
NaN    2
dtype: int64

In [30]: df.groupby(["key1", "key2"], dropna=False).size()
Out[30]: 
key1  key2
a     1       1
 2       1
 <NA>    1
b     1       1
 2       1
NaN   1       2
dtype: int64

一种类似于 size 的组函数是 count,它计算每个组中的非空值的数量:

py 复制代码
In [31]: df.groupby("key1").count()
Out[31]: 
 key2  data1  data2
key1 
a        2      3      3
b        2      2      2

遍历组

groupby 返回的对象支持迭代,生成一个包含组名和数据块的 2 元组序列。考虑以下内容:

py 复制代码
In [32]: for name, group in df.groupby("key1"):
 ....:     print(name)
 ....:     print(group)
 ....:
a
 key1  key2     data1     data2
0    a     1 -0.204708  0.281746
1    a     2  0.478943  0.769023
5    a  <NA>  1.393406  0.274992
b
 key1  key2     data1     data2
3    b     2 -0.555730  1.007189
4    b     1  1.965781 -1.296221

在多个键的情况下,元组中的第一个元素将是一个键值的元组:

py 复制代码
In [33]: for (k1, k2), group in df.groupby(["key1", "key2"]):
 ....:     print((k1, k2))
 ....:     print(group)
 ....:
('a', 1)
 key1  key2     data1     data2
0    a     1 -0.204708  0.281746
('a', 2)
 key1  key2     data1     data2
1    a     2  0.478943  0.769023
('b', 1)
 key1  key2     data1     data2
4    b     1  1.965781 -1.296221
('b', 2)
 key1  key2    data1     data2
3    b     2 -0.55573  1.007189

当然,你可以选择对数据块做任何你想做的事情。一个你可能会发现有用的方法是将数据块计算为一个字典:

py 复制代码
In [34]: pieces = {name: group for name, group in df.groupby("key1")}

In [35]: pieces["b"]
Out[35]: 
 key1  key2     data1     data2
3    b     2 -0.555730  1.007189
4    b     1  1.965781 -1.296221

默认情况下,groupbyaxis="index" 上进行分组,但你可以在任何其他轴上进行分组。例如,我们可以按照我们的示例 df 的列是否以 "key""data" 开头进行分组:

py 复制代码
In [36]: grouped = df.groupby({"key1": "key", "key2": "key",
 ....:                       "data1": "data", "data2": "data"}, axis="columns")

我们可以这样打印出组:

py 复制代码
In [37]: for group_key, group_values in grouped:
 ....:     print(group_key)
 ....:     print(group_values)
 ....:
data
 data1     data2
0 -0.204708  0.281746
1  0.478943  0.769023
2 -0.519439  1.246435
3 -0.555730  1.007189
4  1.965781 -1.296221
5  1.393406  0.274992
6  0.092908  0.228913
key
 key1  key2
0     a     1
1     a     2
2  None     1
3     b     2
4     b     1
5     a  <NA>
6  None     1

选择列或列的子集

从 DataFrame 创建的 GroupBy 对象进行索引,使用列名或列名数组会对聚合进行列子集操作。这意味着:

py 复制代码
df.groupby("key1")["data1"]
df.groupby("key1")[["data2"]]

是方便的:

py 复制代码
df["data1"].groupby(df["key1"])
df[["data2"]].groupby(df["key1"])

特别是对于大型数据集,可能只需要聚合几列。例如,在前面的数据集中,仅计算 data2 列的均值并将结果作为 DataFrame 获取,我们可以这样写:

py 复制代码
In [38]: df.groupby(["key1", "key2"])[["data2"]].mean()
Out[38]: 
 data2
key1 key2 
a    1     0.281746
 2     0.769023
b    1    -1.296221
 2     1.007189

通过这种索引操作返回的对象是一个分组的 DataFrame(如果传递了列表或数组),或者是一个分组的 Series(如果只传递了一个列名作为标量):

py 复制代码
In [39]: s_grouped = df.groupby(["key1", "key2"])["data2"]

In [40]: s_grouped
Out[40]: <pandas.core.groupby.generic.SeriesGroupBy object at 0x17b8356c0>

In [41]: s_grouped.mean()
Out[41]: 
key1  key2
a     1       0.281746
 2       0.769023
b     1      -1.296221
 2       1.007189
Name: data2, dtype: float64

使用字典和 Series 进行分组

分组信息可能以其他形式存在,而不仅仅是数组。让我们考虑另一个示例 DataFrame:

py 复制代码
In [42]: people = pd.DataFrame(np.random.standard_normal((5, 5)),
 ....:                       columns=["a", "b", "c", "d", "e"],
 ....:                       index=["Joe", "Steve", "Wanda", "Jill", "Trey"])

In [43]: people.iloc[2:3, [1, 2]] = np.nan # Add a few NA values

In [44]: people
Out[44]: 
 a         b         c         d         e
Joe    1.352917  0.886429 -2.001637 -0.371843  1.669025
Steve -0.438570 -0.539741  0.476985  3.248944 -1.021228
Wanda -0.577087       NaN       NaN  0.523772  0.000940
Jill   1.343810 -0.713544 -0.831154 -2.370232 -1.860761
Trey  -0.860757  0.560145 -1.265934  0.119827 -1.063512

现在,假设我有列的分组对应关系,并且想要按组对列求和:

py 复制代码
In [45]: mapping = {"a": "red", "b": "red", "c": "blue",
 ....:            "d": "blue", "e": "red", "f" : "orange"}

现在,您可以从这个字典构造一个数组传递给groupby,但我们可以直接传递字典(我包含了键"f"来突出显示未使用的分组键是可以的):

py 复制代码
In [46]: by_column = people.groupby(mapping, axis="columns")

In [47]: by_column.sum()
Out[47]: 
 blue       red
Joe   -2.373480  3.908371
Steve  3.725929 -1.999539
Wanda  0.523772 -0.576147
Jill  -3.201385 -1.230495
Trey  -1.146107 -1.364125

相同的功能也适用于 Series,它可以被视为一个固定大小的映射:

py 复制代码
In [48]: map_series = pd.Series(mapping)

In [49]: map_series
Out[49]: 
a       red
b       red
c      blue
d      blue
e       red
f    orange
dtype: object

In [50]: people.groupby(map_series, axis="columns").count()
Out[50]: 
 blue  red
Joe       2    3
Steve     2    3
Wanda     1    2
Jill      2    3
Trey      2    3

使用函数分组

使用 Python 函数比使用字典或 Series 定义分组映射更通用。作为分组键传递的任何函数将针对每个索引值(或者如果使用axis="columns"则是每个列值)调用一次,返回值将用作分组名称。更具体地,考虑前一节中的示例 DataFrame,其中人们的名字作为索引值。假设您想按名称长度分组。虽然您可以计算一个字符串长度的数组,但更简单的方法是只传递len函数:

py 复制代码
In [51]: people.groupby(len).sum()
Out[51]: 
 a         b         c         d         e
3  1.352917  0.886429 -2.001637 -0.371843  1.669025
4  0.483052 -0.153399 -2.097088 -2.250405 -2.924273
5 -1.015657 -0.539741  0.476985  3.772716 -1.020287

将函数与数组、字典或 Series 混合在一起不是问题,因为所有内容在内部都会转换为数组:

py 复制代码
In [52]: key_list = ["one", "one", "one", "two", "two"]

In [53]: people.groupby([len, key_list]).min()
Out[53]: 
 a         b         c         d         e
3 one  1.352917  0.886429 -2.001637 -0.371843  1.669025
4 two -0.860757 -0.713544 -1.265934 -2.370232 -1.860761
5 one -0.577087 -0.539741  0.476985  0.523772 -1.021228

按索引级别分组

对于具有层次索引的数据集,最后一个便利之处是能够使用轴索引的一个级别进行聚合。让我们看一个例子:

py 复制代码
In [54]: columns = pd.MultiIndex.from_arrays([["US", "US", "US", "JP", "JP"],
 ....:                                     [1, 3, 5, 1, 3]],
 ....:                                     names=["cty", "tenor"])

In [55]: hier_df = pd.DataFrame(np.random.standard_normal((4, 5)), columns=column
s)

In [56]: hier_df
Out[56]: 
cty          US                            JP 
tenor         1         3         5         1         3
0      0.332883 -2.359419 -0.199543 -1.541996 -0.970736
1     -1.307030  0.286350  0.377984 -0.753887  0.331286
2      1.349742  0.069877  0.246674 -0.011862  1.004812
3      1.327195 -0.919262 -1.549106  0.022185  0.758363

要按级别分组,请使用level关键字传递级别编号或名称:

py 复制代码
In [57]: hier_df.groupby(level="cty", axis="columns").count()
Out[57]: 
cty  JP  US
0     2   3
1     2   3
2     2   3
3     2   3

10.2 数据聚合

聚合 指的是从数组中产生标量值的任何数据转换。前面的示例中使用了其中几个,包括meancountminsum。当您在 GroupBy 对象上调用mean()时,您可能会想知道发生了什么。许多常见的聚合,如表 10.1 中找到的那些,都有优化的实现。但是,您不仅限于这组方法。

表 10.1:优化的groupby方法

函数名称 描述
any, all 如果任何(一个或多个值)或所有非 NA 值为"真值"则返回True
count 非 NA 值的数量
cummin, cummax 非 NA 值的累积最小值和最大值
cumsum 非 NA 值的累积和
cumprod 非 NA 值的累积乘积
first, last 首个和最后一个非 NA 值
mean 非 NA 值的均值
median 非 NA 值的算术中位数
min, max 非 NA 值的最小值和最大值
nth 检索在排序顺序中出现在位置n的值
ohlc 为类似时间序列的数据计算四个"开盘-最高-最低-收盘"统计数据
prod 非 NA 值的乘积
quantile 计算样本分位数
rank 非 NA 值的序数排名,类似于调用Series.rank
size 计算组大小,将结果返回为 Series
sum 非 NA 值的总和
std, var 样本标准差和方差

您可以使用自己设计的聚合,并额外调用任何也在被分组对象上定义的方法。例如,nsmallest Series 方法从数据中选择请求的最小数量的值。虽然nsmallest没有明确为 GroupBy 实现,但我们仍然可以使用它与非优化的实现。在内部,GroupBy 将 Series 切片,为每个片段调用piece.nsmallest(n),然后将这些结果组装成结果对象:

py 复制代码
In [58]: df
Out[58]: 
 key1  key2     data1     data2
0     a     1 -0.204708  0.281746
1     a     2  0.478943  0.769023
2  None     1 -0.519439  1.246435
3     b     2 -0.555730  1.007189
4     b     1  1.965781 -1.296221
5     a  <NA>  1.393406  0.274992
6  None     1  0.092908  0.228913

In [59]: grouped = df.groupby("key1")

In [60]: grouped["data1"].nsmallest(2)
Out[60]: 
key1 
a     0   -0.204708
 1    0.478943
b     3   -0.555730
 4    1.965781
Name: data1, dtype: float64

要使用自己的聚合函数,只需将任何聚合数组的函数传递给aggregate方法或其简短别名agg

py 复制代码
In [61]: def peak_to_peak(arr):
 ....:     return arr.max() - arr.min()

In [62]: grouped.agg(peak_to_peak)
Out[62]: 
 key2     data1     data2
key1 
a        1  1.598113  0.494031
b        1  2.521511  2.303410

您可能会注意到一些方法,比如describe,即使严格来说它们不是聚合也可以工作:

py 复制代码
In [63]: grouped.describe()
Out[63]: 
 key2                                           data1            ... 
 count mean       std  min   25%  50%   75%  max count      mean  ... 
key1                                                                  ... 
a      2.0  1.5  0.707107  1.0  1.25  1.5  1.75  2.0   3.0  0.555881  ...  \
b      2.0  1.5  0.707107  1.0  1.25  1.5  1.75  2.0   2.0  0.705025  ... 
 data2 
 75%       max count      mean       std       min       25% 
key1 
a     0.936175  1.393406   3.0  0.441920  0.283299  0.274992  0.278369  \
b     1.335403  1.965781   2.0 -0.144516  1.628757 -1.296221 -0.720368 

 50%       75%       max 
key1 
a     0.281746  0.525384  0.769023 
b    -0.144516  0.431337  1.007189 
[2 rows x 24 columns]

我将在应用:通用的分割-应用-合并中更详细地解释这里发生了什么。

注意

自定义聚合函数通常比在 Table 10.1 中找到的优化函数慢得多。这是因为在构建中间组数据块时存在一些额外开销(函数调用,数据重新排列)*### 按列和多函数应用

让我们回到上一章中使用的小费数据集。在使用pandas.read_csv加载后,我们添加一个小费百分比列:

py 复制代码
In [64]: tips = pd.read_csv("examples/tips.csv")

In [65]: tips.head()
Out[65]: 
 total_bill   tip smoker  day    time  size
0       16.99  1.01     No  Sun  Dinner     2
1       10.34  1.66     No  Sun  Dinner     3
2       21.01  3.50     No  Sun  Dinner     3
3       23.68  3.31     No  Sun  Dinner     2
4       24.59  3.61     No  Sun  Dinner     4

现在我将添加一个tip_pct列,其中包含总账单的小费百分比:

py 复制代码
In [66]: tips["tip_pct"] = tips["tip"] / tips["total_bill"]

In [67]: tips.head()
Out[67]: 
 total_bill   tip smoker  day    time  size   tip_pct
0       16.99  1.01     No  Sun  Dinner     2  0.059447
1       10.34  1.66     No  Sun  Dinner     3  0.160542
2       21.01  3.50     No  Sun  Dinner     3  0.166587
3       23.68  3.31     No  Sun  Dinner     2  0.139780
4       24.59  3.61     No  Sun  Dinner     4  0.146808

正如您已经看到的,聚合 Series 或 DataFrame 的所有列是使用aggregate(或agg)与所需函数或调用meanstd方法的问题。但是,您可能希望根据列使用不同的函数进行聚合,或者一次使用多个函数。幸运的是,这是可能的,我将通过一些示例来说明。首先,我将按daysmokertips进行分组:

py 复制代码
In [68]: grouped = tips.groupby(["day", "smoker"])

请注意,对于像 Table 10.1 中的描述性统计数据,您可以将函数的名称作为字符串传递:

py 复制代码
In [69]: grouped_pct = grouped["tip_pct"]

In [70]: grouped_pct.agg("mean")
Out[70]: 
day   smoker
Fri   No        0.151650
 Yes       0.174783
Sat   No        0.158048
 Yes       0.147906
Sun   No        0.160113
 Yes       0.187250
Thur  No        0.160298
 Yes       0.163863
Name: tip_pct, dtype: float64

如果您传递的是函数或函数名称的列表,您将获得一个列名从函数中获取的 DataFrame:

py 复制代码
In [71]: grouped_pct.agg(["mean", "std", peak_to_peak])
Out[71]: 
 mean       std  peak_to_peak
day  smoker 
Fri  No      0.151650  0.028123      0.067349
 Yes     0.174783  0.051293      0.159925
Sat  No      0.158048  0.039767      0.235193
 Yes     0.147906  0.061375      0.290095
Sun  No      0.160113  0.042347      0.193226
 Yes     0.187250  0.154134      0.644685
Thur No      0.160298  0.038774      0.193350
 Yes     0.163863  0.039389      0.151240

在这里,我们将一系列聚合函数传递给agg,以独立评估数据组。

您不需要接受 GroupBy 为列提供的名称;特别是,lambda函数的名称为"<lambda>",这使得它们难以识别(您可以通过查看函数的__name__属性来自行查看)。因此,如果您传递一个(name, function)元组的列表,每个元组的第一个元素将被用作 DataFrame 列名(您可以将 2 元组的列表视为有序映射):

py 复制代码
In [72]: grouped_pct.agg([("average", "mean"), ("stdev", np.std)])
Out[72]: 
 average     stdev
day  smoker 
Fri  No      0.151650  0.028123
 Yes     0.174783  0.051293
Sat  No      0.158048  0.039767
 Yes     0.147906  0.061375
Sun  No      0.160113  0.042347
 Yes     0.187250  0.154134
Thur No      0.160298  0.038774
 Yes     0.163863  0.039389

使用 DataFrame,您有更多的选项,因为您可以指定要应用于所有列或不同列的不同函数的函数列表。首先,假设我们想要计算tip_pcttotal_bill列的相同三个统计数据:

py 复制代码
In [73]: functions = ["count", "mean", "max"]

In [74]: result = grouped[["tip_pct", "total_bill"]].agg(functions)

In [75]: result
Out[75]: 
 tip_pct                     total_bill 
 count      mean       max      count       mean    max
day  smoker 
Fri  No           4  0.151650  0.187735          4  18.420000  22.75
 Yes         15  0.174783  0.263480         15  16.813333  40.17
Sat  No          45  0.158048  0.291990         45  19.661778  48.33
 Yes         42  0.147906  0.325733         42  21.276667  50.81
Sun  No          57  0.160113  0.252672         57  20.506667  48.17
 Yes         19  0.187250  0.710345         19  24.120000  45.35
Thur No          45  0.160298  0.266312         45  17.113111  41.19
 Yes         17  0.163863  0.241255         17  19.190588  43.11

如您所见,生成的 DataFrame 具有分层列,与分别聚合每列并使用列名作为keys参数使用concat粘合结果时获得的结果相同:

py 复制代码
In [76]: result["tip_pct"]
Out[76]: 
 count      mean       max
day  smoker 
Fri  No          4  0.151650  0.187735
 Yes        15  0.174783  0.263480
Sat  No         45  0.158048  0.291990
 Yes        42  0.147906  0.325733
Sun  No         57  0.160113  0.252672
 Yes        19  0.187250  0.710345
Thur No         45  0.160298  0.266312
 Yes        17  0.163863  0.241255

与以前一样,可以传递具有自定义名称的元组列表:

py 复制代码
In [77]: ftuples = [("Average", "mean"), ("Variance", np.var)]

In [78]: grouped[["tip_pct", "total_bill"]].agg(ftuples)
Out[78]: 
 tip_pct           total_bill 
 Average  Variance    Average    Variance
day  smoker 
Fri  No      0.151650  0.000791  18.420000   25.596333
 Yes     0.174783  0.002631  16.813333   82.562438
Sat  No      0.158048  0.001581  19.661778   79.908965
 Yes     0.147906  0.003767  21.276667  101.387535
Sun  No      0.160113  0.001793  20.506667   66.099980
 Yes     0.187250  0.023757  24.120000  109.046044
Thur No      0.160298  0.001503  17.113111   59.625081
 Yes     0.163863  0.001551  19.190588   69.808518

现在,假设您想要对一个或多个列应用可能不同的函数。为此,请将包含列名到迄今为止列出的任何函数规范的映射的字典传递给agg

py 复制代码
In [79]: grouped.agg({"tip" : np.max, "size" : "sum"})
Out[79]: 
 tip  size
day  smoker 
Fri  No       3.50     9
 Yes      4.73    31
Sat  No       9.00   115
 Yes     10.00   104
Sun  No       6.00   167
 Yes      6.50    49
Thur No       6.70   112
 Yes      5.00    40

In [80]: grouped.agg({"tip_pct" : ["min", "max", "mean", "std"],
 ....:              "size" : "sum"})
Out[80]: 
 tip_pct                               size
 min       max      mean       std  sum
day  smoker 
Fri  No      0.120385  0.187735  0.151650  0.028123    9
 Yes     0.103555  0.263480  0.174783  0.051293   31
Sat  No      0.056797  0.291990  0.158048  0.039767  115
 Yes     0.035638  0.325733  0.147906  0.061375  104
Sun  No      0.059447  0.252672  0.160113  0.042347  167
 Yes     0.065660  0.710345  0.187250  0.154134   49
Thur No      0.072961  0.266312  0.160298  0.038774  112
 Yes     0.090014  0.241255  0.163863  0.039389   40

只有在至少对一列应用多个函数时,DataFrame 才会具有分层列。

返回不带行索引的聚合数据

到目前为止的所有示例中,聚合数据都带有一个索引,可能是分层的,由唯一的组键组合组成。由于这并不总是理想的,您可以通过在大多数情况下将as_index=False传递给groupby来禁用此行为:

py 复制代码
In [81]: grouped = tips.groupby(["day", "smoker"], as_index=False)

In [82]: grouped.mean(numeric_only=True)
Out[82]: 
 day smoker  total_bill       tip      size   tip_pct
0   Fri     No   18.420000  2.812500  2.250000  0.151650
1   Fri    Yes   16.813333  2.714000  2.066667  0.174783
2   Sat     No   19.661778  3.102889  2.555556  0.158048
3   Sat    Yes   21.276667  2.875476  2.476190  0.147906
4   Sun     No   20.506667  3.167895  2.929825  0.160113
5   Sun    Yes   24.120000  3.516842  2.578947  0.187250
6  Thur     No   17.113111  2.673778  2.488889  0.160298
7  Thur    Yes   19.190588  3.030000  2.352941  0.163863

当然,通过在结果上调用reset_index,总是可以以这种格式获得结果。使用as_index=False参数可以避免一些不必要的计算。*## 10.3 应用:通用的分割-应用-合并

最通用的 GroupBy 方法是apply,这是本节的主题。apply将被操作的对象分割成片段,对每个片段调用传递的函数,然后尝试连接这些片段。

回到以前的小费数据集,假设您想要按组选择前五个tip_pct值。首先,编写一个函数,该函数选择特定列中最大值的行:

py 复制代码
In [83]: def top(df, n=5, column="tip_pct"):
 ....:     return df.sort_values(column, ascending=False)[:n]

In [84]: top(tips, n=6)
Out[84]: 
 total_bill   tip smoker  day    time  size   tip_pct
172        7.25  5.15    Yes  Sun  Dinner     2  0.710345
178        9.60  4.00    Yes  Sun  Dinner     2  0.416667
67         3.07  1.00    Yes  Sat  Dinner     1  0.325733
232       11.61  3.39     No  Sat  Dinner     2  0.291990
183       23.17  6.50    Yes  Sun  Dinner     4  0.280535
109       14.31  4.00    Yes  Sat  Dinner     2  0.279525

现在,如果我们按smoker分组,并使用此函数调用apply,我们将得到以下结果:

py 复制代码
In [85]: tips.groupby("smoker").apply(top)
Out[85]: 
 total_bill   tip smoker   day    time  size   tip_pct
smoker 
No     232       11.61  3.39     No   Sat  Dinner     2  0.291990
 149        7.51  2.00     No  Thur   Lunch     2  0.266312
 51        10.29  2.60     No   Sun  Dinner     2  0.252672
 185       20.69  5.00     No   Sun  Dinner     5  0.241663
 88        24.71  5.85     No  Thur   Lunch     2  0.236746
Yes    172        7.25  5.15    Yes   Sun  Dinner     2  0.710345
 178        9.60  4.00    Yes   Sun  Dinner     2  0.416667
 67         3.07  1.00    Yes   Sat  Dinner     1  0.325733
 183       23.17  6.50    Yes   Sun  Dinner     4  0.280535
 109       14.31  4.00    Yes   Sat  Dinner     2  0.279525

这里发生了什么?首先,根据smoker的值将tips DataFrame 分成组。然后在每个组上调用top函数,并使用pandas.concat将每个函数调用的结果粘合在一起,用组名标记各个部分。因此,结果具有一个具有内部级别的分层索引,该级别包含原始 DataFrame 的索引值。

如果您将一个接受其他参数或关键字的函数传递给apply,则可以在函数之后传递这些参数:

py 复制代码
In [86]: tips.groupby(["smoker", "day"]).apply(top, n=1, column="total_bill")
Out[86]: 
 total_bill    tip smoker   day    time  size   tip_pct
smoker day 
No     Fri  94        22.75   3.25     No   Fri  Dinner     2  0.142857
 Sat  212       48.33   9.00     No   Sat  Dinner     4  0.186220
 Sun  156       48.17   5.00     No   Sun  Dinner     6  0.103799
 Thur 142       41.19   5.00     No  Thur   Lunch     5  0.121389
Yes    Fri  95        40.17   4.73    Yes   Fri  Dinner     4  0.117750
 Sat  170       50.81  10.00    Yes   Sat  Dinner     3  0.196812
 Sun  182       45.35   3.50    Yes   Sun  Dinner     3  0.077178
 Thur 197       43.11   5.00    Yes  Thur   Lunch     4  0.115982

除了这些基本的使用机制外,要充分利用apply可能需要一些创造力。传递的函数内部发生的事情取决于你;它必须返回一个 pandas 对象或一个标量值。本章的其余部分主要将包含示例,向您展示如何使用groupby解决各种问题。

例如,你可能还记得我之前在 GroupBy 对象上调用describe

py 复制代码
In [87]: result = tips.groupby("smoker")["tip_pct"].describe()

In [88]: result
Out[88]: 
 count      mean       std       min       25%       50%       75% 
smoker 
No      151.0  0.159328  0.039910  0.056797  0.136906  0.155625  0.185014  \
Yes      93.0  0.163196  0.085119  0.035638  0.106771  0.153846  0.195059 
 max 
smoker 
No      0.291990 
Yes     0.710345 

In [89]: result.unstack("smoker")
Out[89]: 
 smoker
count  No        151.000000
 Yes        93.000000
mean   No          0.159328
 Yes         0.163196
std    No          0.039910
 Yes         0.085119
min    No          0.056797
 Yes         0.035638
25%    No          0.136906
 Yes         0.106771
50%    No          0.155625
 Yes         0.153846
75%    No          0.185014
 Yes         0.195059
max    No          0.291990
 Yes         0.710345
dtype: float64

在 GroupBy 中,当你调用像describe这样的方法时,实际上只是一个快捷方式:

py 复制代码
def f(group):
 return group.describe()

grouped.apply(f)

抑制组键

在前面的示例中,您可以看到生成的对象具有从组键形成的分层索引,以及原始对象的每个部分的索引。您可以通过将group_keys=False传递给groupby来禁用这一点:

py 复制代码
In [90]: tips.groupby("smoker", group_keys=False).apply(top)
Out[90]: 
 total_bill   tip smoker   day    time  size   tip_pct
232       11.61  3.39     No   Sat  Dinner     2  0.291990
149        7.51  2.00     No  Thur   Lunch     2  0.266312
51        10.29  2.60     No   Sun  Dinner     2  0.252672
185       20.69  5.00     No   Sun  Dinner     5  0.241663
88        24.71  5.85     No  Thur   Lunch     2  0.236746
172        7.25  5.15    Yes   Sun  Dinner     2  0.710345
178        9.60  4.00    Yes   Sun  Dinner     2  0.416667
67         3.07  1.00    Yes   Sat  Dinner     1  0.325733
183       23.17  6.50    Yes   Sun  Dinner     4  0.280535
109       14.31  4.00    Yes   Sat  Dinner     2  0.279525

分位数和桶分析

正如你可能从第八章:数据整理:连接、合并和重塑中记得的那样,pandas 有一些工具,特别是pandas.cutpandas.qcut,可以将数据切分成您选择的桶或样本分位数。将这些函数与groupby结合起来,可以方便地对数据集进行桶或分位数分析。考虑一个简单的随机数据集和使用pandas.cut进行等长度桶分类:

py 复制代码
In [91]: frame = pd.DataFrame({"data1": np.random.standard_normal(1000),
 ....:                       "data2": np.random.standard_normal(1000)})

In [92]: frame.head()
Out[92]: 
 data1     data2
0 -0.660524 -0.612905
1  0.862580  0.316447
2 -0.010032  0.838295
3  0.050009 -1.034423
4  0.670216  0.434304

In [93]: quartiles = pd.cut(frame["data1"], 4)

In [94]: quartiles.head(10)
Out[94]: 
0     (-1.23, 0.489]
1     (0.489, 2.208]
2     (-1.23, 0.489]
3     (-1.23, 0.489]
4     (0.489, 2.208]
5     (0.489, 2.208]
6     (-1.23, 0.489]
7     (-1.23, 0.489]
8    (-2.956, -1.23]
9     (-1.23, 0.489]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-2.956, -1.23] < (-1.23, 0.489] < (0.
489, 2.208] <
 (2.208, 3.928]]

cut返回的Categorical对象可以直接传递给groupby。因此,我们可以计算四分位数的一组组统计信息,如下所示:

py 复制代码
In [95]: def get_stats(group):
 ....:     return pd.DataFrame(
 ....:         {"min": group.min(), "max": group.max(),
 ....:         "count": group.count(), "mean": group.mean()}
 ....:     )

In [96]: grouped = frame.groupby(quartiles)

In [97]: grouped.apply(get_stats)
Out[97]: 
 min       max  count      mean
data1 
(-2.956, -1.23] data1 -2.949343 -1.230179     94 -1.658818
 data2 -3.399312  1.670835     94 -0.033333
(-1.23, 0.489]  data1 -1.228918  0.488675    598 -0.329524
 data2 -2.989741  3.260383    598 -0.002622
(0.489, 2.208]  data1  0.489965  2.200997    298  1.065727
 data2 -3.745356  2.954439    298  0.078249
(2.208, 3.928]  data1  2.212303  3.927528     10  2.644253
 data2 -1.929776  1.765640     10  0.024750

请记住,同样的结果可以更简单地计算为:

py 复制代码
In [98]: grouped.agg(["min", "max", "count", "mean"])
Out[98]: 
 data1                               data2 
 min       max count      mean       min       max count 
data1 
(-2.956, -1.23] -2.949343 -1.230179    94 -1.658818 -3.399312  1.670835    94  \
(-1.23, 0.489]  -1.228918  0.488675   598 -0.329524 -2.989741  3.260383   598 
(0.489, 2.208]   0.489965  2.200997   298  1.065727 -3.745356  2.954439   298 
(2.208, 3.928]   2.212303  3.927528    10  2.644253 -1.929776  1.765640    10 

 mean 
data1 
(-2.956, -1.23] -0.033333 
(-1.23, 0.489]  -0.002622 
(0.489, 2.208]   0.078249 
(2.208, 3.928]   0.024750 

这些是等长度的桶;要基于样本分位数计算等大小的桶,使用pandas.qcut。我们可以将4作为桶的数量计算样本四分位数,并传递labels=False以仅获取四分位数索引而不是间隔:

py 复制代码
In [99]: quartiles_samp = pd.qcut(frame["data1"], 4, labels=False)

In [100]: quartiles_samp.head()
Out[100]: 
0    1
1    3
2    2
3    2
4    3
Name: data1, dtype: int64

In [101]: grouped = frame.groupby(quartiles_samp)

In [102]: grouped.apply(get_stats)
Out[102]: 
 min       max  count      mean
data1 
0     data1 -2.949343 -0.685484    250 -1.212173
 data2 -3.399312  2.628441    250 -0.027045
1     data1 -0.683066 -0.030280    250 -0.368334
 data2 -2.630247  3.260383    250 -0.027845
2     data1 -0.027734  0.618965    250  0.295812
 data2 -3.056990  2.458842    250  0.014450
3     data1  0.623587  3.927528    250  1.248875
 data2 -3.745356  2.954439    250  0.115899

示例:使用组特定值填充缺失值

在清理缺失数据时,有些情况下您将使用dropna删除数据观察值,但在其他情况下,您可能希望使用固定值或从数据中派生的某个值填充空(NA)值。fillna是正确的工具;例如,这里我用均值填充了空值:

py 复制代码
In [103]: s = pd.Series(np.random.standard_normal(6))

In [104]: s[::2] = np.nan

In [105]: s
Out[105]: 
0         NaN
1    0.227290
2         NaN
3   -2.153545
4         NaN
5   -0.375842
dtype: float64

In [106]: s.fillna(s.mean())
Out[106]: 
0   -0.767366
1    0.227290
2   -0.767366
3   -2.153545
4   -0.767366
5   -0.375842
dtype: float64

假设您需要填充值根据组而变化。一种方法是对数据进行分组,并使用调用fillna的函数在每个数据块上使用apply。这里是一些关于美国各州的样本数据,分为东部和西部地区:

py 复制代码
In [107]: states = ["Ohio", "New York", "Vermont", "Florida",
 .....:           "Oregon", "Nevada", "California", "Idaho"]

In [108]: group_key = ["East", "East", "East", "East",
 .....:              "West", "West", "West", "West"]

In [109]: data = pd.Series(np.random.standard_normal(8), index=states)

In [110]: data
Out[110]: 
Ohio          0.329939
New York      0.981994
Vermont       1.105913
Florida      -1.613716
Oregon        1.561587
Nevada        0.406510
California    0.359244
Idaho        -0.614436
dtype: float64

让我们将数据中的一些值设置为缺失:

py 复制代码
In [111]: data[["Vermont", "Nevada", "Idaho"]] = np.nan

In [112]: data
Out[112]: 
Ohio          0.329939
New York      0.981994
Vermont            NaN
Florida      -1.613716
Oregon        1.561587
Nevada             NaN
California    0.359244
Idaho              NaN
dtype: float64

In [113]: data.groupby(group_key).size()
Out[113]: 
East    4
West    4
dtype: int64

In [114]: data.groupby(group_key).count()
Out[114]: 
East    3
West    2
dtype: int64

In [115]: data.groupby(group_key).mean()
Out[115]: 
East   -0.100594
West    0.960416
dtype: float64

我们可以使用组均值填充 NA 值,如下所示:

py 复制代码
In [116]: def fill_mean(group):
 .....:     return group.fillna(group.mean())

In [117]: data.groupby(group_key).apply(fill_mean)
Out[117]: 
East  Ohio          0.329939
 New York      0.981994
 Vermont      -0.100594
 Florida      -1.613716
West  Oregon        1.561587
 Nevada        0.960416
 California    0.359244
 Idaho         0.960416
dtype: float64

在另一种情况下,您可能在代码中预定义了根据组变化的填充值。由于组内部设置了name属性,我们可以使用它:

py 复制代码
In [118]: fill_values = {"East": 0.5, "West": -1}

In [119]: def fill_func(group):
 .....:     return group.fillna(fill_values[group.name])

In [120]: data.groupby(group_key).apply(fill_func)
Out[120]: 
East  Ohio          0.329939
 New York      0.981994
 Vermont       0.500000
 Florida      -1.613716
West  Oregon        1.561587
 Nevada       -1.000000
 California    0.359244
 Idaho        -1.000000
dtype: float64

示例:随机抽样和排列

假设您想要从大型数据集中随机抽取(有或没有替换)用于蒙特卡洛模拟或其他应用。有许多执行"抽取"的方法;在这里,我们使用 Series 的sample方法。

为了演示,这里有一种构建一副英式扑克牌的方法:

py 复制代码
suits = ["H", "S", "C", "D"]  # Hearts, Spades, Clubs, Diamonds
card_val = (list(range(1, 11)) + [10] * 3) * 4
base_names = ["A"] + list(range(2, 11)) + ["J", "K", "Q"]
cards = []
for suit in suits:
 cards.extend(str(num) + suit for num in base_names)

deck = pd.Series(card_val, index=cards)

现在我们有一个长度为 52 的 Series,其索引包含牌名,值是在二十一点和其他游戏中使用的值(为了简单起见,我让 ace "A"为 1):

py 复制代码
In [122]: deck.head(13)
Out[122]: 
AH      1
2H      2
3H      3
4H      4
5H      5
6H      6
7H      7
8H      8
9H      9
10H    10
JH     10
KH     10
QH     10
dtype: int64

现在,根据我之前说的,从牌组中抽取五张牌可以写成:

py 复制代码
In [123]: def draw(deck, n=5):
 .....:     return deck.sample(n)

In [124]: draw(deck)
Out[124]: 
4D     4
QH    10
8S     8
7D     7
9C     9
dtype: int64

假设你想要从每种花色中抽取两张随机牌。因为花色是每张牌名称的最后一个字符,我们可以根据这个进行分组,并使用apply

py 复制代码
In [125]: def get_suit(card):
 .....:     # last letter is suit
 .....:     return card[-1]

In [126]: deck.groupby(get_suit).apply(draw, n=2)
Out[126]: 
C  6C     6
 KC    10
D  7D     7
 3D     3
H  7H     7
 9H     9
S  2S     2
 QS    10
dtype: int64

或者,我们可以传递group_keys=False以删除外部套索索引,只留下所选的卡:

py 复制代码
In [127]: deck.groupby(get_suit, group_keys=False).apply(draw, n=2)
Out[127]: 
AC      1
3C      3
5D      5
4D      4
10H    10
7H      7
QS     10
7S      7
dtype: int64

示例:组加权平均和相关性

groupby的分割-应用-组合范式下,DataFrame 或两个 Series 中的列之间的操作,例如组加权平均,是可能的。例如,考虑包含组键、值和一些权重的数据集:

py 复制代码
In [128]: df = pd.DataFrame({"category": ["a", "a", "a", "a",
 .....:                                 "b", "b", "b", "b"],
 .....:                    "data": np.random.standard_normal(8),
 .....:                    "weights": np.random.uniform(size=8)})

In [129]: df
Out[129]: 
 category      data   weights
0        a -1.691656  0.955905
1        a  0.511622  0.012745
2        a -0.401675  0.137009
3        a  0.968578  0.763037
4        b -1.818215  0.492472
5        b  0.279963  0.832908
6        b -0.200819  0.658331
7        b -0.217221  0.612009

category加权平均值将是:

py 复制代码
In [130]: grouped = df.groupby("category")

In [131]: def get_wavg(group):
 .....:     return np.average(group["data"], weights=group["weights"])

In [132]: grouped.apply(get_wavg)
Out[132]: 
category
a   -0.495807
b   -0.357273
dtype: float64

另一个例子是,考虑一个最初从 Yahoo! Finance 获取的金融数据集,其中包含一些股票的日终价格和标准普尔 500 指数(SPX符号):

py 复制代码
In [133]: close_px = pd.read_csv("examples/stock_px.csv", parse_dates=True,
 .....:                        index_col=0)

In [134]: close_px.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 2214 entries, 2003-01-02 to 2011-10-14
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype 
---  ------  --------------  ----- 
 0   AAPL    2214 non-null   float64
 1   MSFT    2214 non-null   float64
 2   XOM     2214 non-null   float64
 3   SPX     2214 non-null   float64
dtypes: float64(4)
memory usage: 86.5 KB

In [135]: close_px.tail(4)
Out[135]: 
 AAPL   MSFT    XOM      SPX
2011-10-11  400.29  27.00  76.27  1195.54
2011-10-12  402.19  26.96  77.16  1207.25
2011-10-13  408.43  27.18  76.37  1203.66
2011-10-14  422.00  27.27  78.11  1224.58

这里的 DataFrame info()方法是获取 DataFrame 内容概述的便捷方式。

一个感兴趣的任务可能是计算一个由每日收益(从百分比变化计算)与SPX的年度相关性组成的 DataFrame。作为一种方法,我们首先创建一个函数,计算每列与"SPX"列的成对相关性:

py 复制代码
In [136]: def spx_corr(group):
 .....:     return group.corrwith(group["SPX"])

接下来,我们使用pct_change计算close_px的百分比变化:

py 复制代码
In [137]: rets = close_px.pct_change().dropna()

最后,我们按年将这些百分比变化分组,可以使用一个一行函数从每个行标签中提取datetime标签的year属性:

py 复制代码
In [138]: def get_year(x):
 .....:     return x.year

In [139]: by_year = rets.groupby(get_year)

In [140]: by_year.apply(spx_corr)
Out[140]: 
 AAPL      MSFT       XOM  SPX
2003  0.541124  0.745174  0.661265  1.0
2004  0.374283  0.588531  0.557742  1.0
2005  0.467540  0.562374  0.631010  1.0
2006  0.428267  0.406126  0.518514  1.0
2007  0.508118  0.658770  0.786264  1.0
2008  0.681434  0.804626  0.828303  1.0
2009  0.707103  0.654902  0.797921  1.0
2010  0.710105  0.730118  0.839057  1.0
2011  0.691931  0.800996  0.859975  1.0

您还可以计算列间的相关性。这里我们计算苹果和微软之间的年度相关性:

py 复制代码
In [141]: def corr_aapl_msft(group):
 .....:     return group["AAPL"].corr(group["MSFT"])

In [142]: by_year.apply(corr_aapl_msft)
Out[142]: 
2003    0.480868
2004    0.259024
2005    0.300093
2006    0.161735
2007    0.417738
2008    0.611901
2009    0.432738
2010    0.571946
2011    0.581987
dtype: float64

示例:组内线性回归

与前面的示例相同,您可以使用groupby执行更复杂的组内统计分析,只要函数返回一个 pandas 对象或标量值。例如,我可以定义以下regress函数(使用statsmodels计量经济学库),它在每个数据块上执行普通最小二乘(OLS)回归:

py 复制代码
import statsmodels.api as sm
def regress(data, yvar=None, xvars=None):
 Y = data[yvar]
 X = data[xvars]
 X["intercept"] = 1.
 result = sm.OLS(Y, X).fit()
 return result.params

如果您尚未安装statsmodels,可以使用 conda 安装它:

py 复制代码
conda install statsmodels

现在,要在AAPLSPX回报的年度线性回归中执行:

py 复制代码
In [144]: by_year.apply(regress, yvar="AAPL", xvars=["SPX"])
Out[144]: 
 SPX  intercept
2003  1.195406   0.000710
2004  1.363463   0.004201
2005  1.766415   0.003246
2006  1.645496   0.000080
2007  1.198761   0.003438
2008  0.968016  -0.001110
2009  0.879103   0.002954
2010  1.052608   0.001261
2011  0.806605   0.001514

10.4 组转换和"展开"的 GroupBys

在 Apply: General split-apply-combine 中,我们看了一下在分组操作中执行转换的apply方法。还有另一个内置方法叫做transform,它类似于apply,但对您可以使用的函数种类施加了更多的约束:

  • 它可以生成一个标量值广播到组的形状。

  • 它可以生成与输入组相同形状的对象。

  • 它不能改变其输入。

让我们考虑一个简单的例子以说明:

py 复制代码
In [145]: df = pd.DataFrame({'key': ['a', 'b', 'c'] * 4,
 .....:                    'value': np.arange(12.)})

In [146]: df
Out[146]: 
 key  value
0    a    0.0
1    b    1.0
2    c    2.0
3    a    3.0
4    b    4.0
5    c    5.0
6    a    6.0
7    b    7.0
8    c    8.0
9    a    9.0
10   b   10.0
11   c   11.0

这里是按键的组平均值:

py 复制代码
In [147]: g = df.groupby('key')['value']

In [148]: g.mean()
Out[148]: 
key
a    4.5
b    5.5
c    6.5
Name: value, dtype: float64

假设我们想要生成一个与df['value']相同形状的 Series,但值被按'key'分组后的平均值替换。我们可以传递一个计算单个组平均值的函数给transform

py 复制代码
In [149]: def get_mean(group):
 .....:     return group.mean()

In [150]: g.transform(get_mean)
Out[150]: 
0     4.5
1     5.5
2     6.5
3     4.5
4     5.5
5     6.5
6     4.5
7     5.5
8     6.5
9     4.5
10    5.5
11    6.5
Name: value, dtype: float64

对于内置的聚合函数,我们可以像 GroupBy agg方法一样传递一个字符串别名:

py 复制代码
In [151]: g.transform('mean')
Out[151]: 
0     4.5
1     5.5
2     6.5
3     4.5
4     5.5
5     6.5
6     4.5
7     5.5
8     6.5
9     4.5
10    5.5
11    6.5
Name: value, dtype: float64

apply一样,transform适用于返回 Series 的函数,但结果必须与输入的大小相同。例如,我们可以使用一个辅助函数将每个组乘以 2:

py 复制代码
In [152]: def times_two(group):
 .....:     return group * 2

In [153]: g.transform(times_two)
Out[153]: 
0      0.0
1      2.0
2      4.0
3      6.0
4      8.0
5     10.0
6     12.0
7     14.0
8     16.0
9     18.0
10    20.0
11    22.0
Name: value, dtype: float64

作为一个更复杂的例子,我们可以计算每个组按降序排名:

py 复制代码
In [154]: def get_ranks(group):
 .....:     return group.rank(ascending=False)

In [155]: g.transform(get_ranks)
Out[155]: 
0     4.0
1     4.0
2     4.0
3     3.0
4     3.0
5     3.0
6     2.0
7     2.0
8     2.0
9     1.0
10    1.0
11    1.0
Name: value, dtype: float64

考虑一个由简单聚合组成的组转换函数:

py 复制代码
In [156]: def normalize(x):
 .....:     return (x - x.mean()) / x.std()

在这种情况下,我们可以使用transformapply获得等效的结果:

py 复制代码
In [157]: g.transform(normalize)
Out[157]: 
0    -1.161895
1    -1.161895
2    -1.161895
3    -0.387298
4    -0.387298
5    -0.387298
6     0.387298
7     0.387298
8     0.387298
9     1.161895
10    1.161895
11    1.161895
Name: value, dtype: float64

In [158]: g.apply(normalize)
Out[158]: 
key 
a    0    -1.161895
 3    -0.387298
 6     0.387298
 9     1.161895
b    1    -1.161895
 4    -0.387298
 7     0.387298
 10    1.161895
c    2    -1.161895
 5    -0.387298
 8     0.387298
 11    1.161895
Name: value, dtype: float64

内置的聚合函数如'mean''sum'通常比一般的apply函数快得多。当与transform一起使用时,这些函数也有一个"快速路径"。这使我们能够执行所谓的展开组操作:

py 复制代码
In [159]: g.transform('mean')
Out[159]: 
0     4.5
1     5.5
2     6.5
3     4.5
4     5.5
5     6.5
6     4.5
7     5.5
8     6.5
9     4.5
10    5.5
11    6.5
Name: value, dtype: float64

In [160]: normalized = (df['value'] - g.transform('mean')) / g.transform('std')

In [161]: normalized
Out[161]: 
0    -1.161895
1    -1.161895
2    -1.161895
3    -0.387298
4    -0.387298
5    -0.387298
6     0.387298
7     0.387298
8     0.387298
9     1.161895
10    1.161895
11    1.161895
Name: value, dtype: float64

在这里,我们在多个 GroupBy 操作的输出之间进行算术运算,而不是编写一个函数并将其传递给groupby(...).apply。这就是所谓的"展开"。

尽管展开的组操作可能涉及多个组聚合,但矢量化操作的整体效益通常超过了这一点。

10.5 透视表和交叉制表

透视表 是一种经常在电子表格程序和其他数据分析软件中找到的数据汇总工具。它通过一个或多个键对数据表进行聚合,将数据排列在一个矩形中,其中一些组键沿行排列,另一些沿列排列。在 Python 中,通过本章描述的groupby功能以及利用分层索引进行重塑操作,可以实现使用 pandas 的透视表。DataFrame 还有一个pivot_table方法,还有一个顶级的pandas.pivot_table函数。除了提供一个方便的groupby接口外,pivot_table还可以添加部分总计,也称为边际

返回到小费数据集,假设您想要计算按daysmoker排列的组平均值的表格(默认的pivot_table聚合类型):

py 复制代码
In [162]: tips.head()
Out[162]: 
 total_bill   tip smoker  day    time  size   tip_pct
0       16.99  1.01     No  Sun  Dinner     2  0.059447
1       10.34  1.66     No  Sun  Dinner     3  0.160542
2       21.01  3.50     No  Sun  Dinner     3  0.166587
3       23.68  3.31     No  Sun  Dinner     2  0.139780
4       24.59  3.61     No  Sun  Dinner     4  0.146808

In [163]: tips.pivot_table(index=["day", "smoker"],
 .....:                  values=["size", "tip", "tip_pct", "total_bill"])
Out[163]: 
 size       tip   tip_pct  total_bill
day  smoker 
Fri  No      2.250000  2.812500  0.151650   18.420000
 Yes     2.066667  2.714000  0.174783   16.813333
Sat  No      2.555556  3.102889  0.158048   19.661778
 Yes     2.476190  2.875476  0.147906   21.276667
Sun  No      2.929825  3.167895  0.160113   20.506667
 Yes     2.578947  3.516842  0.187250   24.120000
Thur No      2.488889  2.673778  0.160298   17.113111
 Yes     2.352941  3.030000  0.163863   19.190588

这可以直接使用groupby生成,使用tips.groupby(["day", "smoker"]).mean()。现在,假设我们只想计算tip_pctsize的平均值,并另外按time分组。我将smoker放在表格列中,timeday放在行中:

py 复制代码
In [164]: tips.pivot_table(index=["time", "day"], columns="smoker",
 .....:                  values=["tip_pct", "size"])
Out[164]: 
 size             tip_pct 
smoker             No       Yes        No       Yes
time   day 
Dinner Fri   2.000000  2.222222  0.139622  0.165347
 Sat   2.555556  2.476190  0.158048  0.147906
 Sun   2.929825  2.578947  0.160113  0.187250
 Thur  2.000000       NaN  0.159744       NaN
Lunch  Fri   3.000000  1.833333  0.187735  0.188937
 Thur  2.500000  2.352941  0.160311  0.163863

我们可以通过传递margins=True来增加此表,以包括部分总计。这将添加All行和列标签,相应的值是单个层次内所有数据的组统计信息:

py 复制代码
In [165]: tips.pivot_table(index=["time", "day"], columns="smoker",
 .....:                  values=["tip_pct", "size"], margins=True)
Out[165]: 
 size                       tip_pct 
smoker             No       Yes       All        No       Yes       All
time   day 
Dinner Fri   2.000000  2.222222  2.166667  0.139622  0.165347  0.158916
 Sat   2.555556  2.476190  2.517241  0.158048  0.147906  0.153152
 Sun   2.929825  2.578947  2.842105  0.160113  0.187250  0.166897
 Thur  2.000000       NaN  2.000000  0.159744       NaN  0.159744
Lunch  Fri   3.000000  1.833333  2.000000  0.187735  0.188937  0.188765
 Thur  2.500000  2.352941  2.459016  0.160311  0.163863  0.161301
All          2.668874  2.408602  2.569672  0.159328  0.163196  0.160803

这里,All值是没有考虑吸烟者与非吸烟者(All列)或行中的两个级别分组的平均值(All行)。

要使用除mean之外的聚合函数,请将其传递给aggfunc关键字参数。例如,"count"len将为您提供组大小的交叉制表(计数或频率)(尽管"count"将在数据组内排除空值的计数,而len不会):

py 复制代码
In [166]: tips.pivot_table(index=["time", "smoker"], columns="day",
 .....:                  values="tip_pct", aggfunc=len, margins=True)
Out[166]: 
day             Fri   Sat   Sun  Thur  All
time   smoker 
Dinner No       3.0  45.0  57.0   1.0  106
 Yes      9.0  42.0  19.0   NaN   70
Lunch  No       1.0   NaN   NaN  44.0   45
 Yes      6.0   NaN   NaN  17.0   23
All            19.0  87.0  76.0  62.0  244

如果某些组合为空(或其他 NA),您可能希望传递一个fill_value

py 复制代码
In [167]: tips.pivot_table(index=["time", "size", "smoker"], columns="day",
 .....:                  values="tip_pct", fill_value=0)
Out[167]: 
day                      Fri       Sat       Sun      Thur
time   size smoker 
Dinner 1    No      0.000000  0.137931  0.000000  0.000000
 Yes     0.000000  0.325733  0.000000  0.000000
 2    No      0.139622  0.162705  0.168859  0.159744
 Yes     0.171297  0.148668  0.207893  0.000000
 3    No      0.000000  0.154661  0.152663  0.000000
...                      ...       ...       ...       ...
Lunch  3    Yes     0.000000  0.000000  0.000000  0.204952
 4    No      0.000000  0.000000  0.000000  0.138919
 Yes     0.000000  0.000000  0.000000  0.155410
 5    No      0.000000  0.000000  0.000000  0.121389
 6    No      0.000000  0.000000  0.000000  0.173706
[21 rows x 4 columns]

请参阅表 10.2 以获取pivot_table选项的摘要。

表 10.2:pivot_table选项

参数 描述
values 要聚合的列名;默认情况下,聚合所有数值列
index 要在生成的透视表的行上分组的列名或其他组键
columns 要在生成的透视表的列上分组的列名或其他组键
aggfunc 聚合函数或函数列表(默认为"mean");可以是在groupby上下文中有效的任何函数
fill_value 替换结果表中的缺失值
dropna 如果为True,则不包括所有条目都为NA的列
margins 添加行/列小计和总计(默认为False
margins_name 在传递margins=True时用于边缘行/列标签的名称;默认为"All"
observed 使用分类组键,如果为True,则仅显示键中的观察类别值,而不是所有类别

交叉制表:交叉制表

交叉制表 (或简称为交叉制表)是计算组频率的透视表的一种特殊情况。这里是一个例子:

py 复制代码
In [168]: from io import StringIO

In [169]: data = """Sample  Nationality  Handedness
 .....: 1   USA  Right-handed
 .....: 2   Japan    Left-handed
 .....: 3   USA  Right-handed
 .....: 4   Japan    Right-handed
 .....: 5   Japan    Left-handed
 .....: 6   Japan    Right-handed
 .....: 7   USA  Right-handed
 .....: 8   USA  Left-handed
 .....: 9   Japan    Right-handed
 .....: 10  USA  Right-handed"""
 .....:

In [170]: data = pd.read_table(StringIO(data), sep="\s+")
py 复制代码
In [171]: data
Out[171]: 
 Sample Nationality    Handedness
0       1         USA  Right-handed
1       2       Japan   Left-handed
2       3         USA  Right-handed
3       4       Japan  Right-handed
4       5       Japan   Left-handed
5       6       Japan  Right-handed
6       7         USA  Right-handed
7       8         USA   Left-handed
8       9       Japan  Right-handed
9      10         USA  Right-handed

作为一些调查分析的一部分,我们可能希望按国籍和惯用手总结这些数据。您可以使用pivot_table来做到这一点,但pandas.crosstab函数可能更方便:

py 复制代码
In [172]: pd.crosstab(data["Nationality"], data["Handedness"], margins=True)
Out[172]: 
Handedness   Left-handed  Right-handed  All
Nationality 
Japan                  2             3    5
USA                    1             4    5
All                    3             7   10

crosstab的前两个参数可以是数组、Series 或数组列表。就像在小费数据中一样:

py 复制代码
In [173]: pd.crosstab([tips["time"], tips["day"]], tips["smoker"], margins=True)
Out[173]: 
smoker        No  Yes  All
time   day 
Dinner Fri     3    9   12
 Sat    45   42   87
 Sun    57   19   76
 Thur    1    0    1
Lunch  Fri     1    6    7
 Thur   44   17   61
All          151   93  244

10.6 结论

掌握 pandas 的数据分组工具可以帮助数据清洗和建模或统计分析工作。在 Ch 13:数据分析示例中,我们将查看几个更多实际数据上使用groupby的示例用例。

在下一章中,我们将把注意力转向时间序列数据。

十一、时间序列

原文:wesmckinney.com/book/time-series

译者:飞龙

协议:CC BY-NC-SA 4.0
此开放访问网络版本的《Python 数据分析第三版》现已作为印刷版和数字版的伴侣提供。如果您发现任何勘误,请在此处报告。请注意,由 Quarto 生成的本站点的某些方面与 O'Reilly 的印刷版和电子书版本的格式不同。

如果您发现本书的在线版本有用,请考虑订购纸质版无 DRM 的电子书以支持作者。本网站的内容不得复制或再生产。代码示例采用 MIT 许可,可在 GitHub 或 Gitee 上找到。

时间序列数据是许多不同领域中的结构化数据的重要形式,如金融、经济、生态学、神经科学和物理学。任何在许多时间点重复记录的东西都构成一个时间序列。许多时间序列是固定频率 的,也就是说,数据点按照某种规则定期发生,例如每 15 秒、每 5 分钟或每月一次。时间序列也可以是不规则的,没有固定的时间单位或单位之间的偏移。如何标记和引用时间序列数据取决于应用程序,您可能有以下之一:

时间戳

特定的时间点。

固定周期

例如 2017 年 1 月的整个月,或 2020 年的整年。

时间间隔

由开始和结束时间戳指示。周期可以被视为间隔的特殊情况。

实验或经过的时间

每个时间戳都是相对于特定开始时间的时间度量(例如,自放入烤箱以来每秒烘烤的饼干的直径),从 0 开始。

在本章中,我主要关注前三类时间序列,尽管许多技术也可以应用于实验时间序列,其中索引可能是整数或浮点数,表示从实验开始经过的时间。最简单的时间序列是由时间戳索引的。

提示:

pandas 还支持基于时间差的索引,这是一种表示实验或经过时间的有用方式。我们在本书中没有探讨时间差索引,但您可以在pandas 文档中了解更多。

pandas 提供了许多内置的时间序列工具和算法。您可以高效地处理大型时间序列,对不规则和固定频率的时间序列进行切片、聚合和重采样。其中一些工具对金融和经济应用很有用,但您当然也可以用它们来分析服务器日志数据。

与其他章节一样,我们首先导入 NumPy 和 pandas:

py 复制代码
In [12]: import numpy as np

In [13]: import pandas as pd

11.1 日期和时间数据类型和工具

Python 标准库包括用于日期和时间数据以及与日历相关的功能的数据类型。datetimetimecalendar模块是主要的起点。datetime.datetime类型,或简称datetime,被广泛使用:

py 复制代码
In [14]: from datetime import datetime

In [15]: now = datetime.now()

In [16]: now
Out[16]: datetime.datetime(2023, 4, 12, 13, 9, 16, 484533)

In [17]: now.year, now.month, now.day
Out[17]: (2023, 4, 12)

datetime 存储日期和时间,精确到微秒。datetime.timedelta,或简称timedelta,表示两个datetime对象之间的时间差:

py 复制代码
In [18]: delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15)

In [19]: delta
Out[19]: datetime.timedelta(days=926, seconds=56700)

In [20]: delta.days
Out[20]: 926

In [21]: delta.seconds
Out[21]: 56700

您可以将timedelta或其倍数添加(或减去)到datetime对象中,以产生一个新的偏移对象:

py 复制代码
In [22]: from datetime import timedelta

In [23]: start = datetime(2011, 1, 7)

In [24]: start + timedelta(12)
Out[24]: datetime.datetime(2011, 1, 19, 0, 0)

In [25]: start - 2 * timedelta(12)
Out[25]: datetime.datetime(2010, 12, 14, 0, 0)

表 11.1 总结了datetime模块中的数据类型。虽然本章主要关注 pandas 中的数据类型和高级时间序列操作,但您可能会在 Python 的许多其他地方遇到基于datetime的类型。

表 11.1:datetime模块中的类型

类型 描述
date 使用公历存储日期(年,月,日)
time 以小时,分钟,秒和微秒存储一天中的时间
datetime 存储日期和时间
timedelta 两个datetime值之间的差异(以天,秒和微秒计)
tzinfo 存储时区信息的基本类型

在字符串和日期时间之间转换

您可以使用strstrftime方法对datetime对象和 pandas 的Timestamp对象进行格式化为字符串,传递格式规范:

py 复制代码
In [26]: stamp = datetime(2011, 1, 3)

In [27]: str(stamp)
Out[27]: '2011-01-03 00:00:00'

In [28]: stamp.strftime("%Y-%m-%d")
Out[28]: '2011-01-03'

请参阅表 11.2 以获取完整的格式代码列表。

表 11.2:datetime格式规范(ISO C89 兼容)

类型 描述
%Y 四位数年份
%y 两位数年份
%m 两位数月份[01, 12]
%d 两位数日期[01, 31]
%H 小时(24 小时制)[00, 23]
%I 小时(12 小时制)[01, 12]
%M 两位数分钟[00, 59]
%S 秒[00, 61](秒 60, 61 表示闰秒)
%f 微秒作为整数,零填充(从 000000 到 999999)
%j 一年中的日期作为零填充的整数(从 001 到 336)
%w 星期几作为整数[0(星期日),6]
%u 从 1 开始的星期几整数,其中 1 是星期一。
%U 一年中的周数[00, 53]; 星期日被认为是一周的第一天,年初第一个星期日之前的日子被称为"第 0 周"
%W 一年中的周数[00, 53]; 星期一被认为是一周的第一天,年初第一个星期一之前的日子被称为"第 0 周"
%z UTC 时区偏移为+HHMM-HHMM; 如果时区是 naive,则为空
%Z 时区名称作为字符串,如果没有时区则为空字符串
%F %Y-%m-%d的快捷方式(例如,2012-4-18
%D %m/%d/%y的快捷方式(例如,04/18/12

您可以使用许多相同的格式代码使用datetime.strptime将字符串转换为日期(但是一些代码,如%F,不能使用):

py 复制代码
In [29]: value = "2011-01-03"

In [30]: datetime.strptime(value, "%Y-%m-%d")
Out[30]: datetime.datetime(2011, 1, 3, 0, 0)

In [31]: datestrs = ["7/6/2011", "8/6/2011"]

In [32]: [datetime.strptime(x, "%m/%d/%Y") for x in datestrs]
Out[32]: 
[datetime.datetime(2011, 7, 6, 0, 0),
 datetime.datetime(2011, 8, 6, 0, 0)]

datetime.strptime 是一种解析具有已知格式的日期的方法。

pandas 通常面向处理日期数组,无论是作为轴索引还是数据框中的列。pandas.to_datetime方法解析许多不同类型的日期表示。标准日期格式如 ISO 8601 可以快速解析:

py 复制代码
In [33]: datestrs = ["2011-07-06 12:00:00", "2011-08-06 00:00:00"]

In [34]: pd.to_datetime(datestrs)
Out[34]: DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00'], dtype='dat
etime64[ns]', freq=None)

它还处理应被视为缺失的值(None,空字符串等):

py 复制代码
In [35]: idx = pd.to_datetime(datestrs + [None])

In [36]: idx
Out[36]: DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00', 'NaT'], dty
pe='datetime64[ns]', freq=None)

In [37]: idx[2]
Out[37]: NaT

In [38]: pd.isna(idx)
Out[38]: array([False, False,  True])

NaT(不是时间)是 pandas 中的时间戳数据的空值。

注意

dateutil.parser是一个有用但不完美的工具。值得注意的是,它会将一些字符串识别为日期,而您可能希望它不会;例如,"42"将被解析为年份2042与今天的日历日期相对应。

datetime对象还具有许多针对其他国家或语言系统的特定于区域的格式选项。例如,德国或法国系统上的缩写月份名称与英语系统上的不同。请参阅表 11.3 以获取列表。

表 11.3:特定于区域的日期格式化

类型 描述
%a 缩写的星期几名称
%A 完整的星期几名称
%b 缩写的月份名称
%B 完整的月份名称
%c 完整的日期和时间(例如,'周二 2012 年 5 月 1 日 下午 04:20:57')
%p AM 或 PM 的本地等效
%x 本地适用的格式化日期(例如,在美国,2012 年 5 月 1 日为'05/01/2012')

| %X | 本地适用的时间(例如,'下午 04:24:12') |

11.2 时间序列基础知识

pandas 中的一种基本类型的时间序列对象是由时间戳索引的 Series,通常在 pandas 之外表示为 Python 字符串或datetime对象:

py 复制代码
In [39]: dates = [datetime(2011, 1, 2), datetime(2011, 1, 5),
 ....:          datetime(2011, 1, 7), datetime(2011, 1, 8),
 ....:          datetime(2011, 1, 10), datetime(2011, 1, 12)]

In [40]: ts = pd.Series(np.random.standard_normal(6), index=dates)

In [41]: ts
Out[41]: 
2011-01-02   -0.204708
2011-01-05    0.478943
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
2011-01-12    1.393406
dtype: float64

在幕后,这些datetime对象已被放入DatetimeIndex中:

py 复制代码
In [42]: ts.index
Out[42]: 
DatetimeIndex(['2011-01-02', '2011-01-05', '2011-01-07', '2011-01-08',
 '2011-01-10', '2011-01-12'],
 dtype='datetime64[ns]', freq=None)

与其他 Series 一样,不同索引的时间序列之间的算术运算会自动对齐日期:

py 复制代码
In [43]: ts + ts[::2]
Out[43]: 
2011-01-02   -0.409415
2011-01-05         NaN
2011-01-07   -1.038877
2011-01-08         NaN
2011-01-10    3.931561
2011-01-12         NaN
dtype: float64

请记住,ts[::2]选择ts中的每个第二个元素。

pandas 使用 NumPy 的datetime64数据类型以纳秒分辨率存储时间戳:

py 复制代码
In [44]: ts.index.dtype
Out[44]: dtype('<M8[ns]')

来自DatetimeIndex的标量值是 pandas 的Timestamp对象:

py 复制代码
In [45]: stamp = ts.index[0]

In [46]: stamp
Out[46]: Timestamp('2011-01-02 00:00:00')

pandas.Timestamp可以替代大多数您将使用datetime对象的地方。然而,反之则不成立,因为pandas.Timestamp可以存储纳秒精度数据,而datetime仅存储微秒精度。此外,pandas.Timestamp可以存储频率信息(如果有的话),并且了解如何执行时区转换和其他类型的操作。稍后在时区处理中会更详细地介绍这两个方面。

索引、选择、子集

当您根据标签索引和选择数据时,时间序列的行为与任何其他 Series 相同:

py 复制代码
In [47]: stamp = ts.index[2]

In [48]: ts[stamp]
Out[48]: -0.5194387150567381

为了方便起见,您还可以传递一个可解释为日期的字符串:

py 复制代码
In [49]: ts["2011-01-10"]
Out[49]: 1.9657805725027142

对于更长的时间序列,可以传递一年或仅一年和一个月以轻松选择数据的片段(pandas.date_range在生成日期范围中有更详细的讨论):

py 复制代码
In [50]: longer_ts = pd.Series(np.random.standard_normal(1000),
 ....:                       index=pd.date_range("2000-01-01", periods=1000))

In [51]: longer_ts
Out[51]: 
2000-01-01    0.092908
2000-01-02    0.281746
2000-01-03    0.769023
2000-01-04    1.246435
2000-01-05    1.007189
 ... 
2002-09-22    0.930944
2002-09-23   -0.811676
2002-09-24   -1.830156
2002-09-25   -0.138730
2002-09-26    0.334088
Freq: D, Length: 1000, dtype: float64

In [52]: longer_ts["2001"]
Out[52]: 
2001-01-01    1.599534
2001-01-02    0.474071
2001-01-03    0.151326
2001-01-04   -0.542173
2001-01-05   -0.475496
 ... 
2001-12-27    0.057874
2001-12-28   -0.433739
2001-12-29    0.092698
2001-12-30   -1.397820
2001-12-31    1.457823
Freq: D, Length: 365, dtype: float64

在这里,字符串"2001"被解释为一年,并选择了那个时间段。如果指定月份,也可以这样做:

py 复制代码
In [53]: longer_ts["2001-05"]
Out[53]: 
2001-05-01   -0.622547
2001-05-02    0.936289
2001-05-03    0.750018
2001-05-04   -0.056715
2001-05-05    2.300675
 ... 
2001-05-27    0.235477
2001-05-28    0.111835
2001-05-29   -1.251504
2001-05-30   -2.949343
2001-05-31    0.634634
Freq: D, Length: 31, dtype: float64

使用datetime对象进行切片也是有效的:

py 复制代码
In [54]: ts[datetime(2011, 1, 7):]
Out[54]: 
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
2011-01-12    1.393406
dtype: float64

In [55]: ts[datetime(2011, 1, 7):datetime(2011, 1, 10)]
Out[55]: 
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
dtype: float64

因为大多数时间序列数据是按时间顺序排列的,所以可以使用不包含在时间序列中的时间戳进行切片以执行范围查询:

py 复制代码
In [56]: ts
Out[56]: 
2011-01-02   -0.204708
2011-01-05    0.478943
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
2011-01-12    1.393406
dtype: float64

In [57]: ts["2011-01-06":"2011-01-11"]
Out[57]: 
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
dtype: float64

与以前一样,您可以传递字符串日期、datetime或时间戳。请记住,以这种方式切片会在源时间序列上产生视图,就像在 NumPy 数组上切片一样。这意味着不会复制任何数据,并且对切片的修改将反映在原始数据中。

有一个等效的实例方法,truncate,它在两个日期之间切片一个 Series:

py 复制代码
In [58]: ts.truncate(after="2011-01-09")
Out[58]: 
2011-01-02   -0.204708
2011-01-05    0.478943
2011-01-07   -0.519439
2011-01-08   -0.555730
dtype: float64

对于 DataFrame 来说,所有这些都是正确的,可以对其行进行索引:

py 复制代码
In [59]: dates = pd.date_range("2000-01-01", periods=100, freq="W-WED")

In [60]: long_df = pd.DataFrame(np.random.standard_normal((100, 4)),
 ....:                        index=dates,
 ....:                        columns=["Colorado", "Texas",
 ....:                                 "New York", "Ohio"])

In [61]: long_df.loc["2001-05"]
Out[61]: 
 Colorado     Texas  New York      Ohio
2001-05-02 -0.006045  0.490094 -0.277186 -0.707213
2001-05-09 -0.560107  2.735527  0.927335  1.513906
2001-05-16  0.538600  1.273768  0.667876 -0.969206
2001-05-23  1.676091 -0.817649  0.050188  1.951312
2001-05-30  3.260383  0.963301  1.201206 -1.852001

具有重复索引的时间序列

在某些应用程序中,可能会有多个数据观测值落在特定的时间戳上。这里是一个例子:

py 复制代码
In [62]: dates = pd.DatetimeIndex(["2000-01-01", "2000-01-02", "2000-01-02",
 ....:                           "2000-01-02", "2000-01-03"])

In [63]: dup_ts = pd.Series(np.arange(5), index=dates)

In [64]: dup_ts
Out[64]: 
2000-01-01    0
2000-01-02    1
2000-01-02    2
2000-01-02    3
2000-01-03    4
dtype: int64

我们可以通过检查其is_unique属性来确定索引不是唯一的:

py 复制代码
In [65]: dup_ts.index.is_unique
Out[65]: False

现在,对这个时间序列进行索引将产生标量值或切片,具体取决于时间戳是否重复:

py 复制代码
In [66]: dup_ts["2000-01-03"]  # not duplicated
Out[66]: 4

In [67]: dup_ts["2000-01-02"]  # duplicated
Out[67]: 
2000-01-02    1
2000-01-02    2
2000-01-02    3
dtype: int64

假设您想要聚合具有非唯一时间戳的数据。一种方法是使用groupby并传递level=0(唯一的级别):

py 复制代码
In [68]: grouped = dup_ts.groupby(level=0)

In [69]: grouped.mean()
Out[69]: 
2000-01-01    0.0
2000-01-02    2.0
2000-01-03    4.0
dtype: float64

In [70]: grouped.count()
Out[70]: 
2000-01-01    1
2000-01-02    3
2000-01-03    1
dtype: int64

11.3 日期范围、频率和移位

在 pandas 中,通常假定通用时间序列是不规则的;也就是说,它们没有固定的频率。对于许多应用程序来说,这是足够的。然而,通常希望相对于固定频率(如每日、每月或每 15 分钟)进行工作,即使这意味着在时间序列中引入缺失值。幸运的是,pandas 具有一整套标准时间序列频率和重新采样工具(稍后在重新采样和频率转换中更详细地讨论),可以推断频率并生成固定频率的日期范围。例如,您可以通过调用resample将示例时间序列转换为固定的每日频率:

py 复制代码
In [71]: ts
Out[71]: 
2011-01-02   -0.204708
2011-01-05    0.478943
2011-01-07   -0.519439
2011-01-08   -0.555730
2011-01-10    1.965781
2011-01-12    1.393406
dtype: float64

In [72]: resampler = ts.resample("D")

In [73]: resampler
Out[73]: <pandas.core.resample.DatetimeIndexResampler object at 0x17b0e7bb0>

字符串"D"被解释为每日频率。

在频率之间的转换或重新采样是一个足够大的主题,后面会有自己的部分(重新采样和频率转换)。在这里,我将向您展示如何使用基本频率及其倍数。

生成日期范围

虽然我之前没有解释过,但pandas.date_range负责根据特定频率生成具有指定长度的DatetimeIndex

py 复制代码
In [74]: index = pd.date_range("2012-04-01", "2012-06-01")

In [75]: index
Out[75]: 
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
 '2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
 '2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
 '2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
 '2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20',
 '2012-04-21', '2012-04-22', '2012-04-23', '2012-04-24',
 '2012-04-25', '2012-04-26', '2012-04-27', '2012-04-28',
 '2012-04-29', '2012-04-30', '2012-05-01', '2012-05-02',
 '2012-05-03', '2012-05-04', '2012-05-05', '2012-05-06',
 '2012-05-07', '2012-05-08', '2012-05-09', '2012-05-10',
 '2012-05-11', '2012-05-12', '2012-05-13', '2012-05-14',
 '2012-05-15', '2012-05-16', '2012-05-17', '2012-05-18',
 '2012-05-19', '2012-05-20', '2012-05-21', '2012-05-22',
 '2012-05-23', '2012-05-24', '2012-05-25', '2012-05-26',
 '2012-05-27', '2012-05-28', '2012-05-29', '2012-05-30',
 '2012-05-31', '2012-06-01'],
 dtype='datetime64[ns]', freq='D')

默认情况下,pandas.date_range生成每日时间戳。如果只传递开始或结束日期,必须传递一个周期数来生成:

py 复制代码
In [76]: pd.date_range(start="2012-04-01", periods=20)
Out[76]: 
DatetimeIndex(['2012-04-01', '2012-04-02', '2012-04-03', '2012-04-04',
 '2012-04-05', '2012-04-06', '2012-04-07', '2012-04-08',
 '2012-04-09', '2012-04-10', '2012-04-11', '2012-04-12',
 '2012-04-13', '2012-04-14', '2012-04-15', '2012-04-16',
 '2012-04-17', '2012-04-18', '2012-04-19', '2012-04-20'],
 dtype='datetime64[ns]', freq='D')

In [77]: pd.date_range(end="2012-06-01", periods=20)
Out[77]: 
DatetimeIndex(['2012-05-13', '2012-05-14', '2012-05-15', '2012-05-16',
 '2012-05-17', '2012-05-18', '2012-05-19', '2012-05-20',
 '2012-05-21', '2012-05-22', '2012-05-23', '2012-05-24',
 '2012-05-25', '2012-05-26', '2012-05-27', '2012-05-28',
 '2012-05-29', '2012-05-30', '2012-05-31', '2012-06-01'],
 dtype='datetime64[ns]', freq='D')

开始和结束日期为生成的日期索引定义了严格的边界。例如,如果您想要一个包含每个月最后一个工作日的日期索引,您将传递 "BM" 频率(月底的工作日;请参阅 Table 11.4 中更完整的频率列表),只有落在日期区间内或日期区间内的日期将被包括:

py 复制代码
In [78]: pd.date_range("2000-01-01", "2000-12-01", freq="BM")
Out[78]: 
DatetimeIndex(['2000-01-31', '2000-02-29', '2000-03-31', '2000-04-28',
 '2000-05-31', '2000-06-30', '2000-07-31', '2000-08-31',
 '2000-09-29', '2000-10-31', '2000-11-30'],
 dtype='datetime64[ns]', freq='BM')

Table 11.4: 基础时间序列频率(不全面)

别名 偏移类型 描述
D Day 日历日
B BusinessDay 工作日
H Hour 每小时
Tmin Minute 每分钟一次
S Second 每秒一次
Lms Milli 毫秒(1 秒的 1/1,000)
U Micro 微秒(1 秒的 1/1,000,000)
M MonthEnd 月份的最后一个日历日
BM BusinessMonthEnd 月份的最后一个工作日(工作日)
MS MonthBegin 月份的第一个日历日
BMS BusinessMonthBegin 月份的第一个工作日
W-MON, W-TUE, ... Week 每周在给定星期的某一天(MON、TUE、WED、THU、FRI、SAT 或 SUN)
WOM-1MON, WOM-2MON, ... WeekOfMonth 在月份的第一、第二、第三或第四周生成每周日期(例如,每月的第三个星期五为 WOM-3FRI
Q-JAN, Q-FEB, ... QuarterEnd 季度日期锚定在每个月的最后一个日历日,年终在指定月份(JAN、FEB、MAR、APR、MAY、JUN、JUL、AUG、SEP、OCT、NOV 或 DEC)
BQ-JAN, BQ-FEB, ... BusinessQuarterEnd 季度日期锚定在每个月的最后一个工作日,年终在指定月份
QS-JAN, QS-FEB, ... QuarterBegin 季度日期锚定在每个月的第一个日历日,年终在指定月份
BQS-JAN, BQS-FEB, ... BusinessQuarterBegin 季度日期锚定在每个月的第一个工作日,年终在指定月份
A-JAN, A-FEB, ... YearEnd 年度日期锚定在给定月份的最后一个日历日(JAN、FEB、MAR、APR、MAY、JUN、JUL、AUG、SEP、OCT、NOV 或 DEC)
BA-JAN, BA-FEB, ... BusinessYearEnd 年度日期锚定在给定月份的最后一个工作日
AS-JAN, AS-FEB, ... YearBegin 年度日期锚定在给定月份的第一天
BAS-JAN, BAS-FEB, ... BusinessYearBegin 年度日期锚定在给定月份的第一个工作日

pandas.date_range 默认保留开始或结束时间戳的时间(如果有):

py 复制代码
In [79]: pd.date_range("2012-05-02 12:56:31", periods=5)
Out[79]: 
DatetimeIndex(['2012-05-02 12:56:31', '2012-05-03 12:56:31',
 '2012-05-04 12:56:31', '2012-05-05 12:56:31',
 '2012-05-06 12:56:31'],
 dtype='datetime64[ns]', freq='D')

有时您会有带有时间信息的开始或结束日期,但希望生成一组时间戳,规范化 为午夜作为约定。为此,有一个 normalize 选项:

py 复制代码
In [80]: pd.date_range("2012-05-02 12:56:31", periods=5, normalize=True)
Out[80]: 
DatetimeIndex(['2012-05-02', '2012-05-03', '2012-05-04', '2012-05-05',
 '2012-05-06'],
 dtype='datetime64[ns]', freq='D')

频率和日期偏移

在 pandas 中,频率由 基础频率 和一个乘数组成。基础频率通常用字符串别名表示,如 "M" 表示每月或 "H" 表示每小时。对于每个基础频率,都有一个称为 日期偏移 的对象。例如,小时频率可以用 Hour 类表示:

py 复制代码
In [81]: from pandas.tseries.offsets import Hour, Minute

In [82]: hour = Hour()

In [83]: hour
Out[83]: <Hour>

您可以通过传递一个整数来定义偏移的倍数:

py 复制代码
In [84]: four_hours = Hour(4)

In [85]: four_hours
Out[85]: <4 * Hours>

在大多数应用程序中,您通常不需要显式创建这些对象之一;而是使用类似 "H""4H" 的字符串别名。在基础频率前放置一个整数会创建一个倍数:

py 复制代码
In [86]: pd.date_range("2000-01-01", "2000-01-03 23:59", freq="4H")
Out[86]: 
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 04:00:00',
 '2000-01-01 08:00:00', '2000-01-01 12:00:00',
 '2000-01-01 16:00:00', '2000-01-01 20:00:00',
 '2000-01-02 00:00:00', '2000-01-02 04:00:00',
 '2000-01-02 08:00:00', '2000-01-02 12:00:00',
 '2000-01-02 16:00:00', '2000-01-02 20:00:00',
 '2000-01-03 00:00:00', '2000-01-03 04:00:00',
 '2000-01-03 08:00:00', '2000-01-03 12:00:00',
 '2000-01-03 16:00:00', '2000-01-03 20:00:00'],
 dtype='datetime64[ns]', freq='4H')

许多偏移可以通过加法组合:

py 复制代码
In [87]: Hour(2) + Minute(30)
Out[87]: <150 * Minutes>

同样,您可以传递频率字符串,如 "1h30min",这将有效地解析为相同的表达式:

py 复制代码
In [88]: pd.date_range("2000-01-01", periods=10, freq="1h30min")
Out[88]: 
DatetimeIndex(['2000-01-01 00:00:00', '2000-01-01 01:30:00',
 '2000-01-01 03:00:00', '2000-01-01 04:30:00',
 '2000-01-01 06:00:00', '2000-01-01 07:30:00',
 '2000-01-01 09:00:00', '2000-01-01 10:30:00',
 '2000-01-01 12:00:00', '2000-01-01 13:30:00'],
 dtype='datetime64[ns]', freq='90T')

一些频率描述的是时间点,这些时间点不是均匀间隔的。例如,"M"(日历月底)和 "BM"(月底的最后一个工作日/工作日)取决于一个月的天数,以及在后一种情况下,月份是否在周末结束。我们将这些称为 锚定 偏移。

请参考 Table 11.4 以获取 pandas 中可用的频率代码和日期偏移类的列表。

注意

用户可以定义自己的自定义频率类,以提供 pandas 中不可用的日期逻辑,但这些完整的细节超出了本书的范围。

月份周日期

一个有用的频率类是"月份周",从WOM开始。这使您可以获得每个月的第三个星期五这样的日期:

py 复制代码
In [89]: monthly_dates = pd.date_range("2012-01-01", "2012-09-01", freq="WOM-3FRI
")

In [90]: list(monthly_dates)
Out[90]: 
[Timestamp('2012-01-20 00:00:00'),
 Timestamp('2012-02-17 00:00:00'),
 Timestamp('2012-03-16 00:00:00'),
 Timestamp('2012-04-20 00:00:00'),
 Timestamp('2012-05-18 00:00:00'),
 Timestamp('2012-06-15 00:00:00'),
 Timestamp('2012-07-20 00:00:00'),
 Timestamp('2012-08-17 00:00:00')]

移动(领先和滞后)数据

移动 指的是通过时间向后和向前移动数据。Series 和 DataFrame 都有一个shift方法,用于进行简单的向前或向后移位,保持索引不变:

py 复制代码
In [91]: ts = pd.Series(np.random.standard_normal(4),
 ....:                index=pd.date_range("2000-01-01", periods=4, freq="M"))

In [92]: ts
Out[92]: 
2000-01-31   -0.066748
2000-02-29    0.838639
2000-03-31   -0.117388
2000-04-30   -0.517795
Freq: M, dtype: float64

In [93]: ts.shift(2)
Out[93]: 
2000-01-31         NaN
2000-02-29         NaN
2000-03-31   -0.066748
2000-04-30    0.838639
Freq: M, dtype: float64

In [94]: ts.shift(-2)
Out[94]: 
2000-01-31   -0.117388
2000-02-29   -0.517795
2000-03-31         NaN
2000-04-30         NaN
Freq: M, dtype: float64

当我们这样移动时,缺失数据会在时间序列的开始或结束引入。

shift的一个常见用法是计算时间序列或多个时间序列的连续百分比变化作为 DataFrame 列。这表示为:

py 复制代码
ts / ts.shift(1) - 1

因为无时区移位会保持索引不变,所以会丢失一些数据。因此,如果知道频率,可以将其传递给shift以推进时间戳,而不仅仅是数据:

py 复制代码
In [95]: ts.shift(2, freq="M")
Out[95]: 
2000-03-31   -0.066748
2000-04-30    0.838639
2000-05-31   -0.117388
2000-06-30   -0.517795
Freq: M, dtype: float64

也可以传递其他频率,这样可以在如何领先和滞后数据方面提供一些灵活性:

py 复制代码
In [96]: ts.shift(3, freq="D")
Out[96]: 
2000-02-03   -0.066748
2000-03-03    0.838639
2000-04-03   -0.117388
2000-05-03   -0.517795
dtype: float64

In [97]: ts.shift(1, freq="90T")
Out[97]: 
2000-01-31 01:30:00   -0.066748
2000-02-29 01:30:00    0.838639
2000-03-31 01:30:00   -0.117388
2000-04-30 01:30:00   -0.517795
dtype: float64

这里的T代表分钟。请注意,这里的freq参数表示要应用于时间戳的偏移量,但它不会改变数据的基础频率(如果有的话)。

使用偏移移动日期

pandas 日期偏移也可以与datetimeTimestamp对象一起使用:

py 复制代码
In [98]: from pandas.tseries.offsets import Day, MonthEnd

In [99]: now = datetime(2011, 11, 17)

In [100]: now + 3 * Day()
Out[100]: Timestamp('2011-11-20 00:00:00')

如果添加像MonthEnd这样的锚定偏移,第一个增量将根据频率规则"向前滚动"日期到下一个日期:

py 复制代码
In [101]: now + MonthEnd()
Out[101]: Timestamp('2011-11-30 00:00:00')

In [102]: now + MonthEnd(2)
Out[102]: Timestamp('2011-12-31 00:00:00')

锚定偏移可以通过简单使用它们的rollforwardrollback方法明确地"滚动"日期向前或向后:

py 复制代码
In [103]: offset = MonthEnd()

In [104]: offset.rollforward(now)
Out[104]: Timestamp('2011-11-30 00:00:00')

In [105]: offset.rollback(now)
Out[105]: Timestamp('2011-10-31 00:00:00')

日期偏移的一个创造性用法是将这些方法与groupby一起使用:

py 复制代码
In [106]: ts = pd.Series(np.random.standard_normal(20),
 .....:                index=pd.date_range("2000-01-15", periods=20, freq="4D")
)

In [107]: ts
Out[107]: 
2000-01-15   -0.116696
2000-01-19    2.389645
2000-01-23   -0.932454
2000-01-27   -0.229331
2000-01-31   -1.140330
2000-02-04    0.439920
2000-02-08   -0.823758
2000-02-12   -0.520930
2000-02-16    0.350282
2000-02-20    0.204395
2000-02-24    0.133445
2000-02-28    0.327905
2000-03-03    0.072153
2000-03-07    0.131678
2000-03-11   -1.297459
2000-03-15    0.997747
2000-03-19    0.870955
2000-03-23   -0.991253
2000-03-27    0.151699
2000-03-31    1.266151
Freq: 4D, dtype: float64

In [108]: ts.groupby(MonthEnd().rollforward).mean()
Out[108]: 
2000-01-31   -0.005833
2000-02-29    0.015894
2000-03-31    0.150209
dtype: float64

当然,更简单更快的方法是使用resample(我们将在重新采样和频率转换中更深入地讨论这个问题):

py 复制代码
In [109]: ts.resample("M").mean()
Out[109]: 
2000-01-31   -0.005833
2000-02-29    0.015894
2000-03-31    0.150209
Freq: M, dtype: float64

11.4 时区处理

与时区一起工作可能是时间序列操作中最不愉快的部分之一。因此,许多时间序列用户选择在协调世界时UTC中处理时间序列,这是地理独立的国际标准。时区表示为与 UTC 的偏移;例如,纽约在夏令时(DST)期间比 UTC 晚四个小时,在其他时间比 UTC 晚五个小时。

在 Python 中,时区信息来自第三方pytz库(可通过 pip 或 conda 安装),该库公开了Olson 数据库,这是世界时区信息的编译。这对于历史数据尤为重要,因为夏令时转换日期(甚至 UTC 偏移)已根据地区法律多次更改。在美国,自 1900 年以来,夏令时转换时间已经多次更改!

有关pytz库的详细信息,您需要查看该库的文档。就本书而言,pandas 封装了pytz的功能,因此您可以忽略其 API 以外的时区名称。由于 pandas 对pytz有硬性依赖,因此不需要单独安装它。时区名称可以在交互式和文档中找到:

py 复制代码
In [110]: import pytz

In [111]: pytz.common_timezones[-5:]
Out[111]: ['US/Eastern', 'US/Hawaii', 'US/Mountain', 'US/Pacific', 'UTC']

要从pytz中获取时区对象,请使用pytz.timezone

py 复制代码
In [112]: tz = pytz.timezone("America/New_York")

In [113]: tz
Out[113]: <DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>

pandas 中的方法将接受时区名称或这些对象。

时区本地化和转换

默认情况下,pandas 中的时间序列是时区无关的。例如,考虑以下时间序列:

py 复制代码
In [114]: dates = pd.date_range("2012-03-09 09:30", periods=6)

In [115]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)

In [116]: ts
Out[116]: 
2012-03-09 09:30:00   -0.202469
2012-03-10 09:30:00    0.050718
2012-03-11 09:30:00    0.639869
2012-03-12 09:30:00    0.597594
2012-03-13 09:30:00   -0.797246
2012-03-14 09:30:00    0.472879
Freq: D, dtype: float64

索引的tz字段为None

py 复制代码
In [117]: print(ts.index.tz)
None

可以生成带有时区设置的日期范围:

py 复制代码
In [118]: pd.date_range("2012-03-09 09:30", periods=10, tz="UTC")
Out[118]: 
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
 '2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
 '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
 '2012-03-15 09:30:00+00:00', '2012-03-16 09:30:00+00:00',
 '2012-03-17 09:30:00+00:00', '2012-03-18 09:30:00+00:00'],
 dtype='datetime64[ns, UTC]', freq='D')

从无时区转换为本地化 (重新解释为在特定时区中观察到)由tz_localize方法处理:

py 复制代码
In [119]: ts
Out[119]: 
2012-03-09 09:30:00   -0.202469
2012-03-10 09:30:00    0.050718
2012-03-11 09:30:00    0.639869
2012-03-12 09:30:00    0.597594
2012-03-13 09:30:00   -0.797246
2012-03-14 09:30:00    0.472879
Freq: D, dtype: float64

In [120]: ts_utc = ts.tz_localize("UTC")

In [121]: ts_utc
Out[121]: 
2012-03-09 09:30:00+00:00   -0.202469
2012-03-10 09:30:00+00:00    0.050718
2012-03-11 09:30:00+00:00    0.639869
2012-03-12 09:30:00+00:00    0.597594
2012-03-13 09:30:00+00:00   -0.797246
2012-03-14 09:30:00+00:00    0.472879
Freq: D, dtype: float64

In [122]: ts_utc.index
Out[122]: 
DatetimeIndex(['2012-03-09 09:30:00+00:00', '2012-03-10 09:30:00+00:00',
 '2012-03-11 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
 '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00'],
 dtype='datetime64[ns, UTC]', freq='D')

一旦时间序列被本地化到特定的时区,它可以使用tz_convert转换为另一个时区:

py 复制代码
In [123]: ts_utc.tz_convert("America/New_York")
Out[123]: 
2012-03-09 04:30:00-05:00   -0.202469
2012-03-10 04:30:00-05:00    0.050718
2012-03-11 05:30:00-04:00    0.639869
2012-03-12 05:30:00-04:00    0.597594
2012-03-13 05:30:00-04:00   -0.797246
2012-03-14 05:30:00-04:00    0.472879
Freq: D, dtype: float64

在前述时间序列的情况下,该时间序列跨越了America/New_York时区的夏令时转换,我们可以将其本地化为美国东部时间,然后转换为 UTC 或柏林时间:

py 复制代码
In [124]: ts_eastern = ts.tz_localize("America/New_York")

In [125]: ts_eastern.tz_convert("UTC")
Out[125]: 
2012-03-09 14:30:00+00:00   -0.202469
2012-03-10 14:30:00+00:00    0.050718
2012-03-11 13:30:00+00:00    0.639869
2012-03-12 13:30:00+00:00    0.597594
2012-03-13 13:30:00+00:00   -0.797246
2012-03-14 13:30:00+00:00    0.472879
dtype: float64

In [126]: ts_eastern.tz_convert("Europe/Berlin")
Out[126]: 
2012-03-09 15:30:00+01:00   -0.202469
2012-03-10 15:30:00+01:00    0.050718
2012-03-11 14:30:00+01:00    0.639869
2012-03-12 14:30:00+01:00    0.597594
2012-03-13 14:30:00+01:00   -0.797246
2012-03-14 14:30:00+01:00    0.472879
dtype: float64

tz_localizetz_convert也是DatetimeIndex的实例方法:

py 复制代码
In [127]: ts.index.tz_localize("Asia/Shanghai")
Out[127]: 
DatetimeIndex(['2012-03-09 09:30:00+08:00', '2012-03-10 09:30:00+08:00',
 '2012-03-11 09:30:00+08:00', '2012-03-12 09:30:00+08:00',
 '2012-03-13 09:30:00+08:00', '2012-03-14 09:30:00+08:00'],
 dtype='datetime64[ns, Asia/Shanghai]', freq=None)

注意

本地化无时区时间戳还会检查夏令时转换周围的模糊或不存在的时间。

与时区感知时间戳对象的操作

类似于时间序列和日期范围,个别Timestamp对象也可以从无时区转换为时区感知,并从一个时区转换为另一个时区:

py 复制代码
In [128]: stamp = pd.Timestamp("2011-03-12 04:00")

In [129]: stamp_utc = stamp.tz_localize("utc")

In [130]: stamp_utc.tz_convert("America/New_York")
Out[130]: Timestamp('2011-03-11 23:00:00-0500', tz='America/New_York')

创建Timestamp时也可以传递时区:

py 复制代码
In [131]: stamp_moscow = pd.Timestamp("2011-03-12 04:00", tz="Europe/Moscow")

In [132]: stamp_moscow
Out[132]: Timestamp('2011-03-12 04:00:00+0300', tz='Europe/Moscow')

时区感知的Timestamp对象在内部以自 Unix 纪元(1970 年 1 月 1 日)以来的纳秒为单位存储 UTC 时间戳值,因此更改时区不会改变内部 UTC 值:

py 复制代码
In [133]: stamp_utc.value
Out[133]: 1299902400000000000

In [134]: stamp_utc.tz_convert("America/New_York").value
Out[134]: 1299902400000000000

在使用 pandas 的DateOffset对象执行时间算术时,pandas 会尽可能尊重夏令时转换。这里我们构造了发生在夏令时转换之前的时间戳(向前和向后)。首先,在转换为夏令时前 30 分钟:

py 复制代码
In [135]: stamp = pd.Timestamp("2012-03-11 01:30", tz="US/Eastern")

In [136]: stamp
Out[136]: Timestamp('2012-03-11 01:30:00-0500', tz='US/Eastern')

In [137]: stamp + Hour()
Out[137]: Timestamp('2012-03-11 03:30:00-0400', tz='US/Eastern')

然后,在夏令时转换前 90 分钟:

py 复制代码
In [138]: stamp = pd.Timestamp("2012-11-04 00:30", tz="US/Eastern")

In [139]: stamp
Out[139]: Timestamp('2012-11-04 00:30:00-0400', tz='US/Eastern')

In [140]: stamp + 2 * Hour()
Out[140]: Timestamp('2012-11-04 01:30:00-0500', tz='US/Eastern')

不同时区之间的操作

如果将具有不同时区的两个时间序列组合,结果将是 UTC。由于时间戳在 UTC 下存储,这是一个简单的操作,不需要转换:

py 复制代码
In [141]: dates = pd.date_range("2012-03-07 09:30", periods=10, freq="B")

In [142]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)

In [143]: ts
Out[143]: 
2012-03-07 09:30:00    0.522356
2012-03-08 09:30:00   -0.546348
2012-03-09 09:30:00   -0.733537
2012-03-12 09:30:00    1.302736
2012-03-13 09:30:00    0.022199
2012-03-14 09:30:00    0.364287
2012-03-15 09:30:00   -0.922839
2012-03-16 09:30:00    0.312656
2012-03-19 09:30:00   -1.128497
2012-03-20 09:30:00   -0.333488
Freq: B, dtype: float64

In [144]: ts1 = ts[:7].tz_localize("Europe/London")

In [145]: ts2 = ts1[2:].tz_convert("Europe/Moscow")

In [146]: result = ts1 + ts2

In [147]: result.index
Out[147]: 
DatetimeIndex(['2012-03-07 09:30:00+00:00', '2012-03-08 09:30:00+00:00',
 '2012-03-09 09:30:00+00:00', '2012-03-12 09:30:00+00:00',
 '2012-03-13 09:30:00+00:00', '2012-03-14 09:30:00+00:00',
 '2012-03-15 09:30:00+00:00'],
 dtype='datetime64[ns, UTC]', freq=None)

不支持在时区无关和时区感知数据之间进行操作,会引发异常。*## 11.5 周期和周期算术

Periods 代表时间跨度,如天、月、季度或年。pandas.Period类表示这种数据类型,需要一个字符串或整数和一个来自 Table 11.4 的支持频率:

py 复制代码
In [148]: p = pd.Period("2011", freq="A-DEC")

In [149]: p
Out[149]: Period('2011', 'A-DEC')

在这种情况下,Period对象表示从 2011 年 1 月 1 日到 2011 年 12 月 31 日的完整时间跨度。方便的是,从周期中添加和减去整数会改变它们的频率:

py 复制代码
In [150]: p + 5
Out[150]: Period('2016', 'A-DEC')

In [151]: p - 2
Out[151]: Period('2009', 'A-DEC')

如果两个周期具有相同的频率,则它们之间的差异是单位之间的数量作为日期偏移量:

py 复制代码
In [152]: pd.Period("2014", freq="A-DEC") - p
Out[152]: <3 * YearEnds: month=12>

可以使用period_range函数构建周期的常规范围:

py 复制代码
In [153]: periods = pd.period_range("2000-01-01", "2000-06-30", freq="M")

In [154]: periods
Out[154]: PeriodIndex(['2000-01', '2000-02', '2000-03', '2000-04', '2000-05', '20
00-06'], dtype='period[M]')

PeriodIndex类存储一系列周期,并可以作为任何 pandas 数据结构中的轴索引:

py 复制代码
In [155]: pd.Series(np.random.standard_normal(6), index=periods)
Out[155]: 
2000-01   -0.514551
2000-02   -0.559782
2000-03   -0.783408
2000-04   -1.797685
2000-05   -0.172670
2000-06    0.680215
Freq: M, dtype: float64

如果您有一个字符串数组,也可以使用PeriodIndex类,其中所有值都是周期:

py 复制代码
In [156]: values = ["2001Q3", "2002Q2", "2003Q1"]

In [157]: index = pd.PeriodIndex(values, freq="Q-DEC")

In [158]: index
Out[158]: PeriodIndex(['2001Q3', '2002Q2', '2003Q1'], dtype='period[Q-DEC]')

周期频率转换

周期和PeriodIndex对象可以使用它们的asfreq方法转换为另一个频率。例如,假设我们有一个年度周期,想要将其转换为每月周期,可以在年初或年末进行。可以这样做:

py 复制代码
In [159]: p = pd.Period("2011", freq="A-DEC")

In [160]: p
Out[160]: Period('2011', 'A-DEC')

In [161]: p.asfreq("M", how="start")
Out[161]: Period('2011-01', 'M')

In [162]: p.asfreq("M", how="end")
Out[162]: Period('2011-12', 'M')

In [163]: p.asfreq("M")
Out[163]: Period('2011-12', 'M')

您可以将Period("2011", "A-DEC")看作是指向一段时间的光标,由月度周期细分。参见 Figure 11.1 以了解这一点。对于以 12 月以外的月份结束的财政年度,相应的月度子周期是不同的:

py 复制代码
In [164]: p = pd.Period("2011", freq="A-JUN")

In [165]: p
Out[165]: Period('2011', 'A-JUN')

In [166]: p.asfreq("M", how="start")
Out[166]: Period('2010-07', 'M')

In [167]: p.asfreq("M", how="end")
Out[167]: Period('2011-06', 'M')

图 11.1:周期频率转换示例

当您从高频率转换为低频率时,pandas 会确定子周期,取决于超级周期"属于"哪里。例如,在A-JUN频率中,月份Aug-2011实际上是2012周期的一部分:

py 复制代码
In [168]: p = pd.Period("Aug-2011", "M")

In [169]: p.asfreq("A-JUN")
Out[169]: Period('2012', 'A-JUN')

整个PeriodIndex对象或时间序列也可以使用相同的语义进行类似转换:

py 复制代码
In [170]: periods = pd.period_range("2006", "2009", freq="A-DEC")

In [171]: ts = pd.Series(np.random.standard_normal(len(periods)), index=periods)

In [172]: ts
Out[172]: 
2006    1.607578
2007    0.200381
2008   -0.834068
2009   -0.302988
Freq: A-DEC, dtype: float64

In [173]: ts.asfreq("M", how="start")
Out[173]: 
2006-01    1.607578
2007-01    0.200381
2008-01   -0.834068
2009-01   -0.302988
Freq: M, dtype: float64

在这里,年度周期被替换为对应于每个年度周期中第一个月的月度周期。如果我们希望每年的最后一个工作日,可以使用"B"频率并指示我们想要周期的结束:

py 复制代码
In [174]: ts.asfreq("B", how="end")
Out[174]: 
2006-12-29    1.607578
2007-12-31    0.200381
2008-12-31   -0.834068
2009-12-31   -0.302988
Freq: B, dtype: float64

季度周期频率

季度数据在会计、金融和其他领域中很常见。许多季度数据是相对于财年结束 报告的,通常是一年中的 12 个月的最后一个日历日或工作日。因此,期间 2012Q4 根据财年结束日期的不同具有不同的含义。pandas 支持所有 12 种可能的季度频率,从 Q-JANQ-DEC

py 复制代码
In [175]: p = pd.Period("2012Q4", freq="Q-JAN")

In [176]: p
Out[176]: Period('2012Q4', 'Q-JAN')

在财年结束于一月的情况下,2012Q4 从 2011 年 11 月到 2012 年 1 月,您可以通过转换为每日频率来检查:

py 复制代码
In [177]: p.asfreq("D", how="start")
Out[177]: Period('2011-11-01', 'D')

In [178]: p.asfreq("D", how="end")
Out[178]: Period('2012-01-31', 'D')

参见 Figure 11.2 进行说明。

Figure 11.2: 不同的季度频率约定

因此,可以进行方便的期间算术;例如,要获取季度倒数第二个工作日下午 4 点的时间戳,可以执行以下操作:

py 复制代码
In [179]: p4pm = (p.asfreq("B", how="end") - 1).asfreq("T", how="start") + 16 * 6
0

In [180]: p4pm
Out[180]: Period('2012-01-30 16:00', 'T')

In [181]: p4pm.to_timestamp()
Out[181]: Timestamp('2012-01-30 16:00:00')

to_timestamp 方法默认返回期间开始的 Timestamp

您可以使用 pandas.period_range 生成季度范围。算术也是相同的:

py 复制代码
In [182]: periods = pd.period_range("2011Q3", "2012Q4", freq="Q-JAN")

In [183]: ts = pd.Series(np.arange(len(periods)), index=periods)

In [184]: ts
Out[184]: 
2011Q3    0
2011Q4    1
2012Q1    2
2012Q2    3
2012Q3    4
2012Q4    5
Freq: Q-JAN, dtype: int64

In [185]: new_periods = (periods.asfreq("B", "end") - 1).asfreq("H", "start") + 1
6

In [186]: ts.index = new_periods.to_timestamp()

In [187]: ts
Out[187]: 
2010-10-28 16:00:00    0
2011-01-28 16:00:00    1
2011-04-28 16:00:00    2
2011-07-28 16:00:00    3
2011-10-28 16:00:00    4
2012-01-30 16:00:00    5
dtype: int64

将时间戳转换为期间(以及相反)

通过 to_period 方法,以时间戳索引的 Series 和 DataFrame 对象可以转换为期间:

py 复制代码
In [188]: dates = pd.date_range("2000-01-01", periods=3, freq="M")

In [189]: ts = pd.Series(np.random.standard_normal(3), index=dates)

In [190]: ts
Out[190]: 
2000-01-31    1.663261
2000-02-29   -0.996206
2000-03-31    1.521760
Freq: M, dtype: float64

In [191]: pts = ts.to_period()

In [192]: pts
Out[192]: 
2000-01    1.663261
2000-02   -0.996206
2000-03    1.521760
Freq: M, dtype: float64

由于期间指的是不重叠的时间跨度,因此给定频率的时间戳只能属于一个期间。虽然新的 PeriodIndex 的频率默认情况下是根据时间戳推断的,但您可以指定任何支持的频率(大多数列在 Table 11.4 中列出的频率都受支持)。在结果中有重复期间也没有问题:

py 复制代码
In [193]: dates = pd.date_range("2000-01-29", periods=6)

In [194]: ts2 = pd.Series(np.random.standard_normal(6), index=dates)

In [195]: ts2
Out[195]: 
2000-01-29    0.244175
2000-01-30    0.423331
2000-01-31   -0.654040
2000-02-01    2.089154
2000-02-02   -0.060220
2000-02-03   -0.167933
Freq: D, dtype: float64

In [196]: ts2.to_period("M")
Out[196]: 
2000-01    0.244175
2000-01    0.423331
2000-01   -0.654040
2000-02    2.089154
2000-02   -0.060220
2000-02   -0.167933
Freq: M, dtype: float64

要转换回时间戳,请使用 to_timestamp 方法,该方法返回一个 DatetimeIndex

py 复制代码
In [197]: pts = ts2.to_period()

In [198]: pts
Out[198]: 
2000-01-29    0.244175
2000-01-30    0.423331
2000-01-31   -0.654040
2000-02-01    2.089154
2000-02-02   -0.060220
2000-02-03   -0.167933
Freq: D, dtype: float64

In [199]: pts.to_timestamp(how="end")
Out[199]: 
2000-01-29 23:59:59.999999999    0.244175
2000-01-30 23:59:59.999999999    0.423331
2000-01-31 23:59:59.999999999   -0.654040
2000-02-01 23:59:59.999999999    2.089154
2000-02-02 23:59:59.999999999   -0.060220
2000-02-03 23:59:59.999999999   -0.167933
Freq: D, dtype: float64

从数组创建 PeriodIndex

固定频率数据集有时会存储在跨多列的时间跨度信息中。例如,在这个宏观经济数据集中,年份和季度在不同的列中:

py 复制代码
In [200]: data = pd.read_csv("examples/macrodata.csv")

In [201]: data.head(5)
Out[201]: 
 year  quarter   realgdp  realcons  realinv  realgovt  realdpi    cpi 
0  1959        1  2710.349    1707.4  286.898   470.045   1886.9  28.98  \
1  1959        2  2778.801    1733.7  310.859   481.301   1919.7  29.15 
2  1959        3  2775.488    1751.8  289.226   491.260   1916.4  29.35 
3  1959        4  2785.204    1753.7  299.356   484.052   1931.3  29.37 
4  1960        1  2847.699    1770.5  331.722   462.199   1955.5  29.54 
 m1  tbilrate  unemp      pop  infl  realint 
0  139.7      2.82    5.8  177.146  0.00     0.00 
1  141.7      3.08    5.1  177.830  2.34     0.74 
2  140.5      3.82    5.3  178.657  2.74     1.09 
3  140.0      4.33    5.6  179.386  0.27     4.06 
4  139.6      3.50    5.2  180.007  2.31     1.19 

In [202]: data["year"]
Out[202]: 
0      1959
1      1959
2      1959
3      1959
4      1960
 ... 
198    2008
199    2008
200    2009
201    2009
202    2009
Name: year, Length: 203, dtype: int64

In [203]: data["quarter"]
Out[203]: 
0      1
1      2
2      3
3      4
4      1
 ..
198    3
199    4
200    1
201    2
202    3
Name: quarter, Length: 203, dtype: int64

通过将这些数组传递给 PeriodIndex 并指定频率,可以将它们组合成 DataFrame 的索引:

py 复制代码
In [204]: index = pd.PeriodIndex(year=data["year"], quarter=data["quarter"],
 .....:                        freq="Q-DEC")

In [205]: index
Out[205]: 
PeriodIndex(['1959Q1', '1959Q2', '1959Q3', '1959Q4', '1960Q1', '1960Q2',
 '1960Q3', '1960Q4', '1961Q1', '1961Q2',
 ...
 '2007Q2', '2007Q3', '2007Q4', '2008Q1', '2008Q2', '2008Q3',
 '2008Q4', '2009Q1', '2009Q2', '2009Q3'],
 dtype='period[Q-DEC]', length=203)

In [206]: data.index = index

In [207]: data["infl"]
Out[207]: 
1959Q1    0.00
1959Q2    2.34
1959Q3    2.74
1959Q4    0.27
1960Q1    2.31
 ... 
2008Q3   -3.16
2008Q4   -8.79
2009Q1    0.94
2009Q2    3.37
2009Q3    3.56
Freq: Q-DEC, Name: infl, Length: 203, dtype: float64

11.6 重新采样和频率转换

重新采样 指的是将时间序列从一种频率转换为另一种频率的过程。将高频数据聚合到低频称为下采样 ,而将低频转换为高频称为上采样 。并非所有重新采样都属于这两类;例如,将 W-WED(每周三)转换为 W-FRI 既不是上采样也不是下采样。

pandas 对象配备有一个 resample 方法,这是所有频率转换的工作函数。resample 具有类似于 groupby 的 API;您调用 resample 来对数据进行分组,然后调用聚合函数:

py 复制代码
In [208]: dates = pd.date_range("2000-01-01", periods=100)

In [209]: ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)

In [210]: ts
Out[210]: 
2000-01-01    0.631634
2000-01-02   -1.594313
2000-01-03   -1.519937
2000-01-04    1.108752
2000-01-05    1.255853
 ... 
2000-04-05   -0.423776
2000-04-06    0.789740
2000-04-07    0.937568
2000-04-08   -2.253294
2000-04-09   -1.772919
Freq: D, Length: 100, dtype: float64

In [211]: ts.resample("M").mean()
Out[211]: 
2000-01-31   -0.165893
2000-02-29    0.078606
2000-03-31    0.223811
2000-04-30   -0.063643
Freq: M, dtype: float64

In [212]: ts.resample("M", kind="period").mean()
Out[212]: 
2000-01   -0.165893
2000-02    0.078606
2000-03    0.223811
2000-04   -0.063643
Freq: M, dtype: float64

resample 是一个灵活的方法,可用于处理大型时间序列。以下部分的示例说明了其语义和用法。Table 11.5 总结了一些选项。

Table 11.5: resample 方法参数

参数 描述
rule 字符串、DateOffset 或时间增量,指示所需的重新采样频率(例如,'M'、'5min' 或 Second(15)
axis 要重新采样的轴;默认 axis=0
fill_method 在上采样时如何插值,例如 "ffill""bfill";默认情况下不进行插值
closed 在下采样时,每个间隔的哪一端是闭合的(包含的),"right""left"
label 在下采样时,如何标记聚合结果,使用 "right""left" 边界(例如,9:30 到 9:35 五分钟间隔可以标记为 9:309:35
limit 在向前或向后填充时,要填充的最大周期数
kind 聚合到期间("period")或时间戳("timestamp");默认为时间序列具有的索引类型
convention 在重新采样周期时,用于将低频周期转换为高频的约定("start""end");默认为"start"
origin 用于确定重新采样箱边缘的"基准"时间戳;也可以是"epoch""start""start_day""end""end_day"之一;有关完整详细信息,请参阅resample文档字符串
offset 添加到原点的偏移时间间隔;默认为None

下采样

下采样 是将数据聚合到常规、较低的频率。您正在聚合的数据不需要经常固定;所需频率定义了用于将时间序列切片成块以进行聚合的箱边缘 。例如,要转换为每月,"M""BM",您需要将数据切割成一个月的间隔。每个间隔被称为半开放 ;数据点只能属于一个间隔,间隔的并集必须构成整个时间范围。在使用resample对数据进行下采样时,有几件事需要考虑:

  • 每个间隔的哪一侧是关闭的

  • 如何为每个聚合的箱子打标签,可以是间隔的开始或结束

为了说明,让我们看一些一分钟频率的数据:

py 复制代码
In [213]: dates = pd.date_range("2000-01-01", periods=12, freq="T")

In [214]: ts = pd.Series(np.arange(len(dates)), index=dates)

In [215]: ts
Out[215]: 
2000-01-01 00:00:00     0
2000-01-01 00:01:00     1
2000-01-01 00:02:00     2
2000-01-01 00:03:00     3
2000-01-01 00:04:00     4
2000-01-01 00:05:00     5
2000-01-01 00:06:00     6
2000-01-01 00:07:00     7
2000-01-01 00:08:00     8
2000-01-01 00:09:00     9
2000-01-01 00:10:00    10
2000-01-01 00:11:00    11
Freq: T, dtype: int64

假设您想要通过将每组的总和来将这些数据聚合成五分钟的块或

py 复制代码
In [216]: ts.resample("5min").sum()
Out[216]: 
2000-01-01 00:00:00    10
2000-01-01 00:05:00    35
2000-01-01 00:10:00    21
Freq: 5T, dtype: int64

您传递的频率定义了以五分钟为增量的箱边缘。对于这个频率,默认情况下 箱边缘是包含的,因此00:00值包含在00:0000:05间隔中,而00:05值不包含在该间隔中。¹

py 复制代码
In [217]: ts.resample("5min", closed="right").sum()
Out[217]: 
1999-12-31 23:55:00     0
2000-01-01 00:00:00    15
2000-01-01 00:05:00    40
2000-01-01 00:10:00    11
Freq: 5T, dtype: int64

生成的时间序列由每个箱子左侧的时间戳标记。通过传递label="right",您可以使用右侧箱子边缘对它们进行标记:

py 复制代码
In [218]: ts.resample("5min", closed="right", label="right").sum()
Out[218]: 
2000-01-01 00:00:00     0
2000-01-01 00:05:00    15
2000-01-01 00:10:00    40
2000-01-01 00:15:00    11
Freq: 5T, dtype: int64

请参见图 11.3,以了解将分钟频率数据重新采样为五分钟频率的示例。

图 11.3:五分钟重新采样示例,显示了闭合、标签约定

最后,您可能希望将结果索引向前移动一定量,例如从右边减去一秒,以便更清楚地了解时间戳所指的间隔。要执行此操作,请向结果索引添加一个偏移量:

py 复制代码
In [219]: from pandas.tseries.frequencies import to_offset

In [220]: result = ts.resample("5min", closed="right", label="right").sum()

In [221]: result.index = result.index + to_offset("-1s")

In [222]: result
Out[222]: 
1999-12-31 23:59:59     0
2000-01-01 00:04:59    15
2000-01-01 00:09:59    40
2000-01-01 00:14:59    11
Freq: 5T, dtype: int64
开盘-最高-最低-收盘(OHLC)重新采样

在金融领域,聚合时间序列的一种流行方式是为每个桶计算四个值:第一个(开盘)、最后一个(收盘)、最大值(最高)和最小值(最低)。通过使用ohlc聚合函数,您将获得一个包含这四个聚合值的列的 DataFrame,这四个值可以在单个函数调用中高效计算:

py 复制代码
In [223]: ts = pd.Series(np.random.permutation(np.arange(len(dates))), index=date
s)

In [224]: ts.resample("5min").ohlc()
Out[224]: 
 open  high  low  close
2000-01-01 00:00:00     8     8    1      5
2000-01-01 00:05:00     6    11    2      2
2000-01-01 00:10:00     0     7    0      7

上采样和插值

上采样是将数据从较低频率转换为较高频率,不需要聚合。让我们考虑一个包含一些周数据的 DataFrame:

py 复制代码
In [225]: frame = pd.DataFrame(np.random.standard_normal((2, 4)),
 .....:                      index=pd.date_range("2000-01-01", periods=2,
 .....:                                          freq="W-WED"),
 .....:                      columns=["Colorado", "Texas", "New York", "Ohio"])

In [226]: frame
Out[226]: 
 Colorado     Texas  New York      Ohio
2000-01-05 -0.896431  0.927238  0.482284 -0.867130
2000-01-12  0.493841 -0.155434  1.397286  1.507055

当您使用聚合函数处理这些数据时,每组只有一个值,缺失值会导致间隙。我们使用asfreq方法将其转换为更高的频率,而不进行任何聚合:

py 复制代码
In [227]: df_daily = frame.resample("D").asfreq()

In [228]: df_daily
Out[228]: 
 Colorado     Texas  New York      Ohio
2000-01-05 -0.896431  0.927238  0.482284 -0.867130
2000-01-06       NaN       NaN       NaN       NaN
2000-01-07       NaN       NaN       NaN       NaN
2000-01-08       NaN       NaN       NaN       NaN
2000-01-09       NaN       NaN       NaN       NaN
2000-01-10       NaN       NaN       NaN       NaN
2000-01-11       NaN       NaN       NaN       NaN
2000-01-12  0.493841 -0.155434  1.397286  1.507055

假设您希望在非星期三填充每周值。与fillnareindex方法中可用的填充或插值方法相同,对于重新采样也是可用的:

py 复制代码
In [229]: frame.resample("D").ffill()
Out[229]: 
 Colorado     Texas  New York      Ohio
2000-01-05 -0.896431  0.927238  0.482284 -0.867130
2000-01-06 -0.896431  0.927238  0.482284 -0.867130
2000-01-07 -0.896431  0.927238  0.482284 -0.867130
2000-01-08 -0.896431  0.927238  0.482284 -0.867130
2000-01-09 -0.896431  0.927238  0.482284 -0.867130
2000-01-10 -0.896431  0.927238  0.482284 -0.867130
2000-01-11 -0.896431  0.927238  0.482284 -0.867130
2000-01-12  0.493841 -0.155434  1.397286  1.507055

您也可以选择仅填充一定数量的周期,以限制使用观察值的范围:

py 复制代码
In [230]: frame.resample("D").ffill(limit=2)
Out[230]: 
 Colorado     Texas  New York      Ohio
2000-01-05 -0.896431  0.927238  0.482284 -0.867130
2000-01-06 -0.896431  0.927238  0.482284 -0.867130
2000-01-07 -0.896431  0.927238  0.482284 -0.867130
2000-01-08       NaN       NaN       NaN       NaN
2000-01-09       NaN       NaN       NaN       NaN
2000-01-10       NaN       NaN       NaN       NaN
2000-01-11       NaN       NaN       NaN       NaN
2000-01-12  0.493841 -0.155434  1.397286  1.507055

值得注意的是,新的日期索引不一定与旧的完全重合:

py 复制代码
In [231]: frame.resample("W-THU").ffill()
Out[231]: 
 Colorado     Texas  New York      Ohio
2000-01-06 -0.896431  0.927238  0.482284 -0.867130
2000-01-13  0.493841 -0.155434  1.397286  1.507055

使用周期重新采样

按周期索引的数据重新采样类似于时间戳:

py 复制代码
In [232]: frame = pd.DataFrame(np.random.standard_normal((24, 4)),
 .....:                      index=pd.period_range("1-2000", "12-2001",
 .....:                                            freq="M"),
 .....:                      columns=["Colorado", "Texas", "New York", "Ohio"])

In [233]: frame.head()
Out[233]: 
 Colorado     Texas  New York      Ohio
2000-01 -1.179442  0.443171  1.395676 -0.529658
2000-02  0.787358  0.248845  0.743239  1.267746
2000-03  1.302395 -0.272154 -0.051532 -0.467740
2000-04 -1.040816  0.426419  0.312945 -1.115689
2000-05  1.234297 -1.893094 -1.661605 -0.005477

In [234]: annual_frame = frame.resample("A-DEC").mean()

In [235]: annual_frame
Out[235]: 
 Colorado     Texas  New York      Ohio
2000  0.487329  0.104466  0.020495 -0.273945
2001  0.203125  0.162429  0.056146 -0.103794

上采样更加微妙,因为在重新采样之前,您必须决定将值放在新频率的时间跨度的哪一端。convention参数默认为"start",但也可以是"end"

py 复制代码
# Q-DEC: Quarterly, year ending in December
In [236]: annual_frame.resample("Q-DEC").ffill()
Out[236]: 
 Colorado     Texas  New York      Ohio
2000Q1  0.487329  0.104466  0.020495 -0.273945
2000Q2  0.487329  0.104466  0.020495 -0.273945
2000Q3  0.487329  0.104466  0.020495 -0.273945
2000Q4  0.487329  0.104466  0.020495 -0.273945
2001Q1  0.203125  0.162429  0.056146 -0.103794
2001Q2  0.203125  0.162429  0.056146 -0.103794
2001Q3  0.203125  0.162429  0.056146 -0.103794
2001Q4  0.203125  0.162429  0.056146 -0.103794

In [237]: annual_frame.resample("Q-DEC", convention="end").asfreq()
Out[237]: 
 Colorado     Texas  New York      Ohio
2000Q4  0.487329  0.104466  0.020495 -0.273945
2001Q1       NaN       NaN       NaN       NaN
2001Q2       NaN       NaN       NaN       NaN
2001Q3       NaN       NaN       NaN       NaN
2001Q4  0.203125  0.162429  0.056146 -0.103794

由于周期指的是时间跨度,因此有关上采样和下采样的规则更为严格:

  • 在下采样中,目标频率必须是源频率的子周期

  • 在上采样中,目标频率必须是源频率的超周期

如果这些规则不满足,将会引发异常。这主要影响季度、年度和每周频率;例如,由Q-MAR定义的时间跨度只与A-MARA-JUNA-SEPA-DEC对齐:

py 复制代码
In [238]: annual_frame.resample("Q-MAR").ffill()
Out[238]: 
 Colorado     Texas  New York      Ohio
2000Q4  0.487329  0.104466  0.020495 -0.273945
2001Q1  0.487329  0.104466  0.020495 -0.273945
2001Q2  0.487329  0.104466  0.020495 -0.273945
2001Q3  0.487329  0.104466  0.020495 -0.273945
2001Q4  0.203125  0.162429  0.056146 -0.103794
2002Q1  0.203125  0.162429  0.056146 -0.103794
2002Q2  0.203125  0.162429  0.056146 -0.103794
2002Q3  0.203125  0.162429  0.056146 -0.103794

分组时间重采样

对于时间序列数据,resample方法在时间间隔化的基础上是一个组操作。这里是一个小例子表:

py 复制代码
In [239]: N = 15

In [240]: times = pd.date_range("2017-05-20 00:00", freq="1min", periods=N)

In [241]: df = pd.DataFrame({"time": times,
 .....:                    "value": np.arange(N)})

In [242]: df
Out[242]: 
 time  value
0  2017-05-20 00:00:00      0
1  2017-05-20 00:01:00      1
2  2017-05-20 00:02:00      2
3  2017-05-20 00:03:00      3
4  2017-05-20 00:04:00      4
5  2017-05-20 00:05:00      5
6  2017-05-20 00:06:00      6
7  2017-05-20 00:07:00      7
8  2017-05-20 00:08:00      8
9  2017-05-20 00:09:00      9
10 2017-05-20 00:10:00     10
11 2017-05-20 00:11:00     11
12 2017-05-20 00:12:00     12
13 2017-05-20 00:13:00     13
14 2017-05-20 00:14:00     14

在这里,我们可以按"time"索引,然后重采样:

py 复制代码
In [243]: df.set_index("time").resample("5min").count()
Out[243]: 
 value
time 
2017-05-20 00:00:00      5
2017-05-20 00:05:00      5
2017-05-20 00:10:00      5

假设一个 DataFrame 包含多个时间序列,由额外的分组键列标记:

py 复制代码
In [244]: df2 = pd.DataFrame({"time": times.repeat(3),
 .....:                     "key": np.tile(["a", "b", "c"], N),
 .....:                     "value": np.arange(N * 3.)})

In [245]: df2.head(7)
Out[245]: 
 time key  value
0 2017-05-20 00:00:00   a    0.0
1 2017-05-20 00:00:00   b    1.0
2 2017-05-20 00:00:00   c    2.0
3 2017-05-20 00:01:00   a    3.0
4 2017-05-20 00:01:00   b    4.0
5 2017-05-20 00:01:00   c    5.0
6 2017-05-20 00:02:00   a    6.0

为了对每个"key"值执行相同的重采样,我们引入pandas.Grouper对象:

py 复制代码
In [246]: time_key = pd.Grouper(freq="5min")

然后我们可以设置时间索引,按"key"time_key分组,并进行聚合:

py 复制代码
In [247]: resampled = (df2.set_index("time")
 .....:              .groupby(["key", time_key])
 .....:              .sum())

In [248]: resampled
Out[248]: 
 value
key time 
a   2017-05-20 00:00:00   30.0
 2017-05-20 00:05:00  105.0
 2017-05-20 00:10:00  180.0
b   2017-05-20 00:00:00   35.0
 2017-05-20 00:05:00  110.0
 2017-05-20 00:10:00  185.0
c   2017-05-20 00:00:00   40.0
 2017-05-20 00:05:00  115.0
 2017-05-20 00:10:00  190.0

In [249]: resampled.reset_index()
Out[249]: 
 key                time  value
0   a 2017-05-20 00:00:00   30.0
1   a 2017-05-20 00:05:00  105.0
2   a 2017-05-20 00:10:00  180.0
3   b 2017-05-20 00:00:00   35.0
4   b 2017-05-20 00:05:00  110.0
5   b 2017-05-20 00:10:00  185.0
6   c 2017-05-20 00:00:00   40.0
7   c 2017-05-20 00:05:00  115.0
8   c 2017-05-20 00:10:00  190.0

使用pandas.Grouper的一个限制是时间必须是 Series 或 DataFrame 的索引。

11.7 移动窗口函数

用于时间序列操作的一类重要的数组转换是在滑动窗口上评估统计数据和其他函数,或者使用指数衰减权重。这对于平滑嘈杂或有缺失数据的数据很有用。我将这些称为移动窗口函数,尽管它们包括没有固定长度窗口的函数,比如指数加权移动平均。与其他统计函数一样,这些函数也会自动排除缺失数据。

在深入研究之前,我们可以加载一些时间序列数据并将其重采样为工作日频率:

py 复制代码
In [250]: close_px_all = pd.read_csv("examples/stock_px.csv",
 .....:                            parse_dates=True, index_col=0)

In [251]: close_px = close_px_all[["AAPL", "MSFT", "XOM"]]

In [252]: close_px = close_px.resample("B").ffill()

我现在介绍rolling运算符,它的行为类似于resamplegroupby。它可以与一个window(表示为一定数量的周期)一起在 Series 或 DataFrame 上调用(请参见 Apple 价格与 250 日移动平均创建的图):

py 复制代码
In [253]: close_px["AAPL"].plot()
Out[253]: <Axes: >

In [254]: close_px["AAPL"].rolling(250).mean().plot()

图 11.4:苹果价格与 250 日移动平均值

表达式rolling(250)在行为上类似于groupby,但不是分组,而是创建一个对象,使得可以在 250 天滑动窗口上进行分组。因此,这里是苹果股价的 250 日移动窗口平均值。

默认情况下,滚动函数要求窗口中的所有值都不是 NA。这种行为可以更改以考虑缺失数据,特别是在时间序列开始时将少于window周期的数据(请参见苹果 250 日每日回报标准差):

py 复制代码
In [255]: plt.figure()
Out[255]: <Figure size 1000x600 with 0 Axes>

In [256]: std250 = close_px["AAPL"].pct_change().rolling(250, min_periods=10).std
()

In [257]: std250[5:12]
Out[257]: 
2003-01-09         NaN
2003-01-10         NaN
2003-01-13         NaN
2003-01-14         NaN
2003-01-15         NaN
2003-01-16    0.009628
2003-01-17    0.013818
Freq: B, Name: AAPL, dtype: float64

In [258]: std250.plot()

图 11.5:苹果 250 日每日回报标准差

要计算扩展窗口均值 ,请使用expanding运算符,而不是rolling。扩展均值从与滚动窗口相同的时间窗口开始,并增加窗口的大小,直到包含整个系列。std250时间序列上的扩展窗口均值如下所示:

py 复制代码
In [259]: expanding_mean = std250.expanding().mean()

在 DataFrame 上调用移动窗口函数会将转换应用于每一列(请参见股价 60 日移动平均(对数 y 轴)):

py 复制代码
In [261]: plt.style.use('grayscale')

In [262]: close_px.rolling(60).mean().plot(logy=True)

图 11.6:股价 60 日移动平均(对数 y 轴)

rolling函数还接受一个字符串,指示固定大小的时间偏移rolling()在移动窗口函数中,而不是一组周期。使用这种表示法对于不规则的时间序列很有用。这些是您可以传递给resample的相同字符串。例如,我们可以这样计算 20 天的滚动均值:

py 复制代码
In [263]: close_px.rolling("20D").mean()
Out[263]: 
 AAPL       MSFT        XOM
2003-01-02    7.400000  21.110000  29.220000
2003-01-03    7.425000  21.125000  29.230000
2003-01-06    7.433333  21.256667  29.473333
2003-01-07    7.432500  21.425000  29.342500
2003-01-08    7.402000  21.402000  29.240000
...                ...        ...        ...
2011-10-10  389.351429  25.602143  72.527857
2011-10-11  388.505000  25.674286  72.835000
2011-10-12  388.531429  25.810000  73.400714
2011-10-13  388.826429  25.961429  73.905000
2011-10-14  391.038000  26.048667  74.185333
[2292 rows x 3 columns]

指数加权函数

使用固定窗口大小和等权观测值的替代方法是指定一个恒定的衰减因子 ,以赋予更多权重给最近的观测值。有几种指定衰减因子的方法。一种流行的方法是使用跨度,使结果与窗口大小等于跨度的简单移动窗口函数可比较。

由于指数加权统计对最近的观察结果赋予更大的权重,与等权重版本相比,它更快地"适应"变化。

pandas 有ewm运算符(代表指数加权移动),与rollingexpanding配合使用。以下是一个示例,比较了苹果公司股价的 30 天移动平均值与指数加权(EW)移动平均值(span=60)(请参阅简单移动平均与指数加权):

py 复制代码
In [265]: aapl_px = close_px["AAPL"]["2006":"2007"]

In [266]: ma30 = aapl_px.rolling(30, min_periods=20).mean()

In [267]: ewma30 = aapl_px.ewm(span=30).mean()

In [268]: aapl_px.plot(style="k-", label="Price")
Out[268]: <Axes: >

In [269]: ma30.plot(style="k--", label="Simple Moving Avg")
Out[269]: <Axes: >

In [270]: ewma30.plot(style="k-", label="EW MA")
Out[270]: <Axes: >

In [271]: plt.legend()

图 11.7:简单移动平均与指数加权

二进制移动窗口函数

一些统计运算符,如相关性和协方差,需要在两个时间序列上操作。例如,金融分析师通常对股票与标普 500 等基准指数的相关性感兴趣。为了查看这一点,我们首先计算所有感兴趣时间序列的百分比变化:

py 复制代码
In [273]: spx_px = close_px_all["SPX"]

In [274]: spx_rets = spx_px.pct_change()

In [275]: returns = close_px.pct_change()

在我们调用rolling之后,corr聚合函数可以计算与spx_rets的滚动相关性(请参阅苹果公司六个月回报与标普 500 的相关性以查看结果图):

py 复制代码
In [276]: corr = returns["AAPL"].rolling(125, min_periods=100).corr(spx_rets)

In [277]: corr.plot()

图 11.8:苹果公司六个月回报与标普 500 的相关性

假设您想要计算 S&P 500 指数与多只股票的滚动相关性。您可以像我们上面为苹果公司所做的那样编写一个循环来计算每只股票的相关性,但如果每只股票是单个 DataFrame 中的一列,我们可以通过在 DataFrame 上调用rolling并传递spx_rets Series 来一次性计算所有滚动相关性。

请参阅与标普 500 的六个月回报相关性以查看结果图:

py 复制代码
In [279]: corr = returns.rolling(125, min_periods=100).corr(spx_rets)

In [280]: corr.plot()

图 11.9:与标普 500 的六个月回报相关性

用户定义的移动窗口函数

rolling和相关方法上的apply方法提供了一种方法,可以在移动窗口上应用自己创建的数组函数。唯一的要求是函数从数组的每个部分产生一个单一值(一个减少)。例如,虽然我们可以使用rolling(...).quantile(q)计算样本分位数,但我们可能对特定值在样本中的百分位数感兴趣。scipy.stats.percentileofscore函数正是这样做的(请参阅 2%苹果公司回报在一年窗口内的百分位数以查看结果图):

py 复制代码
In [282]: from scipy.stats import percentileofscore

In [283]: def score_at_2percent(x):
 .....:     return percentileofscore(x, 0.02)

In [284]: result = returns["AAPL"].rolling(250).apply(score_at_2percent)

In [285]: result.plot()

图 11.10:2%苹果公司回报在一年窗口内的百分位数

如果您尚未安装 SciPy,可以使用 conda 或 pip 进行安装:

py 复制代码
conda install scipy

11.8 结论

时间序列数据需要不同类型的分析和数据转换工具,与我们在之前章节中探讨过的其他类型数据不同。

在接下来的章节中,我们将展示如何开始使用建模库,如 statsmodels 和 scikit-learn。


  1. 对于closedlabel的默认值选择可能对一些用户来说有点奇怪。默认值为closed="left",除了一组特定的值("M""A""Q""BM""BQ""W")默认为closed="right"。选择默认值是为了使结果更直观,但值得知道默认值并不总是一个或另一个。
相关推荐
信号处理学渣20 分钟前
matlab画图,选择性显示legend标签
开发语言·matlab
红龙创客21 分钟前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
蓝天星空21 分钟前
Python调用open ai接口
人工智能·python
jasmine s30 分钟前
Pandas
开发语言·python
郭wes代码30 分钟前
Cmd命令大全(万字详细版)
python·算法·小程序
leaf_leaves_leaf1 小时前
win11用一条命令给anaconda环境安装GPU版本pytorch,并检查是否为GPU版本
人工智能·pytorch·python
biomooc1 小时前
R 语言 | 绘图的文字格式(绘制上标、下标、斜体、文字标注等)
开发语言·r语言
夜雨飘零11 小时前
基于Pytorch实现的说话人日志(说话人分离)
人工智能·pytorch·python·声纹识别·说话人分离·说话人日志
骇客野人1 小时前
【JAVA】JAVA接口公共返回体ResponseData封装
java·开发语言
black^sugar1 小时前
纯前端实现更新检测
开发语言·前端·javascript