返回博客

探索 Polars 中日历感知的日期操作

2024 年 12 月 23 日,周一

“日历感知”是什么意思?

计算机以总是严格执行指令而闻名,即使那并非我们所希望的。在处理日期和时间时,你出乎意料地容易遇到这类问题,因此 Polars API 引入了一种迷你规范语言,使你能够以人类自然理解的方式表达时间间隔。在本文中,我们将探索这种迷你规范语言及其一些应用函数。

首先,你可能需要了解你可能遇到的问题。如果这句话写于 2024 年 12 月 11 日,那么从现在起一个月后会是哪一天?

你可以询问 Python

import datetime as dt

print(
    dt.date(2024, 12, 11) + dt.timedelta(days=31)
)
2025-01-11

Python 告诉你,1 月 11 日在 12 月 11 日之后一个月。用同样的表达式,你可以确定 2 月 11 日是 1 月 11 日之后一个月的日期

print(
    dt.date(2025, 1, 11) + dt.timedelta(days=31)
)
2025-02-11

但如果你用同样的表达式计算 2 月 11 日之后一个月的日期,你会得到一个有趣的结果

print(
    dt.date(2025, 2, 11) + dt.timedelta(days=31)
)
2025-03-14  # ?!

对你我而言,“2 月 11 日之后一个月”的日期是 3 月 11 日。然而,在 Python 的 datetime 模块中,无法表达这个概念。相反,你一直在日期上加上 31 天,因为 12 月和 1 月有 31 天,但对于最后一个表达式,你需要加上 28 天而不是 31 天(因为 2025 年不是闰年,否则你需要加上 29 天)。

值得庆幸的是,在 Polars 中,你可以使用表达式 .dt.offset_by 进行日历感知的持续时间计算

dates = pl.Series([
    dt.date(2024, 12, 11),
    dt.date(2025, 1, 11),
    dt.date(2025, 2, 11),
])

next_month = dates.dt.offset_by("1mo")
print(next_month)
assert (dates.dt.day() == next_month.dt.day()).all()
shape: (3,)
Series: '' [date]
[
    2025-01-11
    2025-02-11
    2025-03-11
]

日历感知持续时间规范

字符串 "1mo" 用于表示 1 个月的日历感知持续时间。Polars 支持其他日历感知持续时间

字符串日历感知单位
"d"
"w"
"mo"
"q"季度
"y"

当你在日期/时间操作中使用这五种规范时,它们会考虑到诸如夏令时偏移以及年份是否为闰年等边缘情况。天和月的规范是唯一严格必要的,因为其他三种可以从 "d""mo" 派生出来

  • "1w" 等同于 "7d"
  • "1q" 等同于 "3mo";以及
  • "1y" 等同于 "12mo"

即便如此,支持 "w""q""y" 提高了指定日历感知持续时间的表达式的可读性,特别是因为不同单位可以组合使用。例如,字符串 "2y3mo10d" 意味着“2 年、3 个月和 10 天”

print(
    pl.Series([dt.date(2024, 1, 1)]).dt.offset_by("2y3mo10d")
)
shape: (1,)
Series: '' [date]
[
    2026-04-11
]

为了完整性,你应该注意 .dt.offset_by 和其他函数使用的规范支持其他持续时间说明符

字符串单位
"ns"纳秒
"us"微秒
"ms"毫秒
"s"
"m"分钟
"h"小时

然而,上面列出的说明符并非日历感知。例如,使用 "24h"(24 小时)不等同于使用 "1d"。事实上,在发生夏令时变化的日期,"1d" 可能等于 "23h""25h",具体取决于夏令时调整的方向1

dates = pl.Series(
    [
        dt.datetime(2024, 10, 26, 10),
        dt.datetime(2024, 3, 30, 10),
    ]
).to_frame("dates").select(pl.col("dates").dt.replace_time_zone("Europe/London"))

print(
    dates.with_columns(
        (pl.col("dates") + pl.duration(hours=24)).alias("+24h"),
        pl.col("dates").dt.offset_by("1d").alias("+1d"),
    )
)

现在你已经熟悉了 Polars 使用这些说明符的方式,让我们来探索一些利用它们的函数。

生成日期(时间)范围

你可以使用 Polars 函数 date_rangedatetime_range 分别生成日期或日期时间范围。函数 date_range 接受上面列出的五个日历感知说明符,而 函数 datetime_range 还接受纳秒到小时之间的六个单位说明符。对于这两个函数,字符串规范用作参数 interval,它指定了生成日期(时间)值的增量。

例如,葡萄牙的一些纳税人有特定的季度纳税义务,必须在季度结束后 1 个月零 2 周的日期前履行。为了确定这些截止日期,你可以首先使用 date_range 计算 2025 年即将到来的季度的开始日期

beginnings = pl.date_range(
    start=dt.date(2025, 1, 1),
    end=dt.date(2025, 12, 31),
    interval="1q",
    eager=True,  # Evaluate immediately to return a series.
)
print(beginnings)
shape: (4,)
Series: 'literal' [date]
[
    2025-01-01
    2025-04-01
    2025-07-01
    2025-10-01
]

最后,你可以使用表达式 .dt.offset_by 计算纳税义务的截止日期

print(
    beginnings.dt.offset_by("1q1mo2w")
)
shape: (4,)
Series: 'literal' [date]
[
    2025-05-15
    2025-08-15
    2025-11-15
    2026-02-15
]

按日期(时间)动态分组数据

Polars 通过 group_by 上下文 支持标准聚合操作。Polars 还支持一种更灵活的聚合类型,当你希望通过 函数 group_by_dynamic 根据日期或日期时间聚合行时。

举个简单的例子,你可以加载一些 Apple 股票数据,并查看每年有多少个数据点

apple_df = pl.read_csv(
    "https://raw.githubusercontent.com/pola-rs/polars-static/refs/heads/master/data/appleStock.csv",
    try_parse_dates=True,
)
print(apple_df.head())
shape: (5, 2)
┌────────────┬───────┐
│ Date       ┆ Close │
│ ---        ┆ ---   │
│ date       ┆ f64   │
╞════════════╪═══════╡
│ 1981-02-23 ┆ 24.62 │
│ 1981-05-06 ┆ 27.38 │
│ 1981-05-18 ┆ 28.0  │
│ 1981-09-25 ┆ 14.25 │
│ 1982-07-08 ┆ 11.0  │
└────────────┴───────┘
print(
    apple_df
    .group_by_dynamic("Date", every="1y").agg(pl.len())
    .select(pl.col("Date").dt.year().alias("year"), pl.col("len"))
)
shape: (34, 2)
┌──────┬─────┐
│ year ┆ len │
│ ---  ┆ --- │
│ i32  ┆ u32 │
╞══════╪═════╡
│ 1981 ┆ 4   │
│ 1982 ┆ 1   │
│ 1983 ┆ 3   │
│ 1984 ┆ 3   │
│ 1985 ┆ 3   │
│ …    ┆ …   │
│ 2010 ┆ 2   │
│ 2011 ┆ 2   │
│ 2012 ┆ 2   │
│ 2013 ┆ 2   │
│ 2014 ┆ 1   │
└──────┴─────┘

默认情况下,group_by_dynamic 创建由参数 every 给定长度的连续、不重叠的窗口。在上面的示例中,这些窗口的长度为一年,由字符串 "1y" 指定。如果为参数 everyperiod 提供值,则可以分别指定窗口的长度和创建不同窗口的周期性。

下面的代码片段创建了每 5 年开始的 10 年窗口

decades = apple_df.group_by_dynamic("Date", every="5y", period="10y").agg(pl.len())
print(decades)
shape: (7, 2)
┌────────────┬─────┐
│ Date       ┆ len │
│ ---        ┆ --- │
│ date       ┆ u32 │
╞════════════╪═════╡
│ 1980-01-01 ┆ 28  │
│ 1985-01-01 ┆ 33  │
│ 1990-01-01 ┆ 35  │
│ 1995-01-01 ┆ 36  │
│ 2000-01-01 ┆ 28  │
│ 2005-01-01 ┆ 20  │
│ 2010-01-01 ┆ 9   │
└────────────┴─────┘

这里有两件重要的事情需要注意。首先,请注意每行如何显示给定十年内的数据点数量,但由于窗口重叠,总和大于原始数据点的总数

print(apple_df.shape)  # (100, 1)
print(decades["len"].sum())  # 189

其次,Polars 根据找到的最早和最新的数据点设置窗口边界,但默认情况下,它会整齐地对齐边界。在这个例子中,最早的数据点是 1981 年的,但 Polars 将第一个窗口的边界与 1980 年代初对齐

Diagram showing how the window boundaries align neatly with the decades and the decade halfpoints, 1980, 1985, 1990, etc, although the first datapoint is from 1981.

如果你更喜欢将第一个窗口与第一个数据点对齐,可以通过指定 start_by="datapoint" 来实现

Diagram showing how the window boundaries shift when we set the parameter start_by to the value datapoint, making it so that the very first window starts exactly on the first datapoint.

如果 Polars 用于对齐窗口边界的启发式方法不符合你的需求,并且你希望对第一个窗口的开始有更大的控制权,你可以指定参数 offset

Diagram showing how the window boundaries can be adjusted by specifying the parameter offset.

group_by_dynamic 函数还有更多内容,所以如果你想了解更多,请务必查看 group_by_dynamic 的 API 参考页面

基于时间间隔的计算

本文中你将学到的最后一个函数是 rolling 函数。rolling 函数与 group_by_dynamic 有些相似,你可以使用它根据时间窗口聚合值。关键区别在于 rolling 函数总是为每个数据点创建一个窗口

Diagram showing how the window boundaries align with the data points and how each data point has a respective window.

换句话说,你无法控制窗口的周期性。

group_by_dynamic 类似,单个数据点可能属于多个窗口

decades = apple_df.rolling("Date", period="10y").agg(pl.len())
print(decades)
print(decades["len"].sum())  # 2793
shape: (100, 2)
┌────────────┬─────┐
│ Date       ┆ len │
│ ---        ┆ --- │
│ date       ┆ u32 │
╞════════════╪═════╡
│ 1981-02-23 ┆ 1   │
│ 1981-05-06 ┆ 2   │
│ 1981-05-18 ┆ 3   │
│ 1981-09-25 ┆ 4   │
│ 1982-07-08 ┆ 5   │
│ …          ┆ …   │
│ 2012-05-16 ┆ 22  │
│ 2012-12-04 ┆ 21  │
│ 2013-07-05 ┆ 22  │
│ 2013-11-07 ┆ 23  │
│ 2014-02-25 ┆ 23  │
└────────────┴─────┘
2793

在这种情况下,rolling 示例中的窗口重叠比 group_by_dynamic 示例中更多,其中所有窗口的总长度为 189。

请记住,你无法操纵 rolling 创建的窗口总数。你唯一能控制的是时间窗口的长度。如果你固定时间窗口的长度,在 group_by_dynamic 中,你可以通过调整参数 every 的值来影响创建的窗口数量。如果我们将 every 设置为一个比我们之前示例中使用的值小得多的值,我们可以在 group_by_dynamic 中获得更多重叠的窗口

overlaps = apple_df.group_by_dynamic("Date", every="3d", period="10y").agg(pl.len())
#                                                  ^^^^ 3 DAYS
print(overlaps["len"].sum())  # 102893

你也可以将 rolling 作为表达式使用,如下一个示例所示。使用之前的 Apple 股票数据,你可以使用表达式 rolling 来计算股票价格在过去 3 年内高于平均价格的幅度

print(
    apple_df.with_columns(
        (
            pl.col("Close").mean().rolling(index_column="Date", period="3y")
            - pl.col("Close")
        ).alias("Diff_to_3y_mean")
    )
)
shape: (100, 3)
┌────────────┬────────┬─────────────────┐
│ Date       ┆ Close  ┆ Diff_to_3y_mean │
│ ---        ┆ ---    ┆ ---             │
│ date       ┆ f64    ┆ f64             │
╞════════════╪════════╪═════════════════╡
│ 1981-02-23 ┆ 24.62  ┆ 0.0             │
│ 1981-05-06 ┆ 27.38  ┆ -1.38           │
│ 1981-05-18 ┆ 28.0   ┆ -1.333333       │
│ 1981-09-25 ┆ 14.25  ┆ 9.3125          │
│ 1982-07-08 ┆ 11.0   ┆ 10.05           │
│ …          ┆ …      ┆ …               │
│ 2012-05-16 ┆ 546.08 ┆ -211.831667     │
│ 2012-12-04 ┆ 575.85 ┆ -173.365        │
│ 2013-07-05 ┆ 417.42 ┆ 15.306667       │
│ 2013-11-07 ┆ 512.49 ┆ -68.368571      │
│ 2014-02-25 ┆ 522.06 ┆ -49.152857      │
└────────────┴────────┴─────────────────┘

将窗口末尾与数据点对齐的默认行为可以通过向参数 offset 传递值来定制。此值被添加到数据点以确定窗口的开始,这意味着参数 offset 的默认值为 -period

例如,如果你希望窗口围绕数据点居中,则需要指定一个等于周期一半的 offset

Diagram showing how the window boundaries align with the data points when the offset is taken into account, allowing you to shift the windows.

通过对参数 offset 使用足够大的值,你还可以创建不包含相应数据点的窗口,这可能是因为窗口在相应数据点之前结束,或者在之后开始。

结论

多个 Polars 函数和表达式支持一种灵活的时间持续时间规范,它模仿了人类推理时间段的方式。在本文中,你了解了 date_rangeoffset_bygroup_by_dynamicrolling,但你可以通过探索API 参考文档找到更多。

注意:⚠️ 本博客文章是使用 Polars 1.17.1 编写的。

注释

  1. 由你来确定在哪个行中添加 "1d" 等同于添加 23 小时,哪个等同于添加 25 小时。

1
2
4
3
5
6
7
8
9
10
11
12