Pandas-秘籍第三版-三-

59 阅读1小时+

Pandas 秘籍第三版(三)

原文:annas-archive.org/md5/dbf45b033e25cfae0fd6c82aa3a4578a

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:重塑 DataFrame

处理数据很困难。很少,甚至从未,有人能够仅仅收集数据就能直接获得洞见。通常,必须投入大量时间和精力进行数据清洗、转换和重塑,以便将数据转化为可用、可消化和/或可理解的格式。

您的源数据是多个 CSV 文件的集合吗?每个文件代表一天的数据?通过正确使用pd.concat,您可以轻松地将这些文件合并为一个。

您使用的关系型数据库作为数据源是否以规范化形式存储数据,而目标列式数据库更倾向于将所有数据存储在一个表中?pd.merge可以帮助您将数据合并在一起。

如果您的老板要求您从数百万行数据中提取并生成一份任何业务人员都能理解的简明报告,该怎么办?pd.pivot_table是完成此任务的正确工具,它能够快速、轻松地汇总您的数据。

最终,您需要重塑数据的原因来自不同的地方。无论是系统还是人们的需求,pandas 都可以帮助您按需操作数据。

在本章中,我们将逐步介绍 pandas 提供的函数和方法,帮助您重塑数据。掌握正确的知识和一些创造力后,使用 pandas 重塑数据可以成为您分析过程中的最有趣和最具回报的部分之一。

本章将介绍以下内容:

  • 连接pd.DataFrame对象

  • 使用pd.merge合并 DataFrame

  • 使用pd.DataFrame.join连接 DataFrame

  • 使用pd.DataFrame.stackpd.DataFrame.unstack进行重塑

  • 使用pd.DataFrame.melt进行重塑

  • 使用pd.wide_to_long进行重塑

  • 使用pd.DataFrame.pivotpd.pivot_table进行重塑

  • 使用pd.DataFrame.explode进行重塑

  • 使用pd.DataFrame.T进行转置

连接 pd.DataFrame 对象

在 pandas 中,连接一词指的是将两个或更多的pd.DataFrame对象以某种方式堆叠起来的过程。最常见的,pandas 用户通常会进行我们认为的垂直连接,即将pd.DataFrame对象堆叠在彼此之上:

图 7.1:两个 pd.DataFrame 对象的垂直连接

然而,pandas 还具有灵活性,可以将您的pd.DataFrame对象并排堆叠,这个过程称为水平连接:

自动生成的表格图示

图 7.2:两个 pd.DataFrame 对象的垂直连接

这些图表可能会帮助您很好地理解连接的概念,但也有一些潜在问题需要考虑。如果我们尝试进行垂直连接,但各个对象的列标签不相同,应该怎么办?相反,如果我们尝试进行水平连接,而不是所有的行标签都相同,又该如何处理?

无论您想要沿着哪个方向进行连接,也无论您的标签是否对齐,pandas 中的连接完全受pd.concat函数的控制。本文将介绍pd.concat的基础知识,同时向您展示在处理不像标记的pd.DataFrame对象时如何控制其行为。

如何做到

假设我们已经收集了关于不同公司在两个季度内股票表现的数据。为了最好地展示如何进行连接操作,我们故意使这两个pd.DataFrame对象涵盖不同的时间段,显示不同的公司,甚至包含不同的列:

`df_q1 = pd.DataFrame([     ["AAPL", 100., 50., 75.],     ["MSFT", 80., 42., 62.],     ["AMZN", 60., 100., 120.], ], columns=["ticker", "shares", "low", "high"]) df_q1 = df_q1.convert_dtypes(dtype_backend="numpy_nullable") df_q1` 
 `ticker   shares   low   high 0   AAPL     100      50    75 1   MSFT     80       42    62 2   AMZN     60       100   120` 
`df_q2 = pd.DataFrame([     ["AAPL", 80., 70., 80., 77.],     ["MSFT", 90., 50., 60., 55.],     ["IBM", 100., 60., 70., 64.],     ["GE", 42., 30., 50., 44.], ], columns=["ticker", "shares", "low", "high", "close"]) df_q2 = df_q2.convert_dtypes(dtype_backend="numpy_nullable") df_q2` 
 `ticker   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

pd.concat的最基本调用将接受这两个pd.DataFrame对象的列表。默认情况下,这将垂直堆叠对象,即第一个pd.DataFrame简单地堆叠在第二个上面。

尽管我们的pd.DataFrame对象中大多数列是重叠的,但df_q1没有close列,而df_q2有。为了让连接仍然生效,pandas 将在pd.concat的结果中包括close列,并为来自df_q1的行分配缺失值:

`pd.concat([df_q1, df_q2])` 
 `ticker   shares   low   high   close 0   AAPL     100      50    75     <NA> 1   MSFT     80       42    62     <NA> 2   AMZN     60       100   120    <NA> 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

您还应该注意 pandas 在结果中给出的行索引。实际上,pandas 获取了df_q1的索引值,范围从 0 到 2,然后获取了df_q2的索引值,范围从 0 到 3。在创建新的行索引时,pandas 简单地保留了这些值,并在结果中垂直堆叠它们。如果您不喜欢这种行为,可以向pd.concat传递ignore_index=True

`pd.concat([df_q1, df_q2], ignore_index=True)` 
 `ticker   shares   low   high   close 0   AAPL     100      50    75     <NA> 1   MSFT     80       42    62     <NA> 2   AMZN     60       100   120    <NA> 3   AAPL     80       70    80     77 4   MSFT     90       50    60     55 5   IBM      100      60    70     64 6   GE       42       30    50     44` 

另一个潜在的问题是我们不能再看到我们的记录最初来自哪个pd.DataFrame了。为了保留这些信息,我们可以通过keys=参数传递自定义标签,以表示数据的来源:

`pd.concat([df_q1, df_q2], keys=["q1", "q2"])` 
 `ticker   shares   low   high   close q1   0   AAPL     100      50    75     <NA>      1   MSFT     80       42    62     <NA>      2   AMZN     60       100   120    <NA> q2   0   AAPL     80       70    80     77      1   MSFT     90       50    60     55      2   IBM      100      60    70     64      3   GE       42       30    50     44` 

pd.concat还允许您控制连接的方向。与默认的垂直堆叠行为不同,我们可以传递axis=1来水平堆叠:

`pd.concat([df_q1, df_q2], keys=["q1", "q2"], axis=1)` 
 `q1                      …   q2     ticker   shares   low   …   low   high   close 0   AAPL     100      5070    80     77 1   MSFT     80       4250    60     55 2   AMZN     60       10060    70     64 3   <NA>     <NA>     <NA>  …   30    50     44 4 rows × 9 columns` 

虽然这样做使我们得到了一个没有错误的结果,但仔细检查结果后发现了一些问题。数据的前两行分别涵盖了AAPLMSFT,所以在这里没有什么好担心的。然而,数据的第三行显示AMZN作为 Q1 的股票代码,而IBM作为 Q2 的股票代码 - 这是怎么回事?

pandas 的问题在于它根据索引的值进行对齐,而不是像ticker这样的其他列,这可能是我们感兴趣的。如果我们希望pd.concat根据ticker进行对齐,在连接之前,我们可以将这两个pd.DataFrame对象的ticker设置为行索引:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1)` 
 `q1                   …   q2         shares  low   high   …   low   high   close ticker AAPL    100     50    7570    80     77 MSFT    80      42    6250    60     55 AMZN    60      100   120    …   <NA>  <NA>   <NA> IBM     <NA>    <NA>  <NA>   …   60    70     64 GE      <NA>    <NA>  <NA>   …   30    50     44 5 rows × 7 columns` 

我们可能想要控制的最后一个对齐行为是如何处理至少在一个对象中出现但不是所有对象中都出现的标签。默认情况下,pd.concat 执行“外连接”操作,这将取所有的索引值(在我们的例子中是 ticker 符号),并将它们显示在输出中,适用时使用缺失值指示符。相对地,传递 join="inner" 作为参数,只会显示在所有被连接对象中都出现的索引标签:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1, join="inner")` 
 `q1                    …   q2         shares   low   high   …   low   high   close ticker AAPL    100      50    7570    80     77 MSFT    80       42    6250    60     55 2 rows × 7 columns` 

还有更多内容…

pd.concat 是一个开销较大的操作,绝对不应该在 Python 循环中调用。如果你在循环中创建了一堆 pd.DataFrame 对象,并且最终希望将它们连接在一起,最好先将它们存储在一个序列中,等到序列完全填充后再调用一次 pd.concat

我们可以使用 IPython 的 %%time 魔法函数来分析不同方法之间的性能差异。让我们从在循环中使用 pd.concat 的反模式开始:

`%%time concatenated_dfs = df_q1 for i in range(1000):     concatenated_dfs = pd.concat([concatenated_dfs, df_q1]) print(f"Final pd.DataFrame shape is {concatenated_dfs.shape}")` 
`Final pd.DataFrame shape is (3003, 4) CPU times: user 267 ms, sys: 0 ns, total: 267 ms Wall time: 287 ms` 

这段代码将产生等效的结果,但遵循在循环中追加到 Python 列表的做法,并且仅在最后调用一次 pd.concat

`%%time df = df_q1 accumulated = [df_q1] for i in range(1000):     accumulated.append(df_q1) concatenated_dfs = pd.concat(accumulated) print(f"Final pd.DataFrame shape is {concatenated_dfs.shape}")` 
`Final pd.DataFrame shape is (3003, 4) CPU times: user 28.4 ms, sys: 0 ns, total: 28.4 ms Wall time: 31 ms` 

使用 pd.merge 合并 DataFrame

数据重塑中的另一个常见任务称为合并,在某些情况下也叫连接,后者术语在数据库术语中使用得较多。与连接操作将对象上下堆叠或并排放置不同,合并通过查找两个实体之间的共同键(或一组键)来工作,并使用这个键将其他列合并在一起:

图示:数字描述自动生成

图 7.3:合并两个 pd.DataFrame 对象

在 pandas 中,最常用的合并方法是 pd.merge,其功能将在本食谱中详细介绍。另一个可行的(但不太常用的)方法是 pd.DataFrame.join,尽管在讨论它之前,先了解 pd.merge 是有帮助的(我们将在下一个食谱中介绍 pd.DataFrame.join)。

如何操作

接着我们继续使用在连接 pd.DataFrame 对象示例中创建的股票 pd.DataFrame 对象:

`df_q1 = pd.DataFrame([     ["AAPL", 100., 50., 75.],     ["MSFT", 80., 42., 62.],     ["AMZN", 60., 100., 120.], ], columns=["ticker", "shares", "low", "high"]) df_q1 = df_q1.convert_dtypes(dtype_backend="numpy_nullable") df_q1` 
 `ticker   shares   low   high 0   AAPL     100      50    75 1   MSFT     80       42    62 2   AMZN     60       100   120` 
`df_q2 = pd.DataFrame([     ["AAPL", 80., 70., 80., 77.],     ["MSFT", 90., 50., 60., 55.],     ["IBM", 100., 60., 70., 64.],     ["GE", 42., 30., 50., 44.], ], columns=["ticker", "shares", "low", "high", "close"]) df_q2 = df_q2.convert_dtypes(dtype_backend="numpy_nullable") df_q2` 
 `ticker   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

在该示例中,我们看到你可以通过结合使用 pd.concatpd.DataFrame.set_index 来通过 ticker 列合并这两个 pd.DataFrame 对象:

`pd.concat([     df_q1.set_index("ticker"),     df_q2.set_index("ticker"), ], keys=["q1", "q2"], axis=1)` 
 `q1                    …   q2          shares   low   high   …   low   high   close ticker AAPL     100      50    7570    80     77 MSFT     80       42    6250    60     55 AMZN     60       100   120    …   <NA>  <NA>   <NA> IBM      <NA>     <NA>  <NA>   …   60    70     64 GE       <NA>     <NA>  <NA>   …   30    50     44 5 rows × 7 columns` 

使用 pd.merge,你可以通过传递 on= 参数更简洁地表达这一点,明确表示你希望 pandas 使用哪一列(或哪几列)进行对齐:

`pd.merge(df_q1, df_q2, on=["ticker"])` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        5070      80       77 1   MSFT     80         4250      60       55 2 rows × 8 columns` 

如你所见,结果并不完全相同,但我们可以通过切换合并行为来更接近原来的结果。默认情况下,pd.merge 执行内连接;如果我们想要一个更类似于 pd.concat 示例的结果,可以传递 how="outer"

`pd.merge(df_q1, df_q2, on=["ticker"], how="outer")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        5070      80       77 1   AMZN     60         100     …   <NA>    <NA>     <NA> 2   GE       <NA>       <NA>    …   30      50       44 3   IBM      <NA>       <NA>    …   60      70       64 4   MSFT     80         4250      60       55 5 rows × 8 columns` 

虽然pd.concat只允许执行内连接外连接,但pd.merge还支持左连接,它保留第一个pd.DataFrame中的所有数据,并根据关键字段匹配将第二个pd.DataFrame中的数据合并进来:

`pd.merge(df_q1, df_q2, on=["ticker"], how="left")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        5070      80       77 1   MSFT     80         4250      60       55 2   AMZN     60         100     …   <NA>    <NA>     <NA> 3 rows × 8 columns` 

how="right"则反转了这一点,确保第二个pd.DataFrame中的每一行都出现在输出中:

`pd.merge(df_q1, df_q2, on=["ticker"], how="right")` 
 `ticker   shares_x   low_x   …   low_y   high_y   close 0   AAPL     100        5070      80       77 1   MSFT     80         4250      60       55 2   IBM      <NA>       <NA>    …   60      70       64 3   GE       <NA>       <NA>    …   30      50       44 4 rows × 8 columns` 

使用how="outer"时的一个额外功能是可以提供一个indicator=参数,这将告诉你结果pd.DataFrame中的每一行来自哪里:

`pd.merge(df_q1, df_q2, on=["ticker"], how="outer", indicator=True)` 
 `ticker   shares_x   low_x   …   high_y   close   _merge 0   AAPL     100        5080       77      both 1   AMZN     60         100     …   <NA>     <NA>    left_only 2   GE       <NA>       <NA>    …   50       44      right_only 3   IBM      <NA>       <NA>    …   70       64      right_only 4   MSFT     80         4260       55      both 5 rows × 9 columns` 

“both”的值表示用于执行合并的键在两个pd.DataFrame对象中都找到了,这在AAPLMSFT的股票代码中是适用的。left_only的值意味着该键仅出现在左侧pd.DataFrame中,正如AMZN的情况。right_only则突出显示仅出现在右侧pd.DataFrame中的键,例如GEIBM

我们的pd.concat输出和pd.merge的区别之一是,前者在列中生成了pd.MultiIndex,从而有效地防止了两个pd.DataFrame对象中出现的列标签冲突。相比之下,pd.merge会为在两个pd.DataFrame对象中都出现的列添加后缀,以进行区分。来自左侧pd.DataFrame的列会附加_x后缀,而_y后缀则表示该列来自右侧pd.DataFrame

若想更好地控制这个后缀,可以将元组作为参数传递给suffixes=。在我们的示例数据中,这个参数可以方便地区分 Q1 和 Q2 的数据:

`pd.merge(     df_q1,     df_q2,     on=["ticker"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   shares_q1   low_q1   …   low_q2   high_q2   close 0   AAPL     100         5070       80        77 1   AMZN     60          100      …   <NA>     <NA>      <NA> 2   GE       <NA>        <NA>     …   30       50        44 3   IBM      <NA>        <NA>     …   60       70        64 4   MSFT     80          4250       60        55 5 rows × 8 columns` 

但是,你应该知道,后缀只会在列名同时出现在两个pd.DataFrame对象中时才会应用。如果某个列只出现在其中一个对象中,则不会应用后缀:

`pd.merge(     df_q1[["ticker"]].assign(only_in_left=42),     df_q2[["ticker"]].assign(only_in_right=555),     on=["ticker"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   only_in_left   only_in_right 0   AAPL     42.0           555.0 1   AMZN     42.0           NaN 2   GE       NaN            555.0 3   IBM      NaN            555.0 4   MSFT     42.0           555.0` 

如果我们的键列在两个pd.DataFrame对象中有不同的名称,那会是个问题吗?当然不会!不过不必只听我说——让我们把其中一个pd.DataFrame对象中的ticker列重命名为SYMBOL试试看:

`df_q2 = df_q2.rename(columns={"ticker": "SYMBOL"}) df_q2` 
 `SYMBOL   shares   low   high   close 0   AAPL     80       70    80     77 1   MSFT     90       50    60     55 2   IBM      100      60    70     64 3   GE       42       30    50     44` 

使用pd.merge时,唯一改变的是你现在需要将两个不同的参数传递给left_on=right_on=,而不再是将一个参数传递给on=

`pd.merge(     df_q1,     df_q2,     left_on=["ticker"],     right_on=["SYMBOL"],     how="outer",     suffixes=("_q1", "_q2"), )` 
 `ticker   shares_q1   low_q1   …   low_q2   high_q2   close 0   AAPL     100         5070       80        77 1   AMZN     60          100      …   <NA>     <NA>      <NA> 2   <NA>     <NA>        <NA>     …   30       50        44 3   <NA>     <NA>        <NA>     …   60       70        64 4   MSFT     80          4250       60        55 5 rows × 9 columns` 

为了完成这个示例,让我们考虑一个案例,其中有多个列应作为我们的合并键。我们可以通过创建一个pd.DataFrame来列出股票代码、季度和最低价来开始:

`lows = pd.DataFrame([     ["AAPL", "Q1", 50.],     ["MSFT", "Q1", 42.],     ["AMZN", "Q1", 100.],     ["AAPL", "Q2", 70.],     ["MSFT", "Q2", 50.],     ["IBM", "Q2", 60.],     ["GE", "Q2", 30.], ], columns=["ticker", "quarter", "low"]) lows = lows.convert_dtypes(dtype_backend="numpy_nullable") lows` 
 `ticker   quarter   low 0   AAPL     Q1        50 1   MSFT     Q1        42 2   AMZN     Q1        100 3   AAPL     Q2        70 4   MSFT     Q2        50 5   IBM      Q2        60 6   GE       Q2        30` 

第二个pd.DataFrame也会包含股票代码和季度(尽管名称不同),但会显示最高值而不是最低值:

`highs = pd.DataFrame([     ["AAPL", "Q1", 75.],     ["MSFT", "Q1", 62.],     ["AMZN", "Q1", 120.],     ["AAPL", "Q2", 80.],     ["MSFT", "Q2", 60.],     ["IBM", "Q2", 70.],     ["GE", "Q2", 50.], ], columns=["SYMBOL", "QTR", "high"]) highs = highs.convert_dtypes(dtype_backend="numpy_nullable") highs` 
 `SYMBOL   QTR   high 0   AAPL     Q1    75 1   MSFT     Q1    62 2   AMZN     Q1    120 3   AAPL     Q2    80 4   MSFT     Q2    60 5   IBM      Q2    70 6   GE       Q2    50` 

在这些pd.DataFrame对象的布局下,我们的关键字段现在变成了股票代码和季度的组合。通过将适当的标签作为参数传递给left_on=right_on=,pandas 仍然可以执行这个合并:

`pd.merge(     lows,     highs,     left_on=["ticker", "quarter"],     right_on=["SYMBOL", "QTR"], )` 
 `ticker   quarter   low   SYMBOL   QTR   high 0   AAPL     Q1        50    AAPL     Q1    75 1   MSFT     Q1        42    MSFT     Q1    62 2   AMZN     Q1        100   AMZN     Q1    120 3   AAPL     Q2        70    AAPL     Q2    80 4   MSFT     Q2        50    MSFT     Q2    60 5   IBM      Q2        60    IBM      Q2    70 6   GE       Q2        30    GE       Q2    50` 

还有更多内容……

在尝试合并数据时,另一个需要考虑的因素是两个pd.DataFrame对象中键的唯一性。对这一点理解不清或理解错误,可能会导致在应用程序中出现难以察觉的错误。幸运的是,pd.merge可以帮助我们提前发现这些问题。

为了说明我们在谈论唯一性时的意思,突出它可能引发的问题,并展示如何通过 pandas 解决这些问题,我们首先从一个小的pd.DataFrame开始,展示假设的销售数据,按销售人员随时间变化:

`sales = pd.DataFrame([     ["Jan", "John", 10],     ["Feb", "John", 20],     ["Mar", "John", 30], ], columns=["month", "salesperson", "sales"]) sales = sales.convert_dtypes(dtype_backend="numpy_nullable") sales` 
 `month   salesperson   sales 0   Jan     John          10 1   Feb     John          20 2   Mar     John          30` 

让我们再创建一个单独的pd.DataFrame,将每个销售人员映射到一个特定的地区:

`regions = pd.DataFrame([     ["John", "Northeast"],     ["Jane", "Southwest"], ], columns=["salesperson", "region"]) regions = regions.convert_dtypes(dtype_backend="numpy_nullable") regions` 
 `salesperson   region 0   John          Northeast 1   Jane          Southwest` 

如果你曾在一家小公司或小部门工作过,你可能见过以这种方式构建的数据源。在那个环境中,员工们都知道John是谁,因此他们对这种数据布局方式感到满意。

在销售数据中,John出现了多次,但在地区数据中,John只出现了一次。因此,使用salesperson作为合并键时,销售与地区之间的关系是多对一(n-to-1)。反之,地区与销售之间的关系是单对多(1-to-n)。

在这些类型的关系中,合并不会引入任何意外的行为。对这两个对象进行pd.merge将简单地显示销售数据的多行,并与相应的地区信息并列显示:

`pd.merge(sales, regions, on=["salesperson"])` 
 `month   salesperson   sales   region 0   Jan     John          10      Northeast 1   Feb     John          20      Northeast 2   Mar     John          30      Northeast` 

如果我们在合并后尝试对销售额进行求和,我们仍然会得到正确的60

`pd.merge(sales, regions, on=["salesperson"])["sales"].sum()` 
`60` 

随着公司或部门的扩展,另一个John被雇佣是不可避免的。为了解决这个问题,我们的regionspd.DataFrame被更新,增加了一个新的last_name列,并为John Newhire添加了一条新记录:

`regions_orig = regions regions = pd.DataFrame([     ["John", "Smith", "Northeast"],     ["Jane", "Doe", "Southwest"],     ["John", "Newhire", "Southeast"], ], columns=["salesperson", "last_name", "region"]) regions = regions.convert_dtypes(dtype_backend="numpy_nullable") regions` 
 `salesperson   last_name   region 0   John          Smith       Northeast 1   Jane          Doe         Southwest 2   John          Newhire     Southeast` 

突然,我们之前执行的相同的pd.merge产生了不同的结果:

`pd.merge(sales, regions, on=["salesperson"])` 
 `month   salesperson   sales   last_name   region 0   Jan     John          10      Smith       Northeast 1   Jan     John          10      Newhire     Southeast 2   Feb     John          20      Smith       Northeast 3   Feb     John          20      Newhire     Southeast 4   Mar     John          30      Smith       Northeast 5   Mar     John          30      Newhire     Southeast` 

这是一个明确的编程错误。如果你尝试从合并后的pd.DataFrame中对sales列进行求和,你最终会将实际销售的数量加倍。总之,我们只卖出了 60 个单位,但通过引入John Newhire到我们的regionspd.DataFrame中,突然改变了两个pd.DataFrame对象之间的关系,变成了多对多(或n-to-n),这使得我们的数据被重复,从而导致了错误的销售数字:

`pd.merge(sales, regions, on=["salesperson"])["sales"].sum()` 
`120` 

为了用 pandas 提前捕捉到这些意外情况,你可以在pd.merge中提供validate=参数,这样可以明确合并键在两个对象之间的预期关系。如果使用我们原始的pd.DataFrame对象,many_to_one的验证是可以的:

`pd.merge(sales, regions_orig, on=["salesperson"], validate="many_to_one")` 
 `month   salesperson   sales   region 0   Jan     John          10      Northeast 1   Feb     John          20      Northeast 2   Mar     John          30      Northeast` 

然而,当John Newhire进入我们的合并时,同样的验证会抛出一个错误:

`pd.merge(sales, regions, on=["salesperson"], validate="many_to_one")` 
`MergeError: Merge keys are not unique in right dataset; not a many-to-one merge` 

在这个简单的例子中,如果我们一开始就以不同的方式建模数据,就可以避免这个问题,方法是使用由多个列组成的自然键来建模销售 pd.DataFrame,或在两个 pd.DataFrame 对象中都使用替代键。因为这些例子数据量很小,我们也可以通过目测发现结构上存在的问题。

在实际应用中,检测类似的问题并不那么简单。你可能需要合并成千上万甚至数百万行数据,即使大量行受到关系问题的影响,也可能很容易被忽视。手动检测此类问题就像是在大海捞针,因此我强烈建议使用数据验证功能,以避免意外情况发生。

虽然失败并非理想的结果,但在这种情况下,你已经大声失败,并且可以轻松识别你的建模假设出现问题的地方。如果没有这些检查,用户将默默看到不正确的数据,这往往是更糟糕的结果。

使用 pd.DataFrame.join 合并 DataFrame

虽然 pd.merge 是合并两个不同 pd.DataFrame 对象的最常用方法,但功能上类似但使用较少的 pd.DataFrame.join 方法是另一个可行的选择。从风格上讲,pd.DataFrame.join 可以被视为当你想要在现有的 pd.DataFrame 中添加更多列时的快捷方式;而相比之下,pd.merge 默认将两个 pd.DataFrame 对象视为具有相等重要性的对象。

如何实现

为了强调 pd.DataFrame.join 是增强现有 pd.DataFrame 的一种快捷方式,假设我们有一个销售表格,其中行索引对应于销售人员,但使用的是替代键而不是自然键:

`sales = pd.DataFrame(     [[1000], [2000], [4000]],     columns=["sales"],     index=pd.Index([42, 555, 9000], name="salesperson_id") ) sales = sales.convert_dtypes(dtype_backend="numpy_nullable") sales` 
 `sales salesperson_id 42      1000 555     2000 9000    4000` 

那么,我们还可以考虑一个专门的 pd.DataFrame,它存储了某些(但不是全部)销售人员的元数据:

`salesperson = pd.DataFrame([     ["John", "Smith"],     ["Jane", "Doe"], ], columns=["first_name", "last_name"], index=pd.Index(     [555, 42], name="salesperson_id" )) salesperson = salesperson.convert_dtypes(dtype_backend="numpy_nullable") salesperson` 
 `first_name   last_name salesperson_id 555     John         Smith 42      Jane         Doe` 

由于我们想要用来连接这两个 pd.DataFrame 对象的数据位于行索引中,因此在调用 pd.merge 时,你需要写出 left_index=Trueright_index=True。同时请注意,因为我们在销售 pd.DataFrame 中有 salesperson_id9000 的记录,但在 salesperson 中没有对应的条目,所以你需要使用 how="left" 来确保合并时记录不会丢失:

`pd.merge(sales, salesperson, left_index=True, right_index=True, how="left")` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith 9000    4000    <NA>         <NA>` 

那个相对较长的 pd.merge 调用描述了 pd.DataFrame.join 的默认行为,因此你可能会发现直接使用后者更为简便:

`sales.join(salesperson)` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith 9000    4000    <NA>         <NA>` 

尽管 pd.DataFrame.join 默认进行左连接,你也可以通过传递 how= 参数选择不同的行为:

`sales.join(salesperson, how="inner")` 
 `sales   first_name   last_name salesperson_id 42      1000    Jane         Doe 555     2000    John         Smith` 

最终,没有强制要求必须使用 pd.DataFrame.join 而非 pd.merge。前者只是一个快捷方式,并且是一种风格上的指示,表示调用的 pd.DataFrame(此处是 sales)在与另一个 pd.DataFrame(如 salesperson)合并时不应该丢失任何记录。

使用 pd.DataFrame.stackpd.DataFrame.unstack 重塑数据

在我们深入探讨堆叠拆分这两个术语之前,让我们退后一步,比较两张数据表。你注意到它们之间有什么不同吗:

abc
x123
y456

表格 7.1:宽格式的表格

与:

xa1
xb2
xc3
ya4
yb5
yc6

表格 7.2:长格式的表格

当然,从视觉上看,表格的形状不同,但它们所包含的数据是相同的。前一个表格通常被称为宽格式表格,因为它将数据分散存储在不同的列中。相比之下,第二个表格(许多人会说它是存储在长格式中)则使用新行来表示不同的数据项。

哪种格式更好?答案是视情况而定——也就是说,这取决于你的受众和/或你所交互的系统。你公司的一位高管可能更喜欢查看以宽格式存储的数据,因为这样一目了然。柱状数据库则更倾向于长格式,因为它在处理数百万甚至数十亿行数据时,能比处理相同数量的列更加优化。

既然没有一种存储数据的统一方式,你可能需要在这两种格式之间来回转换数据,这就引出了堆叠拆分这两个术语。

堆叠指的是将列压入行中的过程,本质上是帮助将宽格式转换为长格式:

图 7.4:将 pd.DataFrame 从宽格式堆叠到长格式

拆分则是相反的过程,将存储在长格式中的数据转换为宽格式:

一张数字和数字的图示,描述自动生成,信心中等

图 7.5:将 pd.DataFrame 从长格式拆分到宽格式

在本节中,我们将引导你正确使用 pd.DataFrame.stackpd.DataFrame.unstack 方法,这些方法可以用于数据格式转换。

如何实现

让我们从以下 pd.DataFrame 开始,它总结了不同州种植的水果数量:

`df = pd.DataFrame([     [12, 10, 40],     [9, 7, 12],     [0, 14, 190] ], columns=pd.Index(["Apple", "Orange", "Banana"], name="fruit"), index=pd.Index(     ["Texas", "Arizona", "Florida"], name="state")) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
`fruit   Apple   Orange  Banana state Texas   12      10      40 Arizona 9       7       12 Florida 0       14      190` 

在数据建模术语中,我们会将此视为一个“宽”表格。每一行代表一个州,并且每种作物的不同数量存储在各自的列中。

如果我们想将表格转换为“长”格式,我们基本上希望将每个 statefruit 的组合作为一行展示。pd.DataFrame.stack 将帮助我们实现这一目标,它通过将水果从列索引中移除,形成一个新的 pd.MultiIndex 在行中,其中包含状态和水果信息:

`df.stack()` 
`state     fruit Texas     Apple      12          Orange      10          Banana      40 Arizona   Apple       9          Orange       7          Banana      12 Florida   Apple       0          Orange      14          Banana     190 dtype: Int64` 

在调用 pd.DataFrame.stack 后,许多用户会接着调用 pd.Series.reset_index 方法,并使用 name= 参数。这将把由 pd.DataFrame.stack 创建的带有 pd.MultiIndexpd.Series 转换回具有有意义列名的 pd.DataFrame

`df.stack().reset_index(name="number_grown")` 
 `state     fruit    number_grown 0   Texas     Apple    12 1   Texas     Orange   10 2   Texas     Banana   40 3   Arizona   Apple    9 4   Arizona   Orange   7 5   Arizona   Banana   12 6   Florida   Apple    0 7   Florida   Orange   14 8   Florida   Banana   190` 

这种数据存储的长格式被许多数据库偏好用于存储,并且是传递给像 Seaborn 这样的库时pd.DataFrame的预期格式,我们在第六章 可视化中的Seaborn 简介食谱中曾展示过。

然而,有时你可能想反向操作,将你的长格式pd.DataFrame转换为宽格式。这在需要在紧凑区域中总结数据时尤其有用;同时利用两个维度进行显示,比让观众滚动查看大量数据行更为有效。

为了看到这一效果,让我们从我们刚才进行的pd.DataFrame.stack调用中创建一个新的pd.Series

`stacked = df.stack() stacked` 
`state    fruit Texas    Apple      12         Orange      10         Banana      40 Arizona  Apple       9         Orange       7         Banana      12 Florida  Apple       0         Orange      14         Banana     190 dtype: Int64` 

要反向操作,将某个索引层级从行移到列,只需要调用pd.Series.unstack

`stacked.unstack()` 
`fruit   Apple   Orange   Banana state Texas   12      10       40 Arizona 9       7        12 Florida 0       14       190` 

默认情况下,调用pd.Series.unstack会移动行索引中最内层的层级,在我们的例子中是fruit。然而,我们可以传递level=0,使其移动最外层的第一个层级,而不是最内层的层级,这样可以将状态汇总到列中:

`stacked.unstack(level=0)` 
`state   Texas   Arizona   Florida fruit Apple   12      9         0 Orange  10      7         14 Banana  40      12        190` 

因为我们的pd.MultiIndex层级有名称,我们也可以通过名称而不是位置来引用我们想要移动的层级:

`stacked.unstack(level="state")` 
`state   Texas   Arizona   Florida fruit Apple   12      9         0 Orange  10      7         14 Banana  40      12        190` 

使用pd.DataFrame.melt进行数据重塑

使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 进行重塑食谱中,我们发现,你可以通过在调用pd.DataFrame.stack之前设置合适的行和列索引,将宽格式的pd.DataFrame转换为长格式。pd.DataFrame.melt函数也能将你的pd.DataFrame从宽格式转换为长格式,但无需在中间步骤设置行和列索引值,同时还能对宽到长的转换中是否包含其他列进行更多控制。

如何操作

让我们再次总结不同水果在不同州的种植情况。然而,与使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 进行重塑食谱不同,我们不会将行索引设置为州值,而是将其视为pd.DataFrame中的另一列:

`df = pd.DataFrame([     ["Texas", 12, 10, 40],     ["Arizona", 9, 7, 12],     ["Florida", 0, 14, 190] ], columns=["state", "apple", "orange", "banana"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state    apple    orange    banana 0   Texas    12       10        40 1   Arizona  9        7         12 2   Florida  0        14        190` 

要通过pd.DataFrame.stack转换为长格式,我们需要将几个调用链起来,最终得到一个没有pd.MultiIndexpd.DataFrame

`df.set_index("state").stack().reset_index()` 
 `state     level_1   0 0   Texas     apple     12 1   Texas     orange    10 2   Texas     banana    40 3   Arizona   apple     9 4   Arizona   orange    7 5   Arizona   banana    12 6   Florida   apple     0 7   Florida   orange    14 8   Florida   banana    190` 

列名level_1在我们的pd.DataFrame.stack操作中默认创建,因为我们开始时的列索引没有名称。我们还看到,对于长格式中新引入的值,会自动生成一个0的列名,所以我们仍然需要链式调用重命名操作来获得一个更具可读性的pd.DataFrame

`df.set_index("state").stack().reset_index().rename(columns={     "level_1": "fruit",     0: "number_grown", })` 
 `state    fruit    number_grown 0   Texas    apple    12 1   Texas    orange   10 2   Texas    banana   40 3   Arizona  apple    9 4   Arizona  orange   7 5   Arizona  banana   12 6   Florida  apple    0 7   Florida  orange   14 8   Florida  banana   190` 

pd.DataFrame.melt通过提供一个id_vars=参数,直接让我们接近我们想要的pd.DataFrame,这个参数对应于你在使用pd.DataFrame.stack时会用到的行索引:

`df.melt(id_vars=["state"])` 
 `state     variable  value 0   Texas     apple     12 1   Arizona   apple     9 2   Florida   apple     0 3   Texas     orange    10 4   Arizona   orange    7 5   Florida   orange    14 6   Texas     banana    40 7   Arizona   banana    12 8   Florida   banana    190` 

使用pd.DataFrame.melt时,我们从变量(这里是不同的水果)创建的新列被命名为variable,值列的默认名称为value。我们可以通过使用var_name=value_name=参数来覆盖这些默认值:

`df.melt(     id_vars=["state"],     var_name="fruit",     value_name="number_grown", )` 
 `state     fruit   number_grown 0   Texas     apple   12 1   Arizona   apple   9 2   Florida   apple   0 3   Texas     orange  10 4   Arizona   orange  7 5   Florida   orange  14 6   Texas     banana  40 7   Arizona   banana  12 8   Florida   banana  190` 

作为额外的好处,pd.DataFrame.melt为你提供了一种简单的方法来控制在宽转长的转换中包含哪些列。例如,如果我们不想在新创建的长表中包含banana的值,我们可以只将appleorange的其他列作为参数传递给value_vars=

`df.melt(     id_vars=["state"],     var_name="fruit",     value_name="number_grown",     value_vars=["apple", "orange"], )` 
 `state     fruit     number_grown 0   Texas     apple     12 1   Arizona   apple     9 2   Florida   apple     0 3   Texas     orange    10 4   Arizona   orange    7 5   Florida   orange    14` 

使用 pd.wide_to_long 重塑数据

到目前为止,我们已经遇到了两种非常可行的方法,将数据从宽格式转换为长格式,无论是通过使用pd.DataFrame.stack方法(我们在使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 重塑数据食谱中介绍的),还是通过使用pd.DataFrame.melt(我们在使用 pd.DataFrame.melt 重塑数据食谱中看到的)。

如果这些还不够,pandas 提供了pd.wide_to_long函数,如果你的列遵循特定的命名模式,它可以帮助完成这种转换,正如我们在本食谱中所看到的。

如何实现

假设我们有以下pd.DataFrame,其中有一个id变量为widget,以及四列代表一个商业季度的销售额。每一列的销售额以"quarter_"开头:

`df = pd.DataFrame([     ["Widget 1", 1, 2, 4, 8],     ["Widget 2", 16, 32, 64, 128], ], columns=["widget", "quarter_1", "quarter_2", "quarter_3", "quarter_4"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `widget     quarter_1   quarter_2   quarter_3   quarter_4 0   Widget 1   1           2           4           8 1   Widget 2   16          32          64          128` 

回到我们pd.DataFrame.stack的例子,我们可以使用以下方法将其从宽格式转换为长格式:

`df.set_index("widget").stack().reset_index().rename(columns={     "level_1": "quarter",     0: "quantity", })` 
 `widget     quarter     quantity 0   Widget 1   quarter_1   1 1   Widget 1   quarter_2   2 2   Widget 1   quarter_3   4 3   Widget 1   quarter_4   8 4   Widget 2   quarter_1   16 5   Widget 2   quarter_2   32 6   Widget 2   quarter_3   64 7   Widget 2   quarter_4   128` 

对于一个更简洁的解决方案,我们可以使用pd.DataFrame.melt

`df.melt(     id_vars=["widget"],     var_name="quarter",     value_name="quantity", )` 
 `widget     quarter     quantity 0   Widget 1   quarter_1   1 1   Widget 2   quarter_1   16 2   Widget 1   quarter_2   2 3   Widget 2   quarter_2   32 4   Widget 1   quarter_3   4 5   Widget 2   quarter_3   64 6   Widget 1   quarter_4   8 7   Widget 2   quarter_4   128` 

但是pd.wide_to_long提供了一个特性,是这两种方法都没有直接处理的——即从正在转换为变量的列标签中创建一个新变量。到目前为止,我们看到新的quarter值为quarter_1quarter_2quarter_3quarter_4,但pd.wide_to_long可以从新创建的变量中提取该字符串,更简单地只留下数字1234

`pd.wide_to_long(     df,     i=["widget"],     stubnames="quarter_",     j="quarter" ).reset_index().rename(columns={"quarter_": "quantity"})` 
 `widget      quarter   quantity 0   Widget 1    1         1 1   Widget 2    1         16 2   Widget 1    2         2 3   Widget 2    2         32 4   Widget 1    3         4 5   Widget 2    3         64 6   Widget 1    4         8 7   Widget 2    4         128` 

使用 pd.DataFrame.pivot 和 pd.pivot_table 重塑数据

到目前为止,在本章中,我们已经看到pd.DataFrame.stackpd.DataFrame.meltpd.wide_to_long都可以帮助你将pd.DataFrame从宽格式转换为长格式。另一方面,我们只看到pd.Series.unstack帮助我们从长格式转换为宽格式,但该方法有一个缺点,需要我们在使用之前为其分配一个合适的行索引。使用pd.DataFrame.pivot,你可以跳过任何中间步骤,直接从长格式转换为宽格式。

除了pd.DataFrame.pivot之外,pandas 还提供了pd.pivot_table函数,它不仅可以将数据从长格式转换为宽格式,还允许你在重塑的同时进行聚合。

图 7.6:使用 pd.pivot_table 进行求和聚合重塑

有效使用pd.pivot_table可以让你使用紧凑简洁的语法执行非常复杂的计算。

如何实现

在前面的一些示例中,我们从宽格式数据开始,之后将其重塑为长格式。在这个示例中,我们将从一开始就使用长格式数据。我们还会添加一个新列number_eaten,以展示在 pandas 中透视时的聚合功能:

`df = pd.DataFrame([     ["Texas", "apple", 12, 8],     ["Arizona", "apple", 9, 10],     ["Florida", "apple", 0, 6],     ["Texas", "orange", 10, 4],     ["Arizona", "orange", 7, 2],     ["Florida", "orange", 14, 3],     ["Texas", "banana", 40, 28],     ["Arizona", "banana", 12, 17],     ["Florida", "banana", 190, 42], ], columns=["state", "fruit", "number_grown", "number_eaten"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state     fruit     number_grown     number_eaten 0   Texas     apple     12               8 1   Arizona   apple     9                10 2   Florida   apple     0                6 3   Texas     orange    10               4 4   Arizona   orange    7                2 5   Florida   orange    14               3 6   Texas     banana    40               28 7   Arizona   banana    12               17 8   Florida   banana    190              42` 

正如我们在使用 pd.DataFrame.stack 和 pd.DataFrame.unstack 重塑数据一节中学到的那样,如果我们希望将数据从长格式转换为宽格式,可以通过巧妙使用pd.DataFrame.set_index配合pd.DataFrame.unstack来实现:

`df.set_index(["state", "fruit"]).unstack()` 
 `number_grown                    number_eaten fruit   apple   banana  orange  apple   banana  orange state Arizona 9       12      7       10      17      2 Florida 0       190     14      6       42      3 Texas   12      40      10      8       28      4` 

pd.DataFrame.pivot让我们通过一次方法调用来解决这个问题。这个方法的基本用法需要index=columns=参数,用来指定哪些列应该出现在行和列的索引中:

`df.pivot(index=["state"], columns=["fruit"])` 
 `number_grown                    number_eaten fruit   apple   banana  orange  apple   banana  orange state Arizona 9       12      7       10      17      2 Florida 0       190     14      6       42      3 Texas   12      40      10      8       28      4` 

pd.DataFrame.pivot会将任何未指定为index=columns=参数的列,尝试转换为结果pd.DataFrame中的值。然而,如果你不希望所有剩余的列都成为透视后pd.DataFrame的一部分,你可以使用values=参数指定需要保留的列。例如,如果我们只关心透视number_grown列,而忽略number_eaten列,可以写成这样:

`df.pivot(       index=["state"],       columns=["fruit"],       values=["number_grown"],   )` 
 `number_grown fruit   apple   banana   orange state Arizona 9       12       7 Florida 0       190      14 Texas   12      40       10` 

如果你只想保留一个值,那么在列中生成的pd.MultiIndex可能显得多余。幸运的是,通过简单调用pd.DataFrame.droplevel,你可以删除它,在这个函数中你需要指明axis=,以指定你希望删除哪个级别(对于列,指定1),以及你希望删除的索引级别(这里0代表第一级):

`wide_df = df.pivot(     index=["state"],     columns=["fruit"],     values=["number_grown"], ).droplevel(level=0, axis=1) wide_df` 
`fruit   apple   banana   orange state Arizona 9       12       7 Florida 0       190      14 Texas   12      40       10` 

虽然pd.DataFrame.pivot对于重塑数据很有用,但它仅适用于那些用于形成行和列的值没有重复的情况。为了看到这个限制,我们来看一个稍微修改过的pd.DataFrame,展示不同水果在不同州和年份的消费或种植情况:

`df = pd.DataFrame([     ["Texas", "apple", 2023, 10, 6],     ["Texas", "apple", 2024, 2, 8],     ["Arizona", "apple", 2023, 3, 7],     ["Arizona", "apple", 2024, 6, 3],     ["Texas", "orange", 2023, 5, 2],     ["Texas", "orange", 2024, 5, 2],     ["Arizona", "orange", 2023, 7, 2], ], columns=["state", "fruit", "year", "number_grown", "number_eaten"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `state      fruit    year    number_grown   number_eaten 0   Texas      apple    2023    10             6 1   Texas      apple    2024    2              8 2   Arizona    apple    2023    3              7 3   Arizona    apple    2024    6              3 4   Texas      orange   2023    5              2 5   Texas      orange   2024    5              2 6   Arizona    orange   2023    7              2` 

如果我们将statefruityear放入行或列中,我们仍然能够在这个pd.DataFrame上使用pd.DataFrame.pivot

`df.pivot(     index=["state", "year"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
 `number_grown            number_eaten         fruit   apple   orange  apple   orange state   year Arizona 2023    3       7       7       2         2024    6       NaN     3       NaN Texas   2023    10      5       6       2         2024    2       5       8       2` 

那如果我们不想在输出中看到year呢?只需从pd.DataFrame.pivot的参数中移除它就会抛出异常:

`df.pivot(     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
`ValueError: Index contains duplicate entries, cannot reshape` 

对于pd.pivot_table,缺少year列完全不成问题:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"] )` 
 `number_eaten            number_grown fruit   apple   orange  apple   orange state Arizona 5.0     2.0     4.5     7.0 Texas   7.0     2.0     6.0     5.0` 

这之所以有效,是因为pd.pivot_table在重塑数据时会对值进行聚合,并转换为宽格式。以亚利桑那州的苹果为例,输入数据显示在 2023 年种植了三颗苹果,到了 2024 年数量翻倍达到了六颗。在我们调用pd.pivot_table时,这显示为4.5。默认情况下,pd.pivot_table会在重塑过程中取你提供的值的平均值。

当然,你可以控制使用的聚合函数。在这种特定情况下,我们可能更关心知道每个州总共种了多少水果,而不是按年份计算平均数。通过将不同的聚合函数作为参数传递给 aggfunc=,你可以轻松获得总和:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"],     aggfunc="sum" )` 
 `number_eaten            number_grown fruit   apple   orange  apple   orange state Arizona 10      2       9       7 Texas   14      4       12      10` 

对于更高级的使用场景,你甚至可以向 aggfunc= 提供一个值的字典,其中字典的每个键/值对分别指定要应用的列和聚合类型:

`pd.pivot_table(     df,     index=["state"],     columns=["fruit"],     values=["number_grown", "number_eaten"],     aggfunc={         "number_eaten": ["min", "max"],         "number_grown": ["sum", "mean"],     }, )` 
 `number_eaten            …       number_grown         max             min     …       mean    sum fruit   apple   orange  apple   …       orange  apple   orange state Arizona 7       2       37.0     9       7 Texas   8       2       65.0     12      10 2 rows × 8 columns` 

使用 pd.DataFrame.explode 进行数据重塑

如果每一条数据都能完美地作为标量适应一个二维的 pd.DataFrame,那该多好啊。然而,生活并非如此简单。特别是当处理像 JSON 这样的半结构化数据源时,pd.DataFrame 中的单个项包含非标量序列(如列表和元组)并不罕见。

你可能觉得将数据保持在这种状态下是可以接受的,但有时,将数据规范化并可能将列中的序列提取为单独的元素是有价值的。

图 7.7:使用 pd.DataFrame.explode 将列表元素提取到单独的行

为此,pd.DataFrame.explode 是完成此任务的正确工具。它可能不是你每天都使用的函数,但当你最终需要使用它时,你会很高兴知道它。试图在 pandas 之外复制相同的功能可能容易出错且性能较差!

如何实现

由于我们在本食谱的介绍中提到过 JSON 是一个很好的半结构化数据源,让我们假设需要与一个 HR 系统的 REST API 交互。HR 系统应该告诉我们公司中每个人是谁,以及谁(如果有的话)向他们报告。

员工之间的层级关系很容易用像 JSON 这样的半结构化格式表示,因此 REST API 可能会返回类似如下的内容:

`[     {         "employee_id": 1,         "first_name": "John",         "last_name": "Smith",         "direct_reports": [2, 3]     },     {         "employee_id": 2,         "first_name": "Jane",         "last_name": "Doe",         "direct_reports": []     },     {         "employee_id": 3,         "first_name": "Joe",         "last_name": "Schmoe",         "direct_reports": []     } ]` 

pandas 库还允许我们将这些数据加载到 pd.DataFrame 中,尽管 direct_reports 列包含的是列表:

`df = pd.DataFrame(     [         {             "employee_id": 1,             "first_name": "John",             "last_name": "Smith",             "direct_reports": [2, 3]         },         {             "employee_id": 2,             "first_name": "Jane",             "last_name": "Doe",             "direct_reports": []         },         {             "employee_id": 3,             "first_name": "Joe",             "last_name": "Schmoe",             "direct_reports": []         }     ] ) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `employee_id   first_name   last_name   direct_reports 0   1             John         Smith       [2, 3] 1   2             Jane         Doe         [] 2   3             Joe          Schmoe      []` 

使用 pd.DataFrame.explode,你可以将 direct_reports 拆分成 pd.DataFrame 中的单独行:

`df.explode("direct_reports").convert_dtypes(dtype_backend="numpy_nullable")` 
 `employee_id   first_name   last_name   direct_reports 0   1             John         Smith       2 0   1             John         Smith       3 1   2             Jane         Doe         <NA> 2   3             Joe          Schmoe      <NA>` 

基于我们在 使用 pd.merge 合并数据框 食谱中学到的合并/连接数据的知识,我们可以非常轻松地将爆炸后的信息与直接汇报人员的名字合并,从而生成一个关于谁在公司工作以及谁(如果有的话)向他们汇报的简易总结:

`exploded = df.explode("direct_reports").convert_dtypes(     dtype_backend="numpy_nullable" ) pd.merge(     exploded,     df.drop(columns=["direct_reports"]),     how="left",     left_on=["direct_reports"],     right_on=["employee_id"],     suffixes=("", "_direct_report"), )` 
 `employee_id  first_name  last_name  …  employee_id_direct_report  first_name_direct_report  last_name_direct_report 0   1        John     Smith    …  2       Jane           Doe 1   1        John     Smith    …  3       Joe            Schmoe 2   2        Jane     Doe      …  <NA>    <NA>           <NA> 3   3        Joe      Schmoe   …  <NA>    <NA>           <NA> 4 rows × 7 columns` 

还有更多内容…

虽然我们在第三章的类型回顾中没有介绍它,数据类型,但 PyArrow 确实提供了一种结构体数据类型,当它在 pd.Series 中使用时,会暴露出 pd.Series.struct.explode 方法:

`dtype = pd.ArrowDtype(pa.struct([     ("int_col", pa.int64()),     ("str_col", pa.string()),     ("float_col", pa.float64()), ])) ser = pd.Series([     {"int_col": 42, "str_col": "Hello, ", "float_col": 3.14159},     {"int_col": 555, "str_col": "world!", "float_col": 3.14159}, ], dtype=dtype) ser` 
`0    {'int_col': 42, 'str_col': 'Hello, ', 'float_c... 1    {'int_col': 555, 'str_col': 'world!', 'float_c... dtype: struct<int_col: int64, str_col: string, float_col: double>[pyarrow]` 

pd.DataFrame.explode 不同,后者会生成新的数据行,pd.Series.struct.explode 会根据其结构成员生成新的数据列:

`ser.struct.explode()` 
 `int_col   str_col   float_col 0   42        Hello,    3.14159 1   555       world!    3.14159` 

如果你处理的是类似 JSON 的半结构化数据源,这特别有用。如果你能将来自这样的数据源的嵌套数据适配到 PyArrow 提供的类型化结构中,那么 pd.Series.struct.explode 可以在尝试展开数据时为你节省大量麻烦。

使用 pd.DataFrame.T 进行转置

本章的最后一个实例,让我们来探索 pandas 中一个较为简单的重塑功能。转置是指将你的 pd.DataFrame 反转,使行变成列,列变成行的过程:

图 7.8:转置一个 pd.DataFrame

在这个实例中,我们将看到如何使用 pd.DataFrame.T 方法进行转置,同时讨论这可能会如何有用。

如何操作

pandas 中的转置非常简单。只需要获取任何 pd.DataFrame

`df = pd.DataFrame([     [1, 2, 3],     [4, 5, 6], ], columns=list("xyz"), index=list("ab")) df` 
 `x   y   z a   1   2   3 b   4   5   6` 

你只需要访问 pd.DataFrame.T 属性,就能看到你的行变成列,列变成行:

`df.T` 
 `a   b x   1   4 y   2   5 z   3   6` 

转置的原因无穷无尽,从单纯地觉得在给定格式下看起来更好,到更容易通过行索引标签选择而不是列索引标签选择的情况。

然而,转置的主要用例之一是,在应用函数之前,将你的 pd.DataFrame 转换为最佳格式。正如我们在第五章《算法与如何应用它们》中所学到的,pandas 能够对每一列进行聚合:

`df.sum()` 
`x    5 y    7 z    9 dtype: int64` 

以及对每一行使用axis=1参数:

`df.sum(axis=1)` 
`a     6 b    15 dtype: int64` 

不幸的是,使用 axis=1 参数可能会显著降低应用程序的性能。如果你发现自己在代码中散布了大量的 axis=1 调用, chances are 你最好先进行数据转置,再使用默认的 axis=0 来应用函数。

为了看出差异,让我们看一个相当宽的 pd.DataFrame

`np.random.seed(42) df = pd.DataFrame(     np.random.randint(10, size=(2, 10_000)),     index=list("ab"), ) df` 
 `0   1   29997   9998   9999 a   6   3   72      9      4 b   2   4   21      5      5 2 rows × 10,000 columns` 

最终,无论是求行和:

`df.sum(axis=1)` 
`a    44972 b    45097 dtype: int64` 

或者先转置,再使用默认的列求和:

`df.T.sum()` 
`a    44972 b    45097 dtype: int64` 

然而,如果你反复使用 axis=1 作为参数,你会发现,先进行转置可以节省大量时间。

为了衡量这一点,我们可以使用 IPython 来检查执行 100 次求和操作所需的时间:

`import timeit def baseline_sum():    for _ in range(100):       df.sum(axis=1) timeit.timeit(baseline_sum, number=100)` 
`4.366703154002607` 

相比之下,先进行转置然后求和会更快:

`def transposed_sum():    transposed = df.T    for _ in range(100):       transposed.sum() timeit.timeit(transposed_sum, number=100)` 
`0.7069798299999093` 

总体而言,使用 pd.DataFrame.T 来避免后续使用 axis=1 调用是非常推荐的做法。

加入我们的社区,加入 Discord 讨论

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/pandas

第八章:分组操作

数据分析中最基本的任务之一是将数据分成独立的组,然后对每个组执行计算。这种方法已经存在了很长时间,但最近被称为split-apply-combine

split-apply-combine范式的apply步骤中,了解我们是在进行归约(也称为聚合)还是转换是非常有帮助的。前者会将组中的值归约为一个值,而后者则试图保持组的形状不变。

为了说明这一点,以下是归约操作的 split-apply-combine 示例:

图 8.1:归约的 split-apply-combine 范式

下面是转换的相同范式:

图 8.2:转换的 split-apply-combine 范式

在 pandas 中,pd.DataFrame.groupby方法负责将数据进行分组,应用你选择的函数,并将结果合并回最终结果。

本章将介绍以下内容:

  • 分组基础

  • 分组并计算多个列

  • 分组应用

  • 窗口操作

  • 按年份选择评分最高的电影

  • 比较不同年份的棒球最佳击球手

分组基础

熟练掌握 pandas 的分组机制是每个数据分析师的重要技能。使用 pandas,你可以轻松地总结数据,发现不同组之间的模式,并进行组与组之间的比较。从理论上讲,能够在分组后应用的算法数目是无穷无尽的,这为分析师提供了极大的灵活性来探索数据。

在这个初步示例中,我们将从一个非常简单的求和操作开始,针对不同的组进行计算,数据集故意很小。虽然这个示例过于简化,但理解分组操作如何工作是非常重要的,这对于将来的实际应用非常有帮助。

如何实现

为了熟悉分组操作的代码实现,接下来我们将创建一些示例数据,匹配我们在图 8.1图 8.2中的起始点:

`df = pd.DataFrame([     ["group_a", 0],     ["group_a", 2],     ["group_b", 1],     ["group_b", 3],     ["group_b", 5], ], columns=["group", "value"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `group      value 0    group_a    0 1    group_a    2 2    group_b    1 3    group_b    3 4    group_b    5` 

我们的pd.DataFrame有两个不同的组:group_agroup_b。如你所见,group_a的行与value数据的02关联,而group_b的行与value数据的135关联。因此,对每个group的值进行求和,结果应该分别是29

为了使用 pandas 表达这一点,你将使用pd.DataFrame.groupby方法,该方法接受作为参数的分组名称。在我们的例子中,这是group列。技术上,它返回一个pd.core.groupby.DataFrameGroupBy对象,暴露出一个用于求和的pd.core.groupby.DataFrameGroupBy.sum方法:

`df.groupby("group").sum()` 
`group      value group_a    2 group_b    9` 

如果你觉得方法名 pd.core.groupby.DataFrameGroupBy.sum 太冗长,不用担心;它确实冗长,但你永远不需要手动写出它。我们在这里为了完整性会使用它的技术名称,但作为终端用户,你始终会按照你所看到的形式使用:

`df.groupby(<GROUP_OR_GROUPS>)` 

这就是你用来获取 pd.core.groupby.DataFrameGroupBy 对象的方式。

默认情况下,pd.core.groupby.DataFrameGroupBy.sum 被视为聚合,因此每个组在应用阶段会被归约为一行,就像我们在图 8.1 中看到的那样。

我们本可以不直接调用 pd.core.groupby.DataFrameGroupBy.sum,而是使用 pd.core.groupby.DataFrameGroupBy.agg 方法,并传递 "sum" 作为参数:

`df.groupby("group").agg("sum")` 
`group    value group_a  2 group_b  9` 

pd.core.groupby.DataFrameGroupBy.agg 的明确性在与 pd.core.groupby.DataFrameGroupBy.transform 方法对比时显得非常有用,后者将执行转换(再次见图 8.2),而不是归约

`df.groupby("group").transform("sum")` 
 `value 0       2 1       2 2       9 3       9 4       9` 

pd.core.groupby.DataFrameGroupBy.transform 保证返回一个具有相同索引的对象给调用者,这使得它非常适合进行诸如% of group之类的计算:

`df[["value"]].div(df.groupby("group").transform("sum"))` 
 `value 0    0.000000 1    1.000000 2    0.111111 3    0.333333 4    0.555556` 

在应用归约算法时,pd.DataFrame.groupby 会取出组的唯一值,并利用它们来形成一个新的行 pd.Index(或者在多个分组的情况下是 pd.MultiIndex)。如果你不希望分组标签创建新的索引,而是将它们保留为列,你可以传递 as_index=False

`df.groupby("group", as_index=False).sum()` 
 `group    value 0  group_a      2 1  group_b      9` 

你还应该注意,在执行分组操作时,任何非分组列的名称不会改变。例如,即使我们从一个包含名为 value 的列的 pd.DataFrame 开始:

`df` 
 `group    value 0  group_a      0 1  group_a      2 2  group_b      1 3  group_b      3 4  group_b      5` 

事实上,我们随后按 group 列分组并对 value 列求和,这并不会改变结果中的列名;它仍然叫做 value

`df.groupby("group").sum()` 
`group      value group_a    2 group_b    9` 

如果你对组应用其他算法,比如 min,这可能会让人困惑或产生歧义:

`df.groupby("group").min()` 
`group      value group_a    0 group_b    1` 

我们的列仍然叫做 value,即使在某个实例中,我们是在计算value 的总和,而在另一个实例中,我们是在计算value 的最小值

幸运的是,有一种方法可以通过使用 pd.NamedAgg 类来控制这一点。当调用 pd.core.groupby.DataFrameGroupBy.agg 时,你可以提供关键字参数,其中每个参数键决定了所需的列名,而参数值是 pd.NamedAgg,它决定了聚合操作以及它应用的原始列。

例如,如果我们想对 value 列应用 sum 聚合,并且将结果显示为 sum_of_value,我们可以写出以下代码:

`df.groupby("group").agg(sum_of_value=pd.NamedAgg(column="value", aggfunc="sum"))` 
`group           sum_of_value group_a         2 group_b         9` 

还有更多…

尽管这篇教程主要关注求和,但 pandas 提供了许多其他内置的归约算法,可以应用于 pd.core.groupby.DataFrameGroupBy 对象,例如以下几种:

anyallsumprod
idxminidxmaxminmax
meanmedianvarstd
semskewfirstlast

表 8.1:常用的 GroupBy 减少算法

同样,您可以使用一些内置的转换函数:

cumprodcumsumcummin
cummaxrank

表 8.2:常用的 GroupBy 转换算法

功能上,直接调用这些函数作为pd.core.groupby.DataFrameGroupBy的方法与将它们作为参数提供给pd.core.groupby.DataFrameGroupBy.aggpd.core.groupby.DataFrameGroupBy.transform没有区别。你将通过以下方式获得相同的性能和结果:

`df.groupby("group").max()` 
`group      value group_a    2 group_b    5` 

上述代码片段将得到与以下代码相同的结果:

`df.groupby("group").agg("max")` 
`group      value group_a    2 group_b    5` 

你可以说,后者的方法更明确,特别是考虑到max可以作为转换函数使用,就像它作为聚合函数一样:

`df.groupby("group").transform("max")` 
 `value 0       2 1       2 2       5 3       5 4       5` 

在实践中,这两种风格都很常见,因此你应该熟悉不同的方法。

对多个列进行分组和计算

现在我们掌握了基本概念,接下来让我们看一个包含更多数据列的pd.DataFrame。通常情况下,你的pd.DataFrame对象将包含许多列,且每列可能有不同的数据类型,因此了解如何通过pd.core.groupby.DataFrameGroupBy来选择并处理它们非常重要。

如何实现

让我们创建一个pd.DataFrame,展示一个假设的widget在不同regionmonth值下的销售退货数据:

`df = pd.DataFrame([     ["North", "Widget A", "Jan", 10, 2],     ["North", "Widget B", "Jan", 4, 0],     ["South", "Widget A", "Jan", 8, 3],     ["South", "Widget B", "Jan", 12, 8],     ["North", "Widget A", "Feb", 3, 0],     ["North", "Widget B", "Feb", 7, 0],     ["South", "Widget A", "Feb", 11, 2],     ["South", "Widget B", "Feb", 13, 4], ], columns=["region", "widget", "month", "sales", "returns"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `region     widget     month   sales     returns 0    North      Widget A   Jan     10        2 1    North      Widget B   Jan      4        0 2    South      Widget A   Jan      8        3 3    South      Widget B   Jan     12        8 4    North      Widget A   Feb      3        0 5    North      Widget B   Feb      7        0 6    South      Widget A   Feb     11        2 7    South      Widget B   Feb     13        4` 

要计算每个widget销售退货总额,你的第一次尝试可能会是这样的:

`df.groupby("widget").sum()` 
`widget    region                month         sales  returns Widget A  NorthSouthNorthSouth  JanJanFebFeb     32        7 Widget B  NorthSouthNorthSouth  JanJanFebFeb     36       12` 

尽管salesreturns看起来很好,但regionmonth列也被汇总了,使用的是 Python 在处理字符串时的相同求和逻辑:

`"North" + "South" + "North" + "South"` 
`NorthSouthNorthSouth` 

不幸的是,这种默认行为通常是不可取的。我个人认为很少会希望字符串以这种方式连接,而且在处理大型pd.DataFrame对象时,执行此操作的成本可能非常高。

避免这个问题的一种方法是更加明确地选择你希望聚合的列,可以在df.groupby("widget")调用后进行选择:

`df.groupby("widget")[["sales", "returns"]].agg("sum")` 
`widget        sales    returns Widget A      32        7 Widget B      36       12` 

或者,你可以使用我们在Group by basics配方中介绍的pd.NamedAgg类。虽然它更加冗长,但使用pd.NamedAgg可以让你重命名你希望在输出中看到的列(例如,sales可以改为sales_total):

`df.groupby("widget").agg(     sales_total=pd.NamedAgg(column="sales", aggfunc="sum"),     returns_total=pd.NamedAgg(column="returns", aggfunc="sum"), )` 
`widget            sales_total     returns_total Widget A          32               7 Widget B          36              12` 

pd.core.groupby.DataFrameGroupBy的另一个值得注意的特性是其能够处理多个group参数。通过提供一个列表,你可以扩展分组,涵盖widgetregion

`df.groupby(["widget", "region"]).agg(     sales_total=pd.NamedAgg("sales", "sum"),     returns_total=pd.NamedAgg("returns", "sum"), )` 
`widget      region          sales_total     returns_total Widget A    North           13               2             South           19               5 Widget B    North           11               0             South           25              12` 

使用pd.core.groupby.DataFrameGroupBy.agg时,没有对可以应用多少个函数的限制。例如,如果你想查看每个widgetregion中的销售退货summinmean,你可以简单地写出如下代码:

`df.groupby(["widget", "region"]).agg(     sales_total=pd.NamedAgg("sales", "sum"),     returns_total=pd.NamedAgg("returns", "sum"),     sales_min=pd.NamedAgg("sales", "min"),     returns_min=pd.NamedAgg("returns", "min"), )` 
 `sales_total   returns_total   sales_min   returns_min widget     region  Widget A   North            13                2           3             0            South            19                5           8             2 Widget B   North            11                0           4             0            South            25               12          12             4` 

还有更多……

虽然内置的分组聚合和转换函数在默认情况下非常有用,但有时你可能需要使用自己的自定义函数。当你发现某个算法在本地分析中“足够好”时,这尤其有用,尽管这个算法可能很难推广到所有使用场景。

pandas 中一个常见的请求函数是 mode,但是在分组操作中并没有开箱即用的提供,尽管有 pd.Series.mode 方法。使用 pd.Series.mode 时,返回的类型始终是 pd.Series,无论是否只有一个值出现频率最高:

`pd.Series([0, 1, 1]).mode()` 
`0    1 dtype: int64` 

即使有两个或更多元素出现频率相同,这一切仍然成立:

`pd.Series([0, 1, 1, 2, 2]).mode()` 
`0    1 1    2 dtype: int64` 

鉴于pd.Series.mode已存在,为什么 pandas 在进行分组时没有提供类似的功能?从 pandas 开发者的角度来看,原因很简单;没有一种单一的方式来解释分组操作应该返回什么。

让我们通过以下示例更详细地思考这个问题,其中 group_a 包含两个出现频率相同的值(42 和 555),而 group_b 只包含值 0:

`df = pd.DataFrame([     ["group_a", 42],     ["group_a", 555],     ["group_a", 42],     ["group_a", 555],     ["group_b", 0], ], columns=["group", "value"]) df` 
 `group    value 0 group_a     42 1 group_a    555 2 group_a     42 3 group_a    555 4 group_b      0` 

我们需要回答的问题是,对于 group_a,mode 应该返回什么? 一种可能的解决方案是返回一个列表(或任何 Python 序列),其中包含 42 和 555。然而,这种方法的缺点是,返回的 dtype 会是 object,这种类型的陷阱我们在 第三章数据类型 中已经讨论过。

`pd.Series([[42, 555], 0], index=pd.Index(["group_a", "group_b"], name="group"))` 
`group group_a    [42, 555] group_b            0 dtype: object` 

第二种期望是,pandas 只是选择一个值。当然,这就引出了一个问题,pandas 应该如何做出这个决定——对于 group_a,值 42 或 555 哪个更合适,如何在一般情况下做出判断呢?

另一个期望是,在聚合后的结果行索引中,group_a 标签会出现两次。然而,没有其他的分组聚合是这样工作的,所以我们会引入新的并可能是意外的行为,通过简化为此:

`pd.Series(     [42, 555, 0],     index=pd.Index(["group_a", "group_a", "group_b"], name="group") )` 
`group group_a     42 group_a    555 group_b      0 dtype: int64` 

pandas 并没有试图解决所有这些期望并将其编码为 API 的一部分,而是完全交给你来决定如何实现 mode 函数,只要你遵循聚合操作每个分组返回单一值的预期。这排除了我们刚才概述的第三种期望,至少在本章后续谈论 Group by apply 时才会重新讨论。

为此,如果我们想使用自定义的众数函数,它们可能最终看起来像这样:

`def scalar_or_list_mode(ser: pd.Series):     result = ser.mode()     if len(result) > 1:         return result.tolist()     elif len(result) == 1:         return result.iloc[0]     return pd.NA def scalar_or_bust_mode(ser: pd.Series):     result = ser.mode()     if len(result) == 0:         return pd.NA     return result.iloc[0]` 

由于这些都是聚合操作,我们可以在 pd.core.groupby.DataFrameGroupBy.agg 操作的上下文中使用它们:

`df.groupby("group").agg(     scalar_or_list=pd.NamedAgg(column="value", aggfunc=scalar_or_list_mode),     scalar_or_bust=pd.NamedAgg(column="value", aggfunc=scalar_or_bust_mode), )` 
 `scalar_or_list    scalar_or_bust group                                 group_a   [42, 555]              42 group_b          0                0` 

Group by apply

在我们讨论算法以及如何应用它们的 第五章算法及其应用 中,我们接触到了 Apply 函数,它既强大又令人恐惧。一个与 group by 等效的函数是 pd.core.groupby.DataFrameGroupBy.apply,并且有着相同的注意事项。通常,这个函数被过度使用,您应该选择 pd.core.groupby.DataFrameGroupBy.aggpd.core.groupby.DataFrameGroupBy.transform。然而,对于那些您既不想要 聚合 也不想要 转换,但又希望得到介于两者之间的功能的情况,使用 apply 是唯一的选择。

通常情况下,pd.core.groupby.DataFrameGroupBy.apply 应该仅在不得已时使用。它有时会产生模糊的行为,并且在 pandas 的不同版本之间容易出现破裂。

如何做

在前一个食谱的 还有更多… 部分中,我们提到过从以下的 pd.DataFrame 开始是不可能的:

`df = pd.DataFrame([     ["group_a", 42],     ["group_a", 555],     ["group_a", 42],     ["group_a", 555],     ["group_b", 0], ], columns=["group", "value"]) df = df.convert_dtypes(dtype_backend="numpy_nullable") df` 
 `group    value 0 group_a     42 1 group_a    555 2 group_a     42 3 group_a    555 4 group_b      0` 

并使用自定义的 mode 算法,提供给 pd.core.groupby.DataFrameGroupBy.agg 以生成以下输出:

`pd.Series(     [42, 555, 0],     index=pd.Index(["group_a", "group_a", "group_b"], name="group"),     dtype=pd.Int64Dtype(), )` 
`group group_a     42 group_a    555 group_b      0 dtype: Int64` 

这样做的原因很简单;聚合期望你将每个分组标签减少到一个单一值。输出中重复 group_a 标签两次对于聚合来说是不可接受的。同样,转换期望你生成的结果与调用的 pd.DataFrame 具有相同的行索引,而这并不是我们想要的结果。

pd.core.groupby.DataFrameGroupBy.apply 是一个介于两者之间的方法,可以让我们更接近所期望的结果,正如接下来的代码所示。作为一个技术性的旁注,include_groups=False 参数被传递以抑制关于 pandas 2.2 版本行为的弃用警告。在后续版本中,您可能不需要这个参数:

`def mode_for_apply(df: pd.DataFrame):     return df["value"].mode() df.groupby("group").apply(mode_for_apply, include_groups=False)` 
`group       group_a  0     42          1    555 group_b  0      0 Name: value, dtype: Int64` 

需要注意的是,我们将 mode_for_apply 函数的参数注解为 pd.DataFrame。在聚合和转换中,用户定义的函数每次只会接收一个 pd.Series 类型的数据,但使用 apply 时,您将获得整个 pd.DataFrame。如果想更详细地了解发生了什么,可以在用户定义的函数中添加 print 语句:

`def mode_for_apply(df: pd.DataFrame):     print(f"\nThe data passed to apply is:\n{df}")     return df["value"].mode() df.groupby("group").apply(mode_for_apply, include_groups=False)` 
`The data passed to apply is:   value 0     42 1    555 2     42 3    555 The data passed to apply is:   value 4      0 group      group_a  0     42         1    555 group_b  0      0 Name: value, dtype: Int64` 

本质上,pd.core.groupby.DataFrameGroupBy.apply 将数据传递给用户定义的函数,传递的数据是一个 pd.DataFrame,并排除了用于分组的列。从那里,它会查看用户定义的函数的返回类型,并尝试推断出最合适的输出形状。在这个特定的例子中,由于我们的 mode_for_apply 函数返回的是一个 pd.Seriespd.core.groupby.DataFrameGroupBy.apply 已经确定最佳的输出形状应该是一个 pd.MultiIndex,其中索引的第一层是组值,第二层包含由 mode_for_apply 函数返回的 pd.Series 的行索引。

pd.core.groupby.DataFrameGroupBy.apply 被过度使用的地方在于,当它检测到所应用的函数可以减少为单个值时,它会改变形状,看起来像是一个聚合操作:

`def sum_values(df: pd.DataFrame):     return df["value"].sum() df.groupby("group").apply(sum_values, include_groups=False)` 
`group group_a    1194 group_b       0 dtype: int64` 

然而,以这种方式使用它是一个陷阱。即使它能够推断出一些输出的合理形状,确定这些形状的规则是实现细节,这会导致性能损失,或者在不同版本的 pandas 中可能导致代码破裂。如果你知道你的函数将减少为单个值,始终选择使用 pd.core.groupby.DataFrameGroupBy.agg 来代替 pd.core.groupby.DataFrameGroupBy.apply,后者只应在极端用例中使用。

窗口操作

窗口操作允许你在一个滑动的分区(或“窗口”)内计算值。通常,这些操作用于计算“滚动的 90 天平均值”等,但它们足够灵活,可以扩展到你选择的任何算法。

虽然从技术上讲这不是一个分组操作,但窗口操作在这里被包含进来,因为它们共享类似的 API 并且可以与“数据组”一起工作。与分组操作的唯一不同之处在于,窗口操作并不是通过唯一值集来形成分组,而是通过遍历 pandas 对象中的每个值,查看特定数量的前后(有时是后续)值来创建其组。

如何实现

为了理解窗口操作如何工作,让我们从一个简单的 pd.Series 开始,其中每个元素是 2 的递增幂:

`ser = pd.Series([0, 1, 2, 4, 8, 16], dtype=pd.Int64Dtype()) ser` 
`0     0 1     1 2     2 3     4 4     8 5    16 dtype: Int64` 

你将遇到的第一种窗口操作是“滚动窗口”,通过 pd.Series.rolling 方法访问。当调用此方法时,你需要告诉 pandas 你希望的窗口大小 n。pandas 会从每个元素开始,向后查看 n-1 个记录来形成“窗口”:

`ser.rolling(2).sum()` 
`0     NaN 1     1.0 2     3.0 3     6.0 4    12.0 5    24.0 dtype: float64` 

你可能注意到,我们开始时使用了 pd.Int64Dtype(),但在滚动窗口操作后,最终得到了 float64 类型。不幸的是,pandas 窗口操作至少在 2.2 版本中与 pandas 扩展系统的兼容性不好(参见问题 #50449),因此目前,我们需要将结果转换回正确的数据类型:

`ser.rolling(2).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1       1 2       3 3       6 4      12 5      24 dtype: Int64` 

那么,这里发生了什么?本质上,你可以将滚动窗口操作看作是遍历 pd.Series 的值。在此过程中,它向后查看,试图收集足够的值以满足所需的窗口大小,我们指定的窗口大小是 2。

在每个窗口中收集两个元素后,pandas 会应用指定的聚合函数(在我们的例子中是求和)。每个窗口中的聚合结果将用于将结果拼接回去:

图 8.3:滚动窗口与求和聚合

对于我们的第一个记录,由于无法形成包含两个元素的窗口,pandas 会返回缺失值。如果你希望滚动计算即使窗口大小无法满足也能尽可能地求和,你可以向min_periods=传递一个参数,指定每个窗口中进行聚合所需的最小元素数量:

`ser.rolling(2, min_periods=1).sum().astype(pd.Int64Dtype())` 
`0     0 1     1 2     3 3     6 4    12 5    24 dtype: Int64` 

默认情况下,滚动窗口操作会向后查找以满足你的窗口大小要求。你也可以将它们“居中”,让 pandas 同时向前和向后查找。

这种效果在使用奇数窗口大小时更为明显。当我们将窗口大小扩展为3时,注意到的区别如下:

`ser.rolling(3).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1    <NA> 2       3 3       7 4      14 5      28 dtype: Int64` 

与使用center=True参数的相同调用进行比较:

`ser.rolling(3, center=True).sum().astype(pd.Int64Dtype())` 
`0    <NA> 1       3 2       7 3      14 4      28 5    <NA> dtype: Int64` 

与查看当前值及前两个值不同,使用center=True告诉 pandas 在窗口中包含当前值、前一个值和后一个值。

另一种窗口函数是“扩展窗口”,它会查看所有先前遇到的值。其语法非常简单;只需将调用pd.Series.rolling替换为pd.Series.expanding,然后跟随你想要的聚合函数。扩展求和类似于你之前看到的pd.Series.cumsum方法,因此为了演示,我们选择一个不同的聚合函数,比如mean

`ser.expanding().mean().astype(pd.Float64Dtype())` 
`0         0.0 1         0.5 2         1.0 3        1.75 4         3.0 5    5.166667 dtype: Float64` 

以可视化方式表示,扩展窗口计算如下(为了简洁,未显示所有pd.Series元素):

图 8.4:扩展窗口与均值聚合

还有更多…

第九章时间数据类型与算法中,我们将更深入地探讨 pandas 在处理时间数据时提供的一些非常有用的功能。在我们深入探讨之前,值得注意的是,分组和滚动/扩展窗口函数与此类数据非常自然地配合使用,让你能够简洁地执行诸如“X 天移动平均”、“年初至今 X”、“季度至今 X”等计算。

为了看看这如何运作,我们再来看一下在第五章算法及如何应用它们中,我们最初使用的 Nvidia 股票表现数据集,该数据集作为计算追踪止损价格食谱的一部分:

`df = pd.read_csv(     "data/NVDA.csv",     usecols=["Date", "Close"],     parse_dates=["Date"],     dtype_backend="numpy_nullable", ).set_index("Date") df` 
 `Date        Close 2020-01-02    59.977501 2020-01-03    59.017502 2020-01-06    59.264999 2020-01-07    59.982498 2020-01-08    60.095001 …             … 2023-12-22   488.299988 2023-12-26   492.790009 2023-12-27   494.170013 2023-12-28   495.220001 2023-12-29   495.220001 1006 rows × 1 columns` 

使用滚动窗口函数,我们可以轻松地计算 30 天、60 天和 90 天的移动平均。随后调用pd.DataFrame.plot也让这种可视化变得简单:

`import matplotlib.pyplot as plt plt.ion() df.assign(     ma30=df["Close"].rolling(30).mean().astype(pd.Float64Dtype()),     ma60=df["Close"].rolling(60).mean().astype(pd.Float64Dtype()),     ma90=df["Close"].rolling(90).mean().astype(pd.Float64Dtype()), ).plot()` 

对于“年初至今”和“季度至今”计算,我们可以使用分组与扩展窗口函数的组合。对于“年初至今”的最小值、最大值和均值,我们可以首先形成一个分组对象,将数据分成按年划分的桶,然后可以调用.expanding()

`df.groupby(pd.Grouper(freq="YS")).expanding().agg(     ["min", "max", "mean"] )` 
 `Close                         min        max        mean Date       Date 2020-01-01  2020-01-02  59.977501  59.977501  59.977501             2020-01-03  59.017502  59.977501  59.497501             2020-01-06  59.017502  59.977501  59.420001             2020-01-07  59.017502  59.982498  59.560625             2020-01-08  59.017502  60.095001  59.667500 …           …          …          …          … 2023-01-01  2023-12-22  142.649994  504.089996  363.600610             2023-12-26  142.649994  504.089996  364.123644             2023-12-27  142.649994  504.089996  364.648024             2023-12-28  142.649994  504.089996  365.172410             2023-12-29  142.649994  504.089996  365.692600 1006 rows × 3 columns` 

pd.Grouper(freq="YS")将我们的行索引(包含日期时间)按年份的开始进行分组。分组后,调用.expanding()执行最小值/最大值聚合,只看每年开始时的值。这个效果再次通过可视化更容易看出:

`df.groupby(pd.Grouper(freq="YS")).expanding().agg(     ["min", "max", "mean"] ).droplevel(axis=1, level=0).reset_index(level=0, drop=True).plot()` 

为了获得更详细的视图,你可以通过将freq=参数从YS改为QS,计算每个季度的扩展最小/最大收盘价格:

`df.groupby(pd.Grouper(freq="QS")).expanding().agg(     ["min", "max", "mean"] ).reset_index(level=0, drop=True).plot()` 

使用MS freq=参数可以将时间精度降低到月份级别:

`df.groupby(pd.Grouper(freq="MS")).expanding().agg(     ["min", "max", "mean"] ).reset_index(level=0, drop=True).plot()` 

按年份选择评分最高的电影

数据分析中最基本和常见的操作之一是选择某个列在组内具有最大值的行。应用到我们的电影数据集,这可能意味着找出每年评分最高的电影或按内容评级找出最高票房的电影。为了完成这些任务,我们需要对组以及用于排名每个组成员的列进行排序,然后提取每个组中的最高成员。

在这个示例中,我们将使用pd.DataFrame.sort_valuespd.DataFrame.drop_duplicates的组合,找出每年评分最高的电影。

如何操作

开始时,读取电影数据集并将其精简为我们关心的三列:movie_titletitle_yearimdb_score

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score"],     dtype_backend="numpy_nullable", ) df` 
 `movie_title                                  title_year  imdb_score 0  Avatar                                        2009.0        7.9 1  Pirates of the Caribbean: At World's End      2007.0        7.1 2  Spectre                                       2015.0        6.8 3  The Dark Knight Rises                         2012.0        8.5 4  Star Wars: Episode VII - The Force Awakens     <NA>        7.1 …                                                 …          … 4911  Signed Sealed Delivered                    2013.0        7.7 4912  The Following                               <NA>        7.5 4913  A Plague So Pleasant                       2013.0        6.3 4914  Shanghai Calling                           2012.0        6.3 4915  My Date with Drew                          2004.0        6.6 4916 rows × 3 columns` 

如你所见,title_year列被解释为浮动小数点值,但年份应始终是整数。我们可以通过直接为列分配正确的数据类型来纠正这一点:

`df["title_year"] = df["title_year"].astype(pd.Int16Dtype()) df.head(3)` 
 `movie_title                                title_year  imdb_score 0   Avatar                                     2009        7.9 1   Pirates of the Caribbean: At World's End   2007        7.1 2   Spectre                                    2015        6.8` 

另外,我们也可以在pd.read_csv中通过dtype=参数传递所需的数据类型:

`df = pd.read_csv(     "data/movie.csv",     usecols=["movie_title", "title_year", "imdb_score"],     dtype={"title_year": pd.Int16Dtype()},     dtype_backend="numpy_nullable", ) df.head(3)` 
 `movie_title                                 title_year  imdb_score 0   Avatar                                      2009         7.9 1   Pirates of the Caribbean: At World's End    2007         7.1 2   Spectre                                     2015         6.8` 

通过数据清洗工作完成后,我们现在可以专注于回答“每年评分最高的电影是什么?”这个问题。我们可以通过几种方式来计算,但让我们从最常见的方法开始。

当你在 pandas 中执行分组操作时,原始pd.DataFrame中行的顺序会被保留,行会根据不同的组进行分配。知道这一点后,很多用户会通过首先按title_yearimdb_score对数据集进行排序来回答这个问题。排序后,你可以按title_year列进行分组,仅选择movie_title列,并链式调用pd.DataFrameGroupBy.last来选择每个组的最后一个值:

`df.sort_values(["title_year", "imdb_score"]).groupby(     "title_year" )[["movie_title"]].agg(top_rated_movie=pd.NamedAgg("movie_title", "last"))` 
`title_year                                    top_rated_movie 1916         Intolerance: Love's Struggle Throughout the Ages 1920                           Over the Hill to the Poorhouse 1925                                           The Big Parade 1927                                               Metropolis 1929                                            Pandora's Box …                                                           … 2012                                         Django Unchained 2013                  Batman: The Dark Knight Returns, Part 2 2014                                           Butterfly Girl 2015                                          Running Forever 2016                                     Kickboxer: Vengeance 91 rows × 1 columns` 

如果使用pd.DataFrameGroupBy.idxmax,它会选择每年评分最高的电影的行索引值,这是一种更简洁的方法。这要求你事先将索引设置为movie_title

`df.set_index("movie_title").groupby("title_year").agg(     top_rated_movie=pd.NamedAgg("imdb_score", "idxmax") )` 
`title_year                                   top_rated_movie 1916        Intolerance: Love's Struggle Throughout the Ages 1920                          Over the Hill to the Poorhouse 1925                                          The Big Parade 1927                                              Metropolis 1929                                           Pandora's Box …                                                          … 2012                                   The Dark Knight Rises 2013                 Batman: The Dark Knight Returns, Part 2 2014                                  Queen of the Mountains 2015                                         Running Forever 2016                                    Kickboxer: Vengeance 91 rows × 1 columns` 

我们的结果大致相同,尽管我们可以看到在 2012 年和 2014 年,两个方法在选择评分最高的电影时存在不同。仔细查看这些电影标题可以揭示出根本原因:

`df[df["movie_title"].isin({     "Django Unchained",     "The Dark Knight Rises",     "Butterfly Girl",     "Queen of the Mountains", })]` 
 `movie_title 			title_year 	imdb_score 3 			The Dark Knight Rises 	2012 		8.5 293 			Django Unchained 		2012 		8.5 4369 		Queen of the Mountains 	2014 		8.7 4804 		Butterfly Girl			2014 		8.7` 

在发生平局的情况下,每种方法都有自己选择值的方式。没有哪种方法本身是对错的,但如果你希望对这一点进行更精细的控制,你将不得不使用按组应用

假设我们想要汇总这些值,以便在没有平局的情况下返回一个字符串,而在发生平局时返回一组字符串。为此,您应该定义一个接受pd.DataFrame的函数。这个pd.DataFrame将包含与每个唯一分组列相关的值,在我们的例子中,这个分组列是title_year

在函数体内,您可以找出最高的电影评分,找到所有具有该评分的电影,并返回一个单一的电影标题(当没有平局时)或一组电影(在发生平局时):

`def top_rated(df: pd.DataFrame):     top_rating = df["imdb_score"].max()     top_rated = df[df["imdb_score"] == top_rating]["movie_title"].unique()     if len(top_rated) == 1:         return top_rated[0]     else:         return top_rated df.groupby("title_year").apply(     top_rated, include_groups=False ).to_frame().rename(columns={0: "top_rated_movie(s)"})` 
`title_year                                   top_rated_movie(s) 1916           Intolerance: Love's Struggle Throughout the Ages 1920                             Over the Hill to the Poorhouse 1925                                             The Big Parade 1927                                                 Metropolis 1929                                              Pandora's Box …                                                             … 2012                  [The Dark Knight Rises, Django Unchained] 2013                    Batman: The Dark Knight Returns, Part 2 2014                   [Queen of the Mountains, Butterfly Girl] 2015                                            Running Forever 2016                                       Kickboxer: Vengeance 91 rows × 1 columns` 

比较棒球历史上各年最佳击球手

第五章,算法及其应用 中的寻找棒球* *最擅长的球员…*食谱中,我们处理了一个已经汇总了 2020 至 2023 年球员表现的数据集。然而,基于球员在多个年份之间的表现进行比较相当困难。即使是逐年比较,某一年看似精英的统计数据,其他年份可能也仅被认为是“非常好”。统计数据跨年份的变化原因可以进行辩论,但可能归结为战略、设备、天气以及纯粹的统计运气等多种因素的组合。

对于这个食谱,我们将使用一个更精细的数据集,该数据集细化到游戏层级。从那里,我们将把数据汇总到年度总结,然后计算一个常见的棒球统计数据——打击率

对于不熟悉的人,打击率是通过将球员的击球次数(即他们击打棒球并成功上垒的次数)作为总打席次数(即他们上场打击的次数,不包括保送)的百分比来计算的。

那么,什么样的打击率才算好呢?正如你所见,答案是一个不断变化的目标,甚至在过去的二十年里也发生了变化。在 2000 年代初,打击率在.260-.270 之间(即每 26%-27%的打击中能击中一次)被认为是职业选手的中等水平。近年来,这个数字已经下降到了.240-.250 的范围。

因此,为了尝试将每年最佳击球手进行比较,我们不能仅仅看打击率。在一个联盟整体打击率为.240 的年份,打击率为.325 的球员可能比在联盟整体打击率为.260 的年份,打击率为.330 的球员更具震撼力。

如何做

再次强调,我们将使用从retrosheet.org收集的数据,并附上以下法律声明:

这里使用的信息是从 Retrosheet 免费获得的,并且受版权保护。感兴趣的各方可以通过 www.retrosheet.org 联系 Retrosheet。

在这个示例中,我们将使用 2000-2023 年每场常规赛的“比赛记录”摘要:

`df = pd.read_parquet("data/mlb_batting_lines.parquet") df` 
 `year     game       starttime   …   cs  gidp  int 0   2015  ANA201504100   7:12PM   …    0    0    0 1   2015  ANA201504100   7:12PM   …    0    0    0 2   2015  ANA201504100   7:12PM   …    0    0    0 3   2015  ANA201504100   7:12PM   …    0    0    0 4   2015  ANA201504100   7:12PM   …    0    0    0 …     …          …          …   …    …    …    … 1630995 2013  WAS201309222   7:06PM   …    0    0    0 1630996 2013  WAS201309222   7:06PM   …    0    0    0 1630997 2013  WAS201309222   7:06PM   …    0    0    0 1630998 2013  WAS201309222   7:06PM   …    0    0    0 1630999 2013  WAS201309222   7:06PM   …    0    0    0 1631000 rows × 26 columns` 

比赛记录总结了每个球员在比赛中的表现。因此,我们可以专注于 2015 年 4 月 10 日在巴尔的摩进行的某场比赛,并查看击球手的表现:

`bal = df[df["game"] == "BAL201504100"] bal.head()` 
 `year      game       starttime   …  cs  gidp  int 2383  2015  BAL201504100   3:11PM   …   0    0    0 2384  2015  BAL201504100   3:11PM   …   0    0    0 2385  2015  BAL201504100   3:11PM   …   0    0    0 2386  2015  BAL201504100   3:11PM   …   0    0    0 2387  2015  BAL201504100   3:11PM   …   0    0    0 5 rows × 26 columns` 

在那场比赛中,我们看到了总共 75 次打击(ab)、29 次安打(h)和两支本垒打(hr):

`bal[["ab", "h", "hr"]].sum()` 
`ab    75 h     29 hr     2 dtype: Int64` 

通过对比赛记录的基本理解,我们可以将焦点转向计算每个球员每年产生的打击率。每个球员在我们的数据集中都有一个 id 列的标注,而由于我们想查看整个赛季的打击率,因此我们可以使用 yearid 的组合作为 pd.DataFrame.groupby 的参数。然后,我们可以对打击次数(ab)和安打次数(h)列进行求和:

`df.groupby(["year", "id"]).agg(     total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),     total_h=pd.NamedAgg(column="h", aggfunc="sum"), )` 
`year  id        total_ab  total_h 2000  abboj002     215       59       abbok002     157       34       abbop001       5        2       abreb001     576      182       acevj001       1        0 …     …           …        … 2023  zavas001     175       30       zerpa001       0        0       zimmb002       0        0       zunig001       0        0       zunim001     124       22 31508 rows × 2 columns` 

为了将这些总数转化为打击率,我们可以使用 pd.DataFrame.assign 链接一个除法操作。之后,调用 pd.DataFrame.drop 将让我们专注于打击率,删除我们不再需要的 total_abtotal_h 列:

`(     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) )` 
`year  id        avg 2000  abboj002  0.274419       abbok002  0.216561       abbop001  0.400000       abreb001  0.315972       acevj001  0.000000 …     …         … 2023  zavas001  0.171429       zerpa001  NaN       zimmb002  NaN       zunig001  NaN       zunim001  0.177419 31508 rows × 1 columns` 

在我们继续之前,我们必须考虑在计算平均值时可能出现的一些数据质量问题。在一整个棒球赛季中,球队可能会使用一些只在非常特殊情况下出现的球员,导致其打席次数很低。在某些情况下,击球手甚至可能在整个赛季中没有记录一个“打击机会”,所以使用它作为除数时,可能会导致除以 0,从而产生 NaN。在一些击球手打击次数不为零但仍然相对较少的情况下,小样本量可能会严重扭曲他们的打击率。

美国职棒大联盟有严格的规定,确定一个击球手需要多少次打击机会才能在某一年内资格入选记录。我们不必完全遵循这个规则,也不需要在我们的数据集中计算打击机会,但我们可以通过设置至少 400 次打击机会的要求来作为替代:

`(     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .loc[lambda df: df["total_ab"] > 400]     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) )` 
`year  id        avg 2000  abreb001  0.315972       alfoe001  0.323529       alicl001  0.294444       alomr001  0.309836       aloum001  0.354626 …     …         … 2023  walkc002  0.257732       walkj003  0.276190       wittb002  0.276131       yelic001  0.278182       yoshm002  0.288641 4147 rows × 1 columns` 

我们可以进一步总结,通过查找每个赛季的平均值和最大值 batting_average,甚至可以使用 pd.core.groupby.DataFrameGroupBy.idxmax 来识别出打击率最高的球员:

`averages = (     df.groupby(["year", "id"]).agg(         total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),         total_h=pd.NamedAgg(column="h", aggfunc="sum"))     .loc[lambda df: df["total_ab"] > 400]     .assign(avg=lambda x: x["total_h"] / x["total_ab"])     .drop(columns=["total_ab", "total_h"]) ) averages.groupby("year").agg(     league_mean_avg=pd.NamedAgg(column="avg", aggfunc="mean"),     league_max_avg=pd.NamedAgg(column="avg", aggfunc="max"),     batting_champion=pd.NamedAgg(column="avg", aggfunc="idxmax"), )` 
`year  league_mean_avg  league_max_avg  batting_champion 2000  0.284512         0.372414         (2000, heltt001) 2001  0.277945         0.350101         (2001, walkl001) 2002  0.275713         0.369727         (2002, bondb001) 2003  0.279268         0.358714         (2003, pujoa001) 2004  0.281307         0.372159         (2004, suzui001) 2005  0.277350         0.335017         (2005, lee-d002) 2006  0.283609         0.347409         (2006, mauej001) 2007  0.281354         0.363025         (2007, ordom001) 2008  0.277991         0.364465         (2008, jonec004) 2009  0.278010         0.365201         (2009, mauej001) 2010  0.271227         0.359073         (2010, hamij003) 2011  0.269997         0.344406         (2011, cabrm001) 2012  0.269419         0.346405         (2012, cabrm002) 2013  0.268789         0.347748         (2013, cabrm001) 2014  0.267409         0.340909         (2014, altuj001) 2015  0.268417         0.337995         (2015, cabrm001) 2016  0.270181         0.347826         (2016, lemad001) 2017  0.268651         0.345763         (2017, altuj001) 2018  0.261824         0.346154         (2018, bettm001) 2019  0.269233         0.335341         (2019, andet001) 2021  0.262239         0.327731         (2021, turnt001) 2022  0.255169         0.326454         (2022, mcnej002) 2023  0.261457         0.353659         (2023, arral001)` 

正如我们所看到的,平均击球率每年都有波动,而这些数字在 2000 年左右较高。在 2005 年,平均击球率为 0.277,最佳击球手(lee-d002,或德雷克·李)击出了 0.335 的成绩。2019 年最佳击球手(andet001,或蒂姆·安德森)同样打出了 0.335 的平均击球率,但整个联盟的平均击球率约为 0.269。因此,有充分的理由认为,蒂姆·安德森 2019 赛季的表现比德雷克·李 2005 赛季的表现更为出色,至少从击球率的角度来看。

虽然计算均值很有用,但它并没有完整地展示一个赛季内发生的所有情况。我们可能更希望了解每个赛季击球率的整体分布,这就需要可视化图表来呈现。我们在用 seaborn 按十年绘制电影评分的食谱中发现的小提琴图,可以帮助我们更详细地理解这一点。

首先,让我们设置好 seaborn 的导入,并尽快让 Matplotlib 绘制图表:

`import matplotlib.pyplot as plt import seaborn as sns plt.ion()` 

接下来,我们需要为 seaborn 做一些调整。seaborn 不支持pd.MultiIndex,因此我们将使用pd.DataFrame.reset_index将索引值移到列中。此外,seaborn 可能会误将离散的年份值(如 2000、2001、2002 等)解释为一个连续的范围,我们可以通过将该列转化为类别数据类型来解决这个问题。

我们希望构建的pd.CategoricalDtype是有序的,这样 pandas 才能确保 2000 年之后是 2001 年,2001 年之后是 2002 年,依此类推:

`sns_df = averages.reset_index() years = sns_df["year"].unique() cat = pd.CategoricalDtype(sorted(years), ordered=True) sns_df["year"] = sns_df["year"].astype(cat) sns_df` 
 `year      id        avg 0   2000  abreb001  0.315972 1   2000  alfoe001  0.323529 2   2000  alicl001  0.294444 3   2000  alomr001  0.309836 4   2000  aloum001  0.354626 …     …      …         … 4142 2023  walkc002  0.257732 4143 2023  walkj003  0.276190 4144 2023  wittb002  0.276131 4145 2023  yelic001  0.278182 4146 2023  yoshm002  0.288641 4147 rows × 3 columns` 

23 年的数据绘制在一张图上可能会占据大量空间,所以我们先来看 2000-2009 年这段时间的数据:

`mask = (sns_df["year"] >= 2000) & (sns_df["year"] < 2010) fig, ax = plt.subplots() sns.violinplot(     data=sns_df[mask],     ax=ax,     x="avg",     y="year",     order=sns_df.loc[mask, "year"].unique(), ) ax.set_xlim(0.15, 0.4) plt.show()` 

我们故意调用了plt.subplots()并使用ax.set_xlim(0.15, 0.4),以确保在绘制其余年份时 x 轴不会发生变化:

`mask = sns_df["year"] >= 2010 fig, ax = plt.subplots() sns.violinplot(     data=sns_df[mask],     ax=ax,     x="avg",     y="year",     order=sns_df.loc[mask, "year"].unique(), ) ax.set_xlim(0.15, 0.4) plt.show()` 

虽然某些年份的数据表现出偏斜(例如 2014 年向右偏斜,2018 年向左偏斜),但我们通常可以将这些数据的分布视为接近正态分布。因此,为了更好地比较不同年份的最佳表现,我们可以使用一种技术,即在每个赛季内标准化数据。我们不再用绝对的击球率(如 0.250)来思考,而是考虑击球手的表现偏离赛季常态的程度。

更具体地,我们可以使用 Z-score 标准化,数学表示如下:

这里,![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/737efa5aac8548a1ba3e53095d81de31~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771467055&x-signature=3%2Bp7US7WEexSjDW1oTkCNEZylrQ%3D)是均值,![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/d4d32a56d9f146519f29149a59a333a6~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771467055&x-signature=edP8aq0nZHA65r2oQ9U%2F0oJdvxc%3D)是标准差。

在 pandas 中计算这一点相当简单;我们需要做的就是定义一个自定义的normalize函数,并将其作为参数传递给pd.core.groupby.DataFrameGroupBy.transform,以为每一组年和球员的组合分配标准化的击球率。在随后的 group by 操作中使用它,可以帮助我们更好地比较不同年份间的最佳表现:

`def normalize(ser: pd.Series) -> pd.Series:     return (ser - ser.mean()) / ser.std() (     averages.assign(         normalized_avg=averages.groupby("year").transform(normalize)     )     .groupby("year").agg(         league_mean_avg=pd.NamedAgg(column="avg", aggfunc="mean"),         league_max_avg=pd.NamedAgg(column="avg", aggfunc="max"),         batting_champion=pd.NamedAgg(column="avg", aggfunc="idxmax"),         max_normalized_avg=pd.NamedAgg(column="normalized_avg", aggfunc="max"),     )     .sort_values(by="max_normalized_avg", ascending=False) ).head()` 
`year  league_mean_avg  league_max_avg  batting_champion      max_normalized_avg 2023  0.261457         0.353659        (2023, arral001)                3.714121 2004  0.281307         0.372159        (2004, suzui001)                3.699129 2002  0.275713         0.369727        (2002, bondb001)                3.553521 2010  0.271227         0.359073        (2010, hamij003)                3.379203 2008  0.277991         0.364465        (2008, jonec004)                3.320429` 

根据这一分析,路易斯·阿雷兹(Luis Arráez)在 2023 赛季的击球率表现是自 2000 年以来最为出色的。他当年创下的league_max_avg可能看起来是我们前五名中的最低值,但 2023 年的league_mean_avg也正是如此。

正如这个示例所示,合理使用 pandas 的 Group By 功能可以帮助你更公平地评估不同组别中的记录。我们的示例使用了一个赛季内的职业棒球运动员,但同样的方法也可以扩展到评估不同年龄组的用户、不同产品线的产品、不同领域的股票等。简而言之,通过 Group By 探索你的数据的可能性是无穷无尽的!

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

第九章:时间数据类型和算法

正确处理时间数据(即日期和时间)可能看起来很直接,但深入了解后,你会发现它比预想的复杂得多。以下是我想到的一些问题:

  • 一些用户按年计算时间;其他用户按纳秒计算

  • 一些用户忽略时区问题;而其他人需要协调全球的事件

  • 并非每个国家都有多个时区,即使它们足够大有时区(例如:中国)

  • 并非每个国家都实行夏令时;即使实行,国家之间也无法达成统一的时间

  • 在实行夏令时的国家,并非每个地区都会参与(例如,美国的亚利桑那州)

  • 不同的操作系统和版本对时间的处理方式不同(另见2038 年问题

这些问题其实只是冰山一角,尽管存在众多潜在的数据质量问题,时间数据在监控、趋势检测和预测方面是无价的。幸运的是,pandas 使得你不需要成为日期和时间的专家就能从数据中提取洞察。通过使用 pandas 提供的功能和抽象,你可以轻松清洗和插补时间数据,从而减少对日期和时间“问题”的关注,更多地关注数据所能提供的洞察。

虽然我们在第三章《数据类型》中的时间类型 - datetime部分介绍了一些 pandas 提供的时间类型,本章将首先关注 pandas 提供的增强这些类型功能的内容。除此之外,我们将讨论如何清洗和插补你的时间数据,最后以实际应用为重点结束本章。

本章将介绍以下几个内容:

  • 时区处理

  • 日期偏移

  • 日期时间选择

  • 重采样

  • 聚合每周的犯罪和交通事故数据

  • 按类别计算犯罪的年同比变化

  • 准确测量传感器收集的具有缺失值的事件

时区处理

迄今为止,我遇到的关于时间数据的最常见错误,源于对时区的误解。在我居住的美国东海岸,我见过很多用户尝试从数据库中读取他们认为是 2024-01-01 的日期,然而讽刺的是,他们分析出来的日期却是 2023-12-31。尽管这个偏差仅仅是一天,但这种不对齐的影响可能会极大地扭曲将日期按周、月、季或年分组的汇总数据。

对于那些之前遇到过类似问题的人,你可能已经意识到,你所通信的源系统可能确实给你提供了一个 2024-01-01 00:00:00 的时间戳,假设它是午夜 UTC 时间。某个环节中,住在美国东海岸的分析师可能将该时间戳转换成了他们的本地时间,这个时间可能会因为夏令时而比 UTC 快四小时,或者因为标准时间而比 UTC 快五小时。结果,时间戳在 EDT/EST 时区分别被显示为 2023-12-31 20:00:00 或 2023-12-31 19:00:00,而用户可能无意中尝试将其转换为一个日期。

为了避免在处理时间数据时出现这些问题,理解你何时正在处理时区感知日期时间(即那些与时区相关的日期时间,如 UTC 或America/New_York),以及时区无关对象(没有附带时区信息的对象)是至关重要的。在本章中,我们将展示如何创建和识别这两种类型的日期时间,并深入探讨 pandas 提供的工具,帮助你在不同的时区之间进行转换,以及从时区感知转换为时区无关。

如何操作

第三章数据类型中,我们学习了如何创建带有日期时间数据的pd.Series。让我们更详细地看看这个例子:

`ser = pd.Series([     "2024-01-01 00:00:00",     "2024-01-02 00:00:01",     "2024-01-03 00:00:02" ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 00:00:00 1   2024-01-02 00:00:01 2   2024-01-03 00:00:02 dtype: datetime64[ns]` 

这些时间戳表示的是发生在 2024 年 1 月 1 日至 1 月 3 日之间午夜时分或接近午夜时分的事件。然而,这些日期时间无法告诉我们的是这些事件发生的地点;纽约市的午夜时间与迪拜的午夜时间是不同的,因此很难确定这些事件发生的确切时间。没有额外的元数据,这些日期时间是时区无关的

要通过编程确认你的日期时间是时区无关的,你可以使用pd.Series.dt.tz,它将返回None

`ser.dt.tz is None` 
`True` 

使用pd.Series.dt.tz_localize方法,我们可以为这些日期时间分配一个**互联网号码分配局(IANA)**时区标识符,使它们变得时区感知。例如,要指定这些事件发生在美国东海岸,我们可以写:

`ny_ser = ser.dt.tz_localize("America/New_York") ny_ser` 
`0   2024-01-01 00:00:00-05:00 1   2024-01-02 00:00:01-05:00 2   2024-01-03 00:00:02-05:00 dtype: datetime64[ns, America/New_York]` 

如果你尝试在这个pd.Series上使用pd.Series.dt.tz,它将报告你正在使用America/New_York时区:

`ny_ser.dt.tz` 
`<DstTzInfo 'America/New_York' LMT-1 day, 19:04:00 STD>` 

现在我们的pd.Series已经具备时区感知能力,其中包含的日期时间可以映射到世界上任何地方的某个时间点。通过使用pd.Series.dt.tz_convert,你可以轻松地将这些事件转换为另一个时区的时间:

`la_ser = ny_ser.dt.tz_convert("America/Los_Angeles") la_ser` 
`0   2023-12-31 21:00:00-08:00 1   2024-01-01 21:00:01-08:00 2   2024-01-02 21:00:02-08:00 dtype: datetime64[ns, America/Los_Angeles]` 

实际操作中,通常最好将日期时间与时区绑定,这样可以减少在不同日期或不同时间点被误解的风险。然而,并非所有系统和数据库都能够保留这些信息,这可能迫使你在进行互操作时去除时区信息。如果遇到这种需求,你可以通过将None作为参数传递给pd.Series.dt.tz_localize来实现:

`la_ser.dt.tz_localize(None)` 
`0   2023-12-31 21:00:00 1   2024-01-01 21:00:01 2   2024-01-02 21:00:02 dtype: datetime64[ns]` 

如果你被迫从日期时间中去除时区信息,我强烈建议将时区作为字符串存储在pd.DataFrame和数据库的另一个列中:

`df = la_ser.to_frame().assign(    datetime=la_ser.dt.tz_localize(None),    timezone=str(la_ser.dt.tz), ).drop(columns=[0]) df` 
 `datetime              timezone 0   2023-12-31 21:00:00   America/Los_Angeles 1   2024-01-01 21:00:01   America/Los_Angeles 2   2024-01-02 21:00:02   America/Los_Angeles` 

在这种数据往返操作时,你可以通过将timezone列中的值应用到datetime列的数据来重建原始的pd.Series。为了增加安全性,下面的代码示例结合了pd.Series.drop_duplicatespd.Series.squeeze,从timezone列中提取出America/Los_Angeles的单一值,然后传递给pd.Series.dt.tz_localize

`tz = df["timezone"].drop_duplicates().squeeze() df["datetime"].dt.tz_localize(tz)` 
`0   2023-12-31 21:00:00-08:00 1   2024-01-01 21:00:01-08:00 2   2024-01-02 21:00:02-08:00 Name: datetime, dtype: datetime64[ns, America/Los_Angeles]` 

日期偏移

时间类型 – Timedelta的章节中(见第三章数据类型),我们介绍了pd.Timedelta类型,并提到它如何用于将日期时间按有限的时间跨度进行偏移,例如 10 秒或 5 天。然而,pd.Timedelta不能用于偏移日期或日期时间,比如说一个月,因为一个月的长度并不总是相同。在公历中,月份的天数通常在 28 到 31 天之间。2 月通常有 28 天,但对于每个能被 4 整除的年份,它会扩展到 29 天,除非该年能被 100 整除但不能被 400 整除。

如果总是思考这些问题会显得非常繁琐。幸运的是,pandas 处理了所有这些繁琐的细节,只需使用pd.DateOffset对象,你就可以根据日历来移动日期,我们将在本节中进一步探讨。

如何操作

为了构建对这个功能的基础理解,让我们从一个非常简单的pd.Series开始,包含 2024 年初几天的日期:

`ser = pd.Series([     "2024-01-01",     "2024-01-02",     "2024-01-03", ], dtype="datetime64[ns]") ser` 
`0   2024-01-01 1   2024-01-02 2   2024-01-03 dtype: datetime64[ns]` 

将这些日期偏移一个月通常意味着保持同样的日期,只是把日期从 1 月移到 2 月。使用pd.DateOffset,你可以传入一个months=的参数,来指定你希望偏移的月份数;例如,我们可以看一下传入1作为参数的效果:

`ser + pd.DateOffset(months=1)` 
`0   2024-02-01 1   2024-02-02 2   2024-02-03 dtype: datetime64[ns]` 

将日期偏移两个月意味着将这些日期从 1 月移到 3 月。我们不需要关心 1 月有 31 天,而 2 月 2024 年有 29 天;pd.DateOffset会为我们处理这些差异:

`ser + pd.DateOffset(months=2)` 
`0   2024-03-01 1   2024-03-02 2   2024-03-03 dtype: datetime64[ns]` 

对于不存在的日期(例如,试图将 1 月 30 日移到 2 月 30 日),pd.DateOffset会尝试匹配目标月份中最近的有效日期:

`pd.Series([     "2024-01-29",     "2024-01-30",     "2024-01-31", ], dtype="datetime64[ns]") + pd.DateOffset(months=1)` 
`0   2024-02-29 1   2024-02-29 2   2024-02-29 dtype: datetime64[ns]` 

你还可以通过向months=传递负数的参数,倒退日期到前一个月:

`ser + pd.DateOffset(months=-1)` 
`0   2023-12-01 1   2023-12-02 2   2023-12-03 dtype: datetime64[ns]` 

pd.DateOffset 足够灵活,可以同时接受多个关键字参数。例如,如果你想将日期偏移一个月、两天、三小时、四分钟和五秒钟,你可以在一个表达式中完成:

`ser + pd.DateOffset(months=1, days=2, hours=3, minutes=4, seconds=5)` 
`0   2024-02-03 03:04:05 1   2024-02-04 03:04:05 2   2024-02-05 03:04:05 dtype: datetime64[ns]` 

除了 pd.DateOffset 类,pandas 还提供了通过 pd.offsets 模块中的不同类,将日期移动到某一时期的开始或结束的功能。例如,如果你想将日期移动到月末,可以使用 pd.offsets.MonthEnd

`ser + pd.offsets.MonthEnd()` 
`0   2024-01-31 1   2024-01-31 2   2024-01-31 dtype: datetime64[ns]` 

pd.offsets.MonthBegin 将日期移动到下个月的开始:

`ser + pd.offsets.MonthBegin()` 
`0   2024-02-01 1   2024-02-01 2   2024-02-01 dtype: datetime64[ns]` 

pd.offsets.SemiMonthBeginpd.offsets.SemiMonthEndpd.offsets.QuarterBeginpd.offsets.QuarterEndpd.offsets.YearBeginpd.offsets.YearEnd 都提供类似的功能,可以将日期移动到不同时间段的开始或结束。

还有更多……

默认情况下,pd.DateOffset 是基于公历工作的,但它的不同子类可以提供更多自定义功能。

最常用的子类之一是 pd.offsets.BusinessDay,默认情况下,它仅将周一到周五的标准“工作日”计入日期偏移。为了看看它是如何工作的,让我们考虑 ser 中每个日期对应的星期几:

`ser.dt.day_name()` 
`0       Monday 1      Tuesday 2    Wednesday dtype: object` 

现在,让我们看看在给日期添加了三个工作日后会发生什么:

`bd_ser = ser + pd.offsets.BusinessDay(n=3) bd_ser` 
`0   2024-01-04 1   2024-01-05 2   2024-01-08 dtype: datetime64[ns]` 

我们可以使用相同的 pd.Series.dt.day_name 方法来检查这些日期新的星期几:

`bd_ser.dt.day_name()` 
`0    Thursday 1      Friday 2      Monday dtype: object` 

在添加了三个工作日之后,我们从周一和周二开始的日期,分别落在了同一周的周四和周五。我们从周三开始的日期被推到了下周一,因为周六和周日都不算作工作日。

如果你的业务在周一到周五的工作日与常规工作日不同,你可以使用 pd.offsets.CustomBusinessDay 来设定你自己的偏移规则。weekmask= 参数将决定哪些星期几被视为工作日:

`ser + pd.offsets.CustomBusinessDay(     n=3,     weekmask="Mon Tue Wed Thu", )` 
`0   2024-01-04 1   2024-01-08 2   2024-01-09 dtype: datetime64[ns]` 

你甚至可以添加 holidays= 参数来考虑你的业务可能关闭的日子:

`ser + pd.offsets.CustomBusinessDay(     n=3,     weekmask="Mon Tue Wed Thu",     holidays=["2024-01-04"], )` 
`0   2024-01-08 1   2024-01-09 2   2024-01-10 dtype: datetime64[ns]` 

对于公历,我们已经看过 pd.offsets.MonthEndpd.offsets.MonthBegin 类,分别帮助你将日期移动到一个月的开始或结束。类似的类也可以用于在尝试将日期移动到工作月的开始或结束时:

`ser + pd.offsets.BusinessMonthEnd()` 
`0   2024-01-31 1   2024-01-31 2   2024-01-31 dtype: datetime64[ns]` 

日期时间选择

第二章选择和赋值中,我们讨论了 pandas 提供的多种强大方法,帮助你通过与相关行 pd.Index 的交互,从 pd.Seriespd.DataFrame 中选择数据。如果你创建了一个包含日期时间数据的 pd.Index,它将作为一种名为 pd.DatetimeIndex 的特殊子类进行表示。这个子类重写了 pd.Index.loc 方法的一些功能,给你提供了更灵活的选择选项,专门针对时间数据。

如何操作

pd.date_range 是一个方便的函数,帮助你快速生成 pd.DatetimeIndex。使用此函数的一种方式是通过 start= 参数指定起始日期,使用 freq= 参数指定步长频率,并通过 periods= 参数指定所需的 pd.DatetimeIndex 长度。

例如,要生成一个从 2023 年 12 月 27 日开始、总共提供 5 天且每条记录之间间隔 10 天的 pd.DatetimeIndex,你可以写:

`pd.date_range(start="2023-12-27", freq="10D", periods=5)` 
`DatetimeIndex(['2023-12-27', '2024-01-06', '2024-01-16', '2024-01-26',               '2024-02-05'],              dtype='datetime64[ns]', freq='10D')` 

"2W" 的频率字符串将生成间隔为两周的日期。如果 start= 参数是一个星期天,日期将从该日期开始;否则,下一个星期天将作为序列的起点:

`pd.date_range(start="2023-12-27", freq="2W", periods=5)` 
`DatetimeIndex(['2023-12-31', '2024-01-14', '2024-01-28', '2024-02-11',               '2024-02-25'],              dtype='datetime64[ns]', freq='2W-SUN')` 

你甚至可以通过添加像 "-WED" 这样的后缀来控制用于锚定日期的星期几,这将生成每周三的日期,而不是每周日:

`pd.date_range(start="2023-12-27", freq="2W-WED", periods=5)` 
`DatetimeIndex(['2023-12-27', '2024-01-10', '2024-01-24', '2024-02-07',               '2024-02-21'],              dtype='datetime64[ns]', freq='2W-WED')` 

"WOM-3THU"freq= 参数将为你生成每个月的第三个星期四:

`pd.date_range(start="2023-12-27", freq="WOM-3THU", periods=5)` 
`DatetimeIndex(['2024-01-18', '2024-02-15', '2024-03-21', '2024-04-18',               '2024-05-16'],              dtype='datetime64[ns]', freq='WOM-3THU')` 

每月的第一天和第十五天可以通过 "SMS" 参数生成:

`pd.date_range(start="2023-12-27", freq="SMS", periods=5)` 
`DatetimeIndex(['2024-01-01', '2024-01-15', '2024-02-01', '2024-02-15',               '2024-03-01'],              dtype='datetime64[ns]', freq='SMS-15')` 

如你所见,有无数的频率字符串可以用来描述 pandas 所称的 日期偏移。欲获取更完整的列表,务必参考 pandas 文档:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects

pd.DatetimeIndex 的每个元素实际上是一个 pd.Timestamp。当从 pd.Seriespd.DataFrame 中进行选择时,用户可能最初会倾向于写出如下的代码,以选择像 2024-01-18 这样的日期及其之前的所有记录:

`index = pd.date_range(start="2023-12-27", freq="10D", periods=20) ser = pd.Series(range(20), index=index) ser.loc[:pd.Timestamp("2024-01-18")]` 
`2023-12-27    0 2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

类似地,用户可能会倾向于写出如下代码来选择一个日期范围:

`ser.loc[pd.Timestamp("2024-01-06"):pd.Timestamp("2024-01-18")]` 
`2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

然而,从 pd.DatetimeIndex 中进行选择的方法较为冗长。为了方便,pandas 允许你传入字符串来表示所需的日期,而不是使用 pd.Timestamp 实例:

`ser.loc["2024-01-06":"2024-01-18"]` 
`2024-01-06    1 2024-01-16    2 Freq: 10D, dtype: int64` 

你也不需要指定完整的日期(YYYY-MM-DD 格式)。例如,如果你想选择所有发生在 2024 年 2 月的日期,你只需将字符串 2024-02 传递给 pd.Series.loc 调用:

`ser.loc["2024-02"]` 
`2024-02-05    4 2024-02-15    5 2024-02-25    6 Freq: 10D, dtype: int64` 

切片操作足够智能,能够识别这种模式,便于选择二月和三月中的所有记录:

`ser.loc["2024-02":"2024-03"]` 
`2024-02-05    4 2024-02-15    5 2024-02-25    6 2024-03-06    7 2024-03-16    8 2024-03-26    9 Freq: 10D, dtype: int64` 

你可以将这种抽象化再进一步,选择整个年份:

`ser.loc["2024"].head()` 
`2024-01-06    1 2024-01-16    2 2024-01-26    3 2024-02-05    4 2024-02-15    5 Freq: 10D, dtype: int64` 

还有更多……

你还可以通过提供 tz= 参数,将 pd.DatetimeIndex 与时区相关联:

`index = pd.date_range(start="2023-12-27", freq="12h", periods=6, tz="US/Eastern") ser = pd.Series(range(6), index=index) ser` 
`2023-12-27 00:00:00-05:00    0 2023-12-27 12:00:00-05:00    1 2023-12-28 00:00:00-05:00    2 2023-12-28 12:00:00-05:00    3 2023-12-29 00:00:00-05:00    4 2023-12-29 12:00:00-05:00    5 Freq: 12h, dtype: int64` 

当使用字符串从带有时区感知的 pd.DatetimeIndex 中进行选择时,需注意 pandas 会隐式地将你的字符串参数转换为 pd.DatetimeIndex 的时区。例如,下面的代码将只从我们的数据中选择一个元素:

`ser.loc[:"2023-12-27 11:59:59"]` 
`2023-12-27 00:00:00-05:00    0 Freq: 12h, dtype: int64` 

而下面的代码将正确地选择两个元素:

`ser.loc[:"2023-12-27 12:00:00"]` 
`2023-12-27 00:00:00-05:00    0 2023-12-27 12:00:00-05:00    1 Freq: 12h, dtype: int64` 

尽管我们的日期与 UTC 相差五小时,且字符串中没有指明期望的时区,这两种方法仍然有效。这样,pandas 使得从pd.DatetimeIndex中进行选择变得非常简单,无论它是时区感知的还是时区非感知的。

重采样

第八章分组操作中,我们深入探讨了 pandas 提供的分组功能。通过分组,你可以根据数据集中唯一值的组合来拆分数据,应用算法到这些拆分上,并将结果重新合并。

重采样与分组操作非常相似,唯一的区别发生在拆分阶段。与根据唯一值组合生成分组不同,重采样允许你将日期时间数据按增量进行分组,例如每 5 秒每 10 分钟

如何实现

让我们再次使用在日期时间选择示例中介绍过的pd.date_range函数,不过这次我们将生成一个以秒为频率的pd.DatetimeIndex,而不是以天为频率:

`index = pd.date_range(start="2024-01-01", periods=10, freq="s") ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01 00:00:00    0 2024-01-01 00:00:01    1 2024-01-01 00:00:02    2 2024-01-01 00:00:03    3 2024-01-01 00:00:04    4 2024-01-01 00:00:05    5 2024-01-01 00:00:06    6 2024-01-01 00:00:07    7 2024-01-01 00:00:08    8 2024-01-01 00:00:09    9 Freq: s, dtype: Int64` 

如果每秒查看数据被认为过于细致,可以使用pd.Series.resample对数据进行降采样,以获得不同的增量,例如每 3 秒。重采样还需要使用聚合函数来指示在每个增量内所有记录的处理方式;为了简便起见,我们可以从求和开始:

`ser.resample("3s").sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

在这个特定的例子中,resample会使用[00:00:00-00:00:03)[00:00:03-00:00:06)[00:00:06-00:00:09)[00:00:09-00:00:12)这些区间来创建桶。对于每一个区间,左方括号表示该区间在左侧是闭合的(即包括这些值)。相反,右侧的圆括号表示该区间是开放的,不包括该值。

从技术角度来说,所有这些通过"3s"频率重采样创建的区间默认是“左闭合”的,但可以通过closed=参数改变这种行为,从而生成如(23:59:57-00:00:00](00:00:00-00:00:03](00:00:03-00:00:06](00:00:06-00:00:09]这样的区间:

`ser.resample("3s", closed="right").sum()` 
`2023-12-31 23:59:57     0 2024-01-01 00:00:00     6 2024-01-01 00:00:03    15 2024-01-01 00:00:06    24 Freq: 3s, dtype: Int64` 

对于"3s"频率,区间的左值会作为结果行索引中的值。这个行为也可以通过使用label=参数来改变:

`ser.resample("3s", closed="right", label="right").sum()` 
`2024-01-01 00:00:00     0 2024-01-01 00:00:03     6 2024-01-01 00:00:06    15 2024-01-01 00:00:09    24 Freq: 3s, dtype: Int64` 

最后一个需要注意的陷阱是,closed=label=参数的默认值依赖于你选择的频率。我们选择的"3s"频率创建的是左闭合区间,并在行索引中使用左侧区间值。然而,如果我们选择了一个面向周期结束的频率,比如MEYE(月末和年末),pandas 将会生成右闭合区间,并使用右侧标签:

`ser.resample("ME").sum()` 
`2024-01-31    45 Freq: ME, dtype: Int64` 

既然我们在讨论下采样,让我们来看一种不同的频率,比如天数("D")。在这个级别,pd.Series.resample可以方便地将每日事件聚合到每周时间段中。为了看看这怎么运作,我们只需要查看 2024 年的前 10 天:

`index = pd.date_range(start="2024-01-01", freq="D", periods=10) ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01    0 2024-01-02    1 2024-01-03    2 2024-01-04    3 2024-01-05    4 2024-01-06    5 2024-01-07    6 2024-01-08    7 2024-01-09    8 2024-01-10    9 Freq: D, dtype: Int64` 

在不查找每个日期对应星期几的情况下,我们可以使用pd.DatetimeIndex.dt.day_name()来帮助我们定位:

`ser.index.day_name()` 
`Index(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',       'Sunday', 'Monday', 'Tuesday', 'Wednesday'],      dtype='object')` 

默认情况下,重采样为每周的时间段将会创建以周日为结束的周期:

`ser.resample("W").sum()` 
`2024-01-07    21 2024-01-14    24 Freq: W-SUN, dtype: Int64` 

然而,你可以自由选择一周中的任何一天作为周期的结束日。在美国,认为周六是周末的结束日,实际上比周日更常见:

`ser.resample("W-SAT").sum()` 
`2024-01-06    15 2024-01-13    30 Freq: W-SAT, dtype: Int64` 

当然,你也可以选择一周中的任何一天:

`ser.resample("W-WED").sum()` 
`2024-01-03     3 2024-01-10    42 Freq: W-WED, dtype: Int64` 

现在我们已经讲解了下采样(即从更精细的频率转为更粗略的频率)的主题,接下来我们来看看相反的方向——上采样过程。我们的数据展示了每天发生的事件,但如果我们想创建一个每 12 小时记录事件的时间序列,该怎么办呢?

幸运的是,实现这一目标的 API 并不会有太大区别。你仍然可以使用pd.Series.resample开始,但接下来需要链式调用pandas.core.resample.Resampler.asfreq

`ser.resample("12h").asfreq().iloc[:5]` 
`2024-01-01 00:00:00       0 2024-01-01 12:00:00    <NA> 2024-01-02 00:00:00       1 2024-01-02 12:00:00    <NA> 2024-01-03 00:00:00       2 Freq: 12h, dtype: Int64` 

上采样过程中生成的时间间隔,如果没有对应的活动,会被分配为缺失值。如果不做处理,像这样的上采样可能没有太大价值。然而,pandas 提供了几种填充这些缺失数据的方法。

处理缺失数据的第一种方法可能是向前填充或向后填充值,这样缺失值就会分别被前一个或后一个记录替代。

向前填充会生成[0, 0, 1, 1, 2, 2, ...]的值:

`ser.resample("12h").asfreq().ffill().iloc[:6]` 
`2024-01-01 00:00:00    0 2024-01-01 12:00:00    0 2024-01-02 00:00:00    1 2024-01-02 12:00:00    1 2024-01-03 00:00:00    2 2024-01-03 12:00:00    2 Freq: 12h, dtype: Int64` 

而向后填充则会得到[0, 1, 1, 2, 2, 3, ...]

`ser.resample("12h").asfreq().bfill().iloc[:6]` 
`2024-01-01 00:00:00    0 2024-01-01 12:00:00    1 2024-01-02 00:00:00    1 2024-01-02 12:00:00    2 2024-01-03 00:00:00    2 2024-01-03 12:00:00    3 Freq: 12h, dtype: Int64` 

一个更强健的解决方案是使用插值,其中可以利用缺失值前后的值来通过数学方式推测缺失的值。默认的插值方法是线性插值,本质上是取缺失值前后的平均值:

`ser.resample("12h").asfreq().interpolate().iloc[:6]` 
`2024-01-01 00:00:00    0.0 2024-01-01 12:00:00    0.5 2024-01-02 00:00:00    1.0 2024-01-02 12:00:00    1.5 2024-01-03 00:00:00    2.0 2024-01-03 12:00:00    2.5 Freq: 12h, dtype: Float64` 

还有更多内容……

在本节的介绍中,我们提到过,重采样类似于分组(Group By)。实际上,你可以通过pd.DataFrame.groupbypd.Grouper参数重写一个重采样操作。

让我们再次看一个包含 10 条记录的pd.Series,这些记录每秒发生一次:

`index = pd.date_range(start="2024-01-01", periods=10, freq="s") ser = pd.Series(range(10), index=index, dtype=pd.Int64Dtype()) ser` 
`2024-01-01 00:00:00    0 2024-01-01 00:00:01    1 2024-01-01 00:00:02    2 2024-01-01 00:00:03    3 2024-01-01 00:00:04    4 2024-01-01 00:00:05    5 2024-01-01 00:00:06    6 2024-01-01 00:00:07    7 2024-01-01 00:00:08    8 2024-01-01 00:00:09    9 Freq: s, dtype: Int64` 

三秒增量的重采样结果如下所示:

`ser.resample("3s").sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

通过将 "3s" 传递给pd.Grouperfreq=参数,重写后的代码可以得到相同的结果:

`ser.groupby(pd.Grouper(freq="3s")).sum()` 
`2024-01-01 00:00:00     3 2024-01-01 00:00:03    12 2024-01-01 00:00:06    21 2024-01-01 00:00:09     9 Freq: 3s, dtype: Int64` 

并不要求你必须使用pd.DataFrame.resample,实际上,当你还需要按非日期时间值进行分组时,你会发现pd.Grouper方法效果更好。我们将在本章稍后的按类别计算犯罪的年同比变化示例中看到这一点。

聚合每周的犯罪和交通事故数据

到目前为止,我们已经对 pandas 在处理时间数据方面的功能进行了基本的了解。从小型样本数据集开始,使我们能够轻松地检查操作的输出,但现在我们已经进入了可以开始关注如何应用于“真实世界”数据集的阶段。

丹佛市的犯罪数据集庞大,共有超过 46 万行,每行都标注了犯罪报告的日期时间。正如你将看到的,在这个示例中,我们可以使用 pandas 轻松地重新采样这些事件,并提出类似在某一周内报告了多少起犯罪的问题。

如何实现

首先,让我们读取犯罪数据集,并将索引设置为REPORTED_DATE。该数据集是使用 pandas 扩展类型保存的,因此无需指定dtype_backend=参数:

`df = pd.read_parquet(     "data/crime.parquet", ).set_index("REPORTED_DATE") df.head()` 
`REPORTED_DATE           OFFENSE_TYPE_ID               OFFENSE_CATEGORY_ID      2014-06-29 02:01:00     traffic-accident-dui-duid     traffic-accident         2014-06-29 01:54:00     vehicular-eluding-no-chase    all-other-crimes         2014-06-29 02:00:00     disturbing-the-peace          public-disorder          2014-06-29 02:18:00     curfew                        public-disorder          2014-06-29 04:17:00     aggravated-assault            aggravated-assault       GEO_LON           NEIGHBORHOOD_ID             IS_CRIME         IS_TRAFFIC   -105.000149       cbd                         0                1 -105.020719       ath-mar-park                1                0 -105.001552       sunny-side                  1                0 -105.018557       college-view-south-platte   1                0 5 rows × 7 columns` 

为了计算每周的犯罪数量,我们需要按周形成一个分组,我们知道可以通过pd.DataFrame.resample来实现。链式调用.size方法将计算每周的犯罪数:

`df.resample("W").size()` 
`REPORTED_DATE 2012-01-08     877 2012-01-15    1071 2012-01-22     991 2012-01-29     988 2012-02-05     888              ... 2017-09-03    1956 2017-09-10    1733 2017-09-17    1976 2017-09-24    1839 2017-10-01    1059 Freq: W-SUN, Length: 300, dtype: int64` 

现在我们得到了每周犯罪数量的pd.Series,新索引每次递增一周。有几个默认发生的事情非常重要,需要理解。星期日被选为每周的最后一天,并且也是在生成的pd.Series中用于标记每个元素的日期。例如,第一个索引值 2012 年 1 月 8 日是一个星期日。那一周(截至 1 月 8 日)共发生了 877 起犯罪。而 1 月 9 日星期一至 1 月 15 日星期日的一周,记录了 1,071 起犯罪。让我们进行一些合理性检查,确保我们的重新采样做到了这一点:

`len(df.sort_index().loc[:'2012-01-08'])` 
`877` 
`len(df.sort_index().loc['2012-01-09':'2012-01-15'])` 
`1071` 

为了全面了解趋势,从重新采样的数据中创建一个图表会很有帮助:

`import matplotlib.pyplot as plt plt.ion() df.resample("W").size().plot(title="All Denver Crimes")` 

丹佛市的犯罪数据集将所有犯罪和交通事故放在一个表中,并通过二进制列IS_CRIMEIS_TRAFFIC进行区分。通过pd.DataFrame.resample,我们可以仅选择这两列,并对其进行特定时期的汇总。对于季度汇总,你可以写成:

`df.resample("QS")[["IS_CRIME", "IS_TRAFFIC"]].sum().head()` 
 `IS_CRIME  IS_TRAFFIC REPORTED_DATE 2012-01-01    7882      4726 2012-04-01    9641      5255 2012-07-01    10566     5003 2012-10-01    9197      4802 2013-01-01    8730      4442` 

再次来说,使用折线图来了解趋势可能会更加有帮助:

`df.resample("QS")[["IS_CRIME", "IS_TRAFFIC"]].sum().plot(   color=["black", "lightgrey"],   title="Denver Crime and Traffic Accidents" )` 

按类别计算犯罪的年变化

用户经常想知道这种变化年复一年有多少?或者…季度与季度之间的变化是多少?。尽管这些问题经常被提及,但编写算法来回答这些问题可能相当复杂且耗时。幸运的是,pandas 为你提供了许多现成的功能,简化了很多工作。

为了让事情更复杂一些,在这个示例中,我们将提出按类别变化有多少的问题?将按类别纳入计算将使我们无法直接使用pd.DataFrame.resample,但正如你将看到的,pandas 仍然可以非常轻松地帮助你回答这类详细的问题。

如何实现

让我们加载犯罪数据集,但这次我们不会将REPORTED_DATE作为索引:

`df = pd.read_parquet(     "data/crime.parquet", ) df.head()` 
 `OFFENSE_TYPE_ID  OFFENSE_CATEGORY_ID  REPORTED_DATE  …  NEIGHBORHOOD_ID  IS_CRIME   IS_TRAFFIC 0   traffic-accident-dui-duid   traffic-accident   2014-06-29 02:01:00   …   cbd   0   1 1   vehicular-eluding-no-chase   all-other-crimes   2014-06-29 01:54:00   …   east-colfax   1   0 2   disturbing-the-peace   public-disorder   2014-06-29 02:00:00   …   athmar-park   1   0 3   curfew   public-disorder   2014-06-29 02:18:00   …   sunny-side   1   0 4   aggravated-assault   aggravated-assault   2014-06-29 04:17:00   …   college-view-south-platte   1   0 5 rows × 8 columns` 

到现在为止,你应该已经对数据重塑感到足够熟悉,可以回答类似于*某一年发生了多少起犯罪?*这样的问题了。但如果我们想进一步分析,了解每个OFFENSE_CATEGORY_ID内的变化情况该怎么做呢?

由于pd.DataFrame.resample仅适用于pd.DatetimeIndex,因此无法帮助我们按OFFENSE_CATEGORY_IDREPORTED_DATE进行分组。不过,pd.DataFrame.groupbypd.Grouper参数的组合可以帮助我们实现这一点:

`df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), )` 
 `total_crime OFFENSE_CATEGORY_ID    REPORTED_DATE aggravated-assault     2012-01-01           1707                        2013-01-01           1631                        2014-01-01           1788                        2015-01-01           2007                        2016-01-01           2139 …                               …              … white-collar-crime     2013-01-01            771                        2014-01-01           1043                        2015-01-01           1319                        2016-01-01           1232                        2017-01-01           1058 90 rows × 1 columns` 

顺便提一下,observed=True参数可以抑制在 pandas 2.x 版本中使用分类数据类型进行分组时的警告;未来的读者可能不需要指定此参数,因为它将成为默认设置。

为了加入“同比”部分,我们可以尝试使用pd.Series.pct_change方法,它将每条记录表示为直接前一条记录的百分比:

`df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), ).assign(     yoy_change=lambda x: x["total_crime"].pct_change().astype(pd.Float64Dtype()) ).head(10)` 
 `total_crime     yoy_change OFFENSE_CATEGORY_ID      REPORTED_DATE aggravated-assault          2012-01-01           1707           <NA>                             2013-01-01           1631      -0.044523                             2014-01-01           1788        0.09626                             2015-01-01           2007       0.122483                             2016-01-01           2139        0.06577                             2017-01-01           1689      -0.210379 all-other-crimes            2012-01-01           1999       0.183541                             2013-01-01           9377       3.690845                             2014-01-01          15507       0.653727                             2015-01-01          15729       0.014316` 

不幸的是,这并没有给我们准确的结果。如果你仔细观察所有其他犯罪类别的第一个yoy_change值,它显示为 0.183541。然而,这个值是通过将 1999 除以 1689 得到的,1689 来自加重攻击犯罪类别。默认情况下,pd.Series.pct_change并没有做任何智能的操作——它只是将当前行与前一行相除。

幸运的是,有一种方法可以解决这个问题,再次使用分组操作。因为我们的OFFENSE_CATEGORY_ID是第一个索引级别,所以我们可以用第二个分组操作,设置level=0并在此基础上调用.pct_change方法。这样可以避免我们错误地将all-other-crimesaggravated-assault进行比较:

`yoy_crime = df.groupby([     "OFFENSE_CATEGORY_ID",     pd.Grouper(key="REPORTED_DATE", freq="YS"), ], observed=True).agg(     total_crime=pd.NamedAgg(column="IS_CRIME", aggfunc="sum"), ).assign(     yoy_change=lambda x: x.groupby(         level=0, observed=True     ).pct_change().astype(pd.Float64Dtype()) ) yoy_crime.head(10)` 
 `total_crime     yoy_change OFFENSE_CATEGORY_ID     REPORTED_DATE aggravated-assault         2012-01-01           1707           <NA>                            2013-01-01           1631      -0.044523                            2014-01-01           1788        0.09626                            2015-01-01           2007       0.122483                            2016-01-01           2139        0.06577                            2017-01-01           1689      -0.210379 all-other-crimes           2012-01-01           1999           <NA>                            2013-01-01           9377       3.690845                            2014-01-01          15507       0.653727                            2015-01-01          15729       0.014316` 

为了获得更直观的展示,我们可能希望将所有不同分组的总犯罪数和年同比变化并排绘制,基于我们在第六章数据可视化中学到的知识进行构建。

为了简洁并节省视觉空间,我们只会绘制几种犯罪类型:

`crimes = tuple(("aggravated-assault", "arson", "auto-theft")) fig, axes = plt.subplots(nrows=len(crimes), ncols=2, sharex=True) for idx, crime in enumerate(crimes):     crime_df = yoy_crime.loc[crime]     ax0 = axes[idx][0]     ax1 = axes[idx][1]     crime_df.plot(kind="bar", y="total_crime", ax=ax0, legend=False)     crime_df.plot(kind="bar", y="yoy_change", ax=ax1, legend=False)     xlabels = [x.year for x in crime_df.index]     ax0.set_xticklabels(xlabels)     ax0.set_title(f"{crime} total")     ax1.set_xticklabels(xlabels)     ax1.set_title(f"{crime} YoY")     ax0.set_xlabel("")     ax1.set_xlabel("") plt.tight_layout()` 

准确衡量带有缺失值的传感器收集事件

缺失数据可能对数据分析产生巨大影响,但有时并不容易判断缺失数据的发生时间和程度。在详细且大量的交易数据中,数据集是否完整可能并不明显。必须格外注意衡量和恰当地填补缺失的交易数据;否则,对这样的数据集进行任何聚合可能会展示出不完整甚至完全错误的情况。

对于这个案例,我们将使用芝加哥数据门户提供的智能绿色基础设施监测传感器 - 历史数据数据集。该数据集包含了测量芝加哥市不同环境因素的传感器集合,如水流量和温度。理论上,传感器应该持续运行并反馈数值,但实际上,它们易于发生间歇性故障,导致数据丢失。

如何操作

虽然芝加哥数据门户提供了覆盖 2017 年和 2018 年的 CSV 格式源数据,但本书中我们将使用一个精心整理的 Parquet 文件,它仅覆盖了 2017 年 6 月到 2017 年 10 月的几个月数据。仅此数据就有近 500 万行记录,我们可以通过简单的pd.read_parquet调用加载它:

`df = pd.read_parquet(     "data/sgi_monitoring.parquet",     dtype_backend="numpy_nullable", ) df.head()` 
 `Measurement Title   Measurement Description   Measurement Type   …   Latitude   Longitude   Location 0   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 1   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 2   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 3   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 4   UI Labs Bioswale NWS Proba-bility of Precipi-tation <NA>   TimeWin-dowBounda-ry   …   41.90715   -87.653996   POINT (-87.653996 41.90715) 5 rows × 16 columns` 

Measurement Time列应包含每个事件发生时的日期时间数据,但经过仔细检查后,你会发现 pandas 并未将其识别为日期时间类型:

`df["Measurement Time"].head()` 
`0    07/26/2017 07:00:00 AM 1    06/23/2017 07:00:00 AM 2    06/04/2017 07:00:00 AM 3    09/19/2017 07:00:00 AM 4    06/07/2017 07:00:00 AM Name: Measurement Time, dtype: string` 

因此,探索数据的第一步将是使用pd.to_datetime将其转换为实际的日期时间类型。虽然从数据本身不容易看出,但芝加哥数据门户文档指出,这些值是芝加哥时区的本地时间,我们可以使用pd.Series.dt.tz_localize来进行时区设置:

`df["Measurement Time"] = pd.to_datetime(     df["Measurement Time"] ).dt.tz_localize("US/Central") df["Measurement Time"]` 
`0         2017-07-26 07:00:00-05:00 1         2017-06-23 07:00:00-05:00 2         2017-06-04 07:00:00-05:00 3         2017-09-19 07:00:00-05:00 4         2017-06-07 07:00:00-05:00                     ...            4889976   2017-08-26 20:11:55-05:00 4889977   2017-08-26 20:10:54-05:00 4889978   2017-08-26 20:09:53-05:00 4889979   2017-08-26 20:08:52-05:00 4889980   2017-08-26 20:07:50-05:00 Name: Measurement Time, Length: 4889981, dtype: datetime64[ns, US/Central]` 

如前所述,该数据集收集了来自传感器的反馈,这些传感器测量了不同的环境因素,如水流量和温度。检查Measurement TypeUnits列应能让我们更好地理解我们正在查看的数据:

`df[["Measurement Type", "Units"]].value_counts()` 
`Measurement Type         Units                            Temperature              degrees Celsius                     721697 DifferentialPressure     pascals                             721671 WindSpeed                meters per second                   721665 Temperature              millivolts                          612313 SoilMoisture             millivolts                          612312 RelativeHumidity         percent                             389424 CumulativePrecipitation  count                               389415 WindDirection            degrees from north                  389413 SoilMoisture             Percent Volumetric Water Content    208391 CumulativePrecipitation  inches                              122762 TimeWindowBoundary       universal coordinated time             918 Name: count, dtype: int64` 

由于不同的传感器对于不同类型的数据会产生不同的测量结果,我们必须小心,避免一次比较多个传感器。为了这次分析,我们将只专注于TM1 Temp Sensor,它仅使用毫伏作为单位来测量温度。此外,我们将只关注一个Data Stream ID,在芝加哥数据门户中文档中描述为:

一个用于标识测量类型和位置的标识符。所有具有相同值的记录应该是可以比较的。

`df[df["Measurement Description"] == "TM1 Temp Sensor"]["Data Stream ID"].value_counts()` 
`Data Stream ID 33305    211584 39197    207193 39176    193536 Name: count, dtype: Int64` 

对于此次分析,我们将只查看Data Stream ID 39176。过滤后,我们还将设置Measurement Time为行索引并进行排序:

`mask = (     (df["Measurement Description"] == "TM1 Temp Sensor")     & (df["Data Stream ID"] == 39176) ) df = df[mask].set_index("Measurement Time").sort_index() df[["Measurement Type", "Units"]].value_counts()` 
`Measurement Type  Units      Temperature       millivolts    193536 Name: count, dtype: int64` 

Measurement Value列包含传感器的实际毫伏数值。我们可以通过将数据重采样到每日级别,并对该列进行均值聚合,来尝试更高层次地理解我们的数据:

`df.resample("D")["Measurement Value"].mean().plot()` 

几乎立刻,我们就能看到数据中的一些问题。最显著的是,7 月底和 10 月中旬有两个间隙,几乎可以确定这些记录是由于传感器停机而未收集的。

让我们尝试缩小日期范围,以便更清楚地看到数据集中缺失的日期:

`df.loc["2017-07-24":"2017-08-01"].resample("D")["Measurement Value"].mean()` 
`Measurement Time 2017-07-24 00:00:00-05:00    3295.908956 2017-07-25 00:00:00-05:00    3296.152968 2017-07-26 00:00:00-05:00    3296.460156 2017-07-27 00:00:00-05:00    3296.697269 2017-07-28 00:00:00-05:00    3296.328725 2017-07-29 00:00:00-05:00    3295.882705 2017-07-30 00:00:00-05:00    3295.800989 2017-07-31 00:00:00-05:00           <NA> 2017-08-01 00:00:00-05:00    3296.126888 Freq: D, Name: Measurement Value, dtype: Float64` 

如你所见,我们在 2017 年 7 月 31 日完全没有收集到任何数据。为了解决这个问题,我们可以简单地调用pd.Series.interpolate,它将使用前后值的平均数填补缺失的日期:

`df.resample("D")["Measurement Value"].mean().interpolate().plot()` 

完成了!现在,我们的数据收集没有任何间隙,呈现出一个视觉上令人愉悦、完整的图表。

还有更多…

你如何处理缺失数据也取决于你使用的聚合函数。在这个例子中,平均数是一个相对宽容的函数;缺失的交易可以通过它们不显著改变生成的平均数来掩盖。

然而,如果我们要测量每天的读数总和,仍然需要做一些额外的工作。首先,让我们看看这些读数的每日重采样总和是怎样的:

`df.resample("D")["Measurement Value"].sum().plot()` 

情况比想要计算平均值时更加严峻。我们仍然看到 7 月和 10 月末有巨大的跌幅,这显然是数据缺失造成的。然而,当我们深入研究之前看到的 7 月末数据时,求和将揭示一些关于数据的更有趣的内容:

`df.loc["2017-07-30 15:45:00":"2017-08-01"].head()` 
 `Measurement Title   Measurement Description   Measurement Type    …   Latitude   Longitude   Location   Measurement Time 2017-07-30 15:48:44-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-07-30 15:49:45-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-07-30 15:50:46-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-08-01 15:21:33-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 2017-08-01 15:22:34-05:00   Argyle - Thun-der 1: TM1 Temp Sensor   TM1 Temp Sensor   Temperature   …   41.973086   -87.659725   POINT (-87.659725 41.973086) 5 rows × 15 columns` 

不仅仅是 7 月 31 日那天我们发生了停机。我们之前做的平均聚合掩盖了一个事实,即传感器在 7 月 30 日 15:50:46 之后出现故障,并且直到 8 月 1 日 15:21:33 才恢复在线——几乎停机了整整两天。

另一个有趣的事情是尝试测量我们的数据应该以什么频率被填充。初步查看我们的数据时,似乎每分钟都应该提供一个数据点,但如果你尝试测量每小时收集到多少事件,你会看到另一个结果:

`df.resample("h").size().plot()` 

很多小时间隔看起来收集了接近 60 个事件,尽管令人惊讶的是,只有 1 小时实际上收集了满满的 60 个事件。

`df.resample("h").size().loc[lambda x: x >= 60]` 
`Measurement Time 2017-07-05 15:00:00-05:00    60 Freq: h, dtype: int64` 

为了解决这个问题,我们再次尝试按分钟重采样数据,并在结果缺失的地方进行插值:

`df.resample("min")["Measurement Value"].sum().interpolate()` 
`Measurement Time 2017-06-01 00:00:00-05:00    3295.0 2017-06-01 00:01:00-05:00    3295.0 2017-06-01 00:02:00-05:00    3295.0 2017-06-01 00:03:00-05:00    3295.0 2017-06-01 00:04:00-05:00    3295.0                              ...   2017-10-30 23:55:00-05:00    3293.0 2017-10-30 23:56:00-05:00    3293.0 2017-10-30 23:57:00-05:00       0.0 2017-10-30 23:58:00-05:00    3293.0 2017-10-30 23:59:00-05:00    3293.0 Freq: min, Name: Measurement Value, Length: 218880, dtype: Float64` 

用户需要注意的是,缺失值的总和存在一个小的注意事项。默认情况下,pandas 会将所有缺失值求和为0,而不是缺失值。在我们按分钟重采样的情况下,2017 年 10 月 30 日 23:57:00 的数据点没有值可供求和,因此 pandas 返回了0,而不是缺失值指示符。

我们需要一个缺失值指示符来使重采样工作顺利进行。幸运的是,我们仍然可以通过给sum方法提供min_count=参数并设置为1(或更大值)来实现这一点,实际上是设定了必须看到多少个非缺失值才能得到一个非缺失的结果:

`interpolated = df.resample("min")["Measurement Value"].sum(min_count=1).interpolate() interpolated` 
`Measurement Time 2017-06-01 00:00:00-05:00    3295.0 2017-06-01 00:01:00-05:00    3295.0 2017-06-01 00:02:00-05:00    3295.0 2017-06-01 00:03:00-05:00    3295.0 2017-06-01 00:04:00-05:00    3295.0                              ...   2017-10-30 23:55:00-05:00    3293.0 2017-10-30 23:56:00-05:00    3293.0 2017-10-30 23:57:00-05:00    3293.0 2017-10-30 23:58:00-05:00    3293.0 2017-10-30 23:59:00-05:00    3293.0 Freq: min, Name: Measurement Value, Length: 218880, dtype: Float64` 

如你所见,2017 年 10 月 30 日 23:57:00 的值现在显示为3293,这是通过取前后的值进行插值得到的。

完成这些之后,让我们确认我们每小时总是收集到 60 个事件:

`interpolated.resample("h").size().plot()` 

这个检查看起来很好,现在,我们可以尝试再次将数据降采样到日级别,看看整体求和趋势是什么样的:

`interpolated.resample("D").sum().plot()` 

这张图表与我们最初的图表大不相同。我们不仅去除了极端异常值对图表 y 轴的影响,还可以看到数值下限的普遍上升。在我们最初的图表中,总计的毫伏值下限通常在每天 350 万到 400 万之间,但现在,我们的下限大约在 474 万左右。

实际上,通过关注和处理我们时间序列数据中的缺失值,我们能够从数据集中获得许多不同的见解。在相对少量的代码行中,pandas 帮助我们清晰简洁地将数据处理到比开始时更好的状态。

加入我们在 Discord 的社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/pandas

留下您的评价!

感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈对我们非常宝贵,能够帮助我们改进和成长。在阅读完本书后,请抽空在 Amazon 上留下评价;这只需一分钟,但对像您这样的读者来说意义重大。

扫描下面的二维码,获得你选择的免费电子书。

packt.link/NzOWQ