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

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

计算机总是严格执行指令,即使指令不符合我们的真实意图。处理日期时间时,很容易遇到这类问题。Polars API 因此提供了一种小型描述语言,让你用符合人类习惯的方式表达时间间隔。本文将探索这套语言及使用它的函数。

先看看容易踩到的问题:假设写下这句话的日期是2024年12月11日,那么一个月后是哪一天?

可以问 Python:

import datetime as dt

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

Python 告诉我们,12月11日的一个月后是1月11日。使用同样的表达式,也能得出1月11日的一个月后是2月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" 表示日历意义上的一个月。Polars 还支持以下日历感知单位:

字符串 日历单位
d 日
w 周
mo 月
q 季度
y 年

在日期时间操作中使用这五种描述时,会考虑夏令时偏移、是否闰年等边界情况。严格来说,只需要日和月两种描述,因为另外三种可以由它们推导:

  • "1w" 等于 "7d";
  • "1q" 等于 "3mo";
  • "1y" 等于 "12mo"。

即便如此,支持 w、q、y 仍能提高表达式可读性,尤其是组合多种单位时。例如 "2y3mo10d" 表示两年、三个月又十天:

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 不等于 1d。夏令时切换的日子里,依据切换方向,1d 可能对应23小时或25小时。参见文末注释。(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_range 和 datetime_range 分别生成日期与日期时间范围。date_range 接受前面五种日历单位;datetime_range 还接受从纳秒到小时的六种单位。在这两个函数中,描述字符串作为 interval 参数,指定生成日期或日期时间值时的增量。(date_range 函数 · datetime_range 函数)

例如,葡萄牙部分纳税人有季度税务义务,截止日期在季度结束后一个月又两周。若要计算这些日期,可先用 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 上下文提供标准聚合,也通过 group_by_dynamic 提供更灵活的聚合,在按日期或日期时间汇总行时使用。(group_by 上下文 · group_by_dynamic 函数)

举一个简单例子:读取苹果股票数据,统计每年有多少个数据点。

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 指定一年窗口。提供 every 和 period 后,就可以分别指定窗口创建周期与窗口长度。

下面的代码每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, 2)
print(decades["len"].sum())  # 189

示例数据包含 Date 与 Close 两列;shape 注释应为 (100, 2)。

其次,Polars 根据最早和最晚的数据点确定窗口边界,但默认会整齐地对齐边界。本例最早的数据点在1981年,首个窗口却与1980年的十年起点对齐。

每5年创建10年窗口,首个窗口对齐1980年边界

如果希望第一个窗口与第一个数据点对齐,可以指定 start_by="datapoint":

start_by=datapoint 让首个窗口与1981年第一个数据点对齐

如果 Polars 对齐窗口边界的默认规则不适合需求,希望进一步控制首个窗口的起点,可以指定 offset:

offset=-6mo 将首个窗口起点移到1979年7月

group_by_dynamic 还有更多功能,请查看其 API 参考页面。(group_by_dynamic API 参考)

基于时间区间的计算

最后介绍 rolling。它与 group_by_dynamic 类似,按时间窗口聚合数值;主要区别是 rolling 总会为每个数据点创建一个窗口:

rolling(period=10y) 为每个数据点创建一个结束于该点的窗口

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

与 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 设为远小于前例的值,就会得到更多重叠窗口:

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

rolling 也可以作为表达式使用。以下仍使用前面的苹果股票数据,计算股价与此前三年平均股价的差异。原文称“高于均价多少”,但保留的代码实际计算的是三年均价减去当前收盘价:

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 加到数据点上,就得到窗口起点,因此 offset 的默认值是 -period。

例如,希望窗口以数据点为中心时,应向前偏移半个窗口长度。图中 period="10y" 对应 offset="-5y"。

rolling(period=10y, offset=-5y) 让窗口以数据点为中心

使用足够大的 offset,还可以创建不包含对应数据点的窗口:窗口可能在该数据点之前结束,也可能在其之后开始。

结语

Polars 的若干函数与表达式支持灵活的时间间隔描述,贴近人类对时间段的理解方式。本文介绍了 date_range、offset_by、group_by_dynamic 和 rolling;你还可以浏览 API 参考,寻找更多支持这一描述方式的功能。(API 参考文档)

版本:原文基于 Polars 1.17.1 编写。

注释

  • 你可以自行判断:示例中哪一行加1d相当于加23小时,哪一行相当于加25小时。(↩)
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容