“日历感知”是什么意思?
计算机总是严格执行指令,即使指令不符合我们的真实意图。处理日期时间时,很容易遇到这类问题。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年的十年起点对齐。

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

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

group_by_dynamic 还有更多功能,请查看其 API 参考页面。(group_by_dynamic API 参考)
基于时间区间的计算
最后介绍 rolling。它与 group_by_dynamic 类似,按时间窗口聚合数值;主要区别是 rolling 总会为每个数据点创建一个窗口:

换句话说,你无法控制窗口创建的周期。
与 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"。

使用足够大的 offset,还可以创建不包含对应数据点的窗口:窗口可能在该数据点之前结束,也可能在其之后开始。
结语
Polars 的若干函数与表达式支持灵活的时间间隔描述,贴近人类对时间段的理解方式。本文介绍了 date_range、offset_by、group_by_dynamic 和 rolling;你还可以浏览 API 参考,寻找更多支持这一描述方式的功能。(API 参考文档)
版本:原文基于 Polars 1.17.1 编写。
注释
- 你可以自行判断:示例中哪一行加1d相当于加23小时,哪一行相当于加25小时。(↩)












暂无评论内容