“日历感知”是什么意思?
计算机以总是严格执行指令而闻名,即使那并非我们所希望的。在处理日期和时间时,你出乎意料地容易遇到这类问题,因此 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_range
和 datetime_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"
指定。如果为参数 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, 1)
print(decades["len"].sum()) # 189
其次,Polars 根据找到的最早和最新的数据点设置窗口边界,但默认情况下,它会整齐地对齐边界。在这个例子中,最早的数据点是 1981 年的,但 Polars 将第一个窗口的边界与 1980 年代初对齐
如果你更喜欢将第一个窗口与第一个数据点对齐,可以通过指定 start_by="datapoint"
来实现
如果 Polars 用于对齐窗口边界的启发式方法不符合你的需求,并且你希望对第一个窗口的开始有更大的控制权,你可以指定参数 offset
group_by_dynamic
函数还有更多内容,所以如果你想了解更多,请务必查看 group_by_dynamic
的 API 参考页面。
基于时间间隔的计算
本文中你将学到的最后一个函数是 rolling
函数。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
设置为一个比我们之前示例中使用的值小得多的值,我们可以在 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
值
通过对参数 offset
使用足够大的值,你还可以创建不包含相应数据点的窗口,这可能是因为窗口在相应数据点之前结束,或者在之后开始。
结论
多个 Polars 函数和表达式支持一种灵活的时间持续时间规范,它模仿了人类推理时间段的方式。在本文中,你了解了 date_range
、offset_by
、group_by_dynamic
和 rolling
,但你可以通过探索API 参考文档找到更多。
注意:⚠️ 本博客文章是使用 Polars 1.17.1 编写的。
注释
-
由你来确定在哪个行中添加
"1d"
等同于添加 23 小时,哪个等同于添加 25 小时。↩