Polars原生支持解析时间序列数据和执行更复杂的操作,如时间分组和重新采样等。本文结合官方文档,介绍如何解析日期字段、过滤日期、按日期分组,最后介绍对时间序列数据进行重采样。
时间序列数据是按照时间顺序排列的观测值序列。这些观测值通常是在等时间间隔(如每小时、每天、每月等)下记录的数据点。时间是数据的一个关键维度,它反映了数据的动态变化特性。例如,一家商店每天记录的销售额、股票市场每分钟的股票价格、气象站每小时测量的温度等都是时间序列数据。
特点
- 顺序性:数据点按照时间先后顺序排列,这种顺序是有意义的,因为它体现了数据的演变过程。例如,在分析电力消耗的时间序列数据时,上午的用电量通常会与前一天晚上的用电量以及后续下午的用电量存在某种关联,顺序不能随意调换。
- 时间依赖性:时间序列数据的一个重要特点是数据点之间存在时间依赖关系。即一个时间点的数据可能会受到之前时间点数据的影响,同时也可能影响后续的数据。以股票价格为例,今天的股价往往与昨天的股价以及过去一段时间的股价走势相关,并且也会对未来股价产生一定的影响。
- 趋势性:许多时间序列数据呈现出一定的长期趋势。例如,随着经济的发展,一家公司的年营业额可能会呈现逐年上升的趋势;或者随着技术的进步,电子产品的价格可能会逐渐下降。这种趋势可以是线性的,也可以是非线性的。
- 季节性:某些时间序列数据具有明显的季节性波动。比如,一家旅游公司的游客预订数量会在节假日和旅游旺季明显增加,而在淡季则减少;又如,某些商品的销售在特定季节(如夏季的冷饮、冬季的取暖设备)会出现高峰,这就是季节性特征。
- 周期性:除了季节性,时间序列数据还可能呈现出更复杂的周期性变化。周期性与季节性类似,但周期长度可能不固定,不像季节性那样与特定的日历周期(如一年中的四季、12
个月等)紧密相关。例如,经济周期可能会跨越数年,而太阳黑子活动周期则更长。 - 噪声和不规则性:时间序列数据中通常会包含噪声成分,这些噪声使得数据呈现出不规则的波动。噪声可能是由随机因素、测量误差或其他不可预测的事件引起的。例如,在股票价格数据中,突发的政治事件、公司的意外消息等都可能导致股价出现不规则的波动,这种波动在时间序列中表现为噪声。
数据类型
polar有以下日期时间数据类型:
- Date:日期表示形式,如2014-07-08。它在内部表示为由32位带符号整数编码的UNIX纪元以来的天数。
- Datetime:日期时间表示形式,如2014-07-08 07:00:00。自Unix纪元以来,它在内部表示为64位整数,可以有不同的单位,如ns、us、ms。
- Duration:在减去Date/Datetime时创建的时间增量类型。类似于Python中的timedelta。
- Time:时间表示法,内部表示为从午夜开始的纳秒。
解析日期类型
当从CSV文件加载时,如果try_parse_dates标志设置为True, Polars将尝试解析日期和时间:
import polars as pl
from datetime import datetimedf = pl.read_csv("../data/apple_stock_data.csv", try_parse_dates=True)
print(df)# df = df.with_columns(pl.col("Date").str.to_date("%Y-%m-%d"))
# print(df)
还可以将编码为字符串的日期时间列强制转换为日期时间类型。你可以通过调用字符串str.to_date方法并传递日期字符串的格式来实现。输出结果:
shape: (6_953, 7)
┌────────────┬────────┬────────┬────────┬────────┬──────────┬───────────┐
│ Date ┆ Open ┆ High ┆ Low ┆ Close ┆ Volume ┆ Adj Close │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ date ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ i64 ┆ f64 │
╞════════════╪════════╪════════╪════════╪════════╪══════════╪═══════════╡
│ 2012-03-30 ┆ 608.77 ┆ 610.56 ┆ 597.94 ┆ 599.55 ┆ 26050900 ┆ 599.55 │
│ 2012-03-29 ┆ 612.78 ┆ 616.56 ┆ 607.23 ┆ 609.86 ┆ 21668300 ┆ 609.86 │
│ 2012-03-28 ┆ 618.38 ┆ 621.45 ┆ 610.31 ┆ 617.62 ┆ 23385200 ┆ 617.62 │
│ 2012-03-27 ┆ 606.18 ┆ 616.28 ┆ 606.06 ┆ 614.48 ┆ 21628200 ┆ 614.48 │
│ 2012-03-26 ┆ 599.79 ┆ 607.15 ┆ 595.26 ┆ 606.98 ┆ 21259900 ┆ 606.98 │
│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
│ 1984-09-13 ┆ 27.5 ┆ 27.62 ┆ 27.5 ┆ 27.5 ┆ 7429600 ┆ 3.14 │
│ 1984-09-12 ┆ 26.87 ┆ 27.0 ┆ 26.12 ┆ 26.12 ┆ 4773600 ┆ 2.98 │
│ 1984-09-11 ┆ 26.62 ┆ 27.37 ┆ 26.62 ┆ 26.87 ┆ 5444000 ┆ 3.07 │
│ 1984-09-10 ┆ 26.5 ┆ 26.62 ┆ 25.87 ┆ 26.37 ┆ 2346400 ┆ 3.01 │
│ 1984-09-07 ┆ 26.5 ┆ 26.87 ┆ 26.25 ┆ 26.5 ┆ 2981600 ┆ 3.02 │
└────────────┴────────┴────────┴────────┴────────┴──────────┴───────────┘
- 从日期中抽取日期特征
您可以使用.dt命名空间从日期列中提取数据特征,例如年或日,下面df_with_year增加了year列:
df_with_year = df.with_columns(pl.col("Date").dt.year().alias("year"))
print(df_with_year)
过滤数据
筛选日期列的方式与使用.filter方法筛选其他类型列的方式相同。
Polars使用Python的原生datetime、date和timedelta来比较数据类型pl.Datetime、pl.Date和pl.Duration之间的相等性。
- 过滤单个日期
我们可以使用过滤器表达式中的相等比较来过滤单个日期:
filtered_df = df.filter(pl.col("Date") == datetime(1995, 10, 16),
)
print(filtered_df)
注意,我们使用的是小写datetime方法,而不是大写Datetime数据类型。
- 日期范围过滤
我们可以在带有开始和结束日期的过滤器表达式中使用is_between方法按日期范围进行过滤:
filtered_range_df = df.filter(pl.col("Date").is_between(datetime(1995, 7, 1), datetime(1995, 11, 1)),
)
print(filtered_range_df)
数据分组
按固定窗口分组
我们可以使用group_by_dynamic来计算时间统计信息,将行分组为天/月/年等。
下面示例计算每年平均收盘价:
df = df.sort("Date")
print(df)annual_average_df = df.group_by_dynamic("Date", every="1y").agg(pl.col("Close").mean())df_with_year = annual_average_df.with_columns(pl.col("Date").dt.year().alias("year"))
print(df_with_year)
group_by_dynamic
参数解释:
- every:表示窗口周期的间隔时间;
- period:表示窗口的持续时间;
- offset:表示窗口的开始偏移时间。
group_by_dynamic
示例
下面示例按月统计,每月最后三天,并计算该月共有多少天。注意:不能简单统计记录数,实际情况可能有缺失数据。
import datetime as dtdf = (pl.date_range(start=dt.date(2021, 1, 1),end=dt.date(2021, 12, 31),interval="1d",eager=True,).alias("time").to_frame()
)
print(df)out = df.group_by_dynamic("time", every="1mo", period="1mo", closed="left").agg(pl.col("time").cum_count().reverse().head(3).alias("day/eom"),((pl.col("time") - pl.col("time").first()).last().dt.total_days() + 1).alias("days_in_month"),
)
print(out)
输出结果:
shape: (365, 1)
┌────────────┐
│ time │
│ --- │
│ date │
╞════════════╡
│ 2021-01-01 │
│ 2021-01-02 │
│ 2021-01-03 │
│ 2021-01-04 │
│ 2021-01-05 │
│ … │
│ 2021-12-27 │
│ 2021-12-28 │
│ 2021-12-29 │
│ 2021-12-30 │
│ 2021-12-31 │
└────────────┘
shape: (12, 3)
┌────────────┬──────────────┬───────────────┐
│ time ┆ day/eom ┆ days_in_month │
│ --- ┆ --- ┆ --- │
│ date ┆ list[u32] ┆ i64 │
╞════════════╪══════════════╪═══════════════╡
│ 2021-01-01 ┆ [31, 30, 29] ┆ 31 │
...
│ 2021-10-01 ┆ [31, 30, 29] ┆ 31 │
│ 2021-11-01 ┆ [30, 29, 28] ┆ 30 │
│ 2021-12-01 ┆ [31, 30, 29] ┆ 31 │
└────────────┴──────────────┴───────────────┘
这里解释下部分代码:
这是 Polars 中用于动态分组的功能。它按照指定的时间列(这里是"time"
列)以及时间间隔规则来对数据进行分组。every="1mo"
表示按照每个月的时间间隔来划分分组,例如会把属于同一个月的数据划分到一组;period="1mo"
进一步明确了每个分组所涵盖的时间周期也是一个月;closed="left"
指定了分组区间的闭合方式为左闭右开,即包含每个月开始的时间点,但不包含下个月开始的时间点(例如,对于 1 月的分组,包含 1 月 1 日,但不包含 2 月 1 日)。
- 第一个聚合表达式
pl.col("time").cum_count().reverse().head(3).alias("day/eom")
pl.col("time")
用于选取"time"
列的数据。.cum_count()
是一个累计计数操作,会沿着数据框的行方向,对"time"
列中的每个日期依次进行计数,从 0 开始,每一行的计数会在前一行基础上加 1,得到一个累计的计数序列。.reverse()
操作将这个计数序列进行反转,也就是把顺序颠倒过来,使得计数是从后往前的顺序排列。.head(3)
则是选取反转后的序列中的前 3 个元素,相当于获取每个分组(每月)中从后往前数的前 3 个日期对应的计数。- 最后通过
.alias("day/eom")
给这个聚合结果取了一个别名"day/eom"
,用于在最终结果中标识该列。
- 第二个聚合表达式
((pl.col("time") - pl.col("time").first()).last().dt.total_days() + 1).alias("days_in_month")
pl.col("time") - pl.col("time").first()
先计算出每个分组内的每个日期与该分组内第一个日期的时间差(得到的是一个时间间隔类型的数据序列)。.last()
选取这个时间差序列中的最后一个元素,也就是该分组内最后一个日期与第一个日期的时间差。.dt.total_days()
将这个时间差转换为天数(dt
相关的操作是 Polars 中用于处理日期时间类型数据的方法,这里获取总天数)。+ 1
操作是因为时间差算出来是间隔天数,要得到该月包含的总天数需要加 1(例如,1 月 1 日到 1 月 31 日间隔是 30 天,但 1 月总共有 31 天)。- 最后通过
.alias("days_in_month")
给这个聚合结果取了别名"days_in_month"
,用于标识最终结果中的该列数据。
- 第二个表达式还可以使用dt.days_between实现
(pl.col("time").last().dt.days_between(pl.col("time").first()) + 1).alias("days_in_month")
在这个简化后的表达式中:
pl.col("time").last()
用于获取每个分组内时间列("time"
列)的最后一个日期。pl.col("time").first()
用于获取每个分组内时间列的第一个日期。dt.days_between
函数直接计算这两个日期之间的天数差,相较于原始表达式先做减法再取最后一个元素并转换天数的操作更为直接明了。- 最后同样通过
.alias("days_in_month")
给计算结果赋予别名"days_in_month"
,以符合聚合操作后列命名的要求,方便后续对结果数据的使用和理解。
通过使用 dt.days_between
函数,可以让代码在实现相同功能的基础上更加简洁清晰。
重新采样(Resampling)
重新采样主要包括:
- 上采样(将数据移动到更高的频率)
- 下采样(将数据移动到更低的频率)
- 组合采样,例如,先上采样,然后下采样。
Polars将downsampling视为group_by操作的一种特殊情况,可以使用group_by_dynamic和group_by_rolling来实现这一点。
上采样
采用更高频率进行上采样。让我们看一个例子,我们每隔30分钟生成数据:
df = pl.DataFrame({"time": pl.datetime_range(start=datetime(2021, 12, 16),end=datetime(2021, 12, 16, 3),interval="30m",eager=True,),"groups": ["a", "a", "a", "b", "b", "a", "a"],"values": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],}
)
print(df)
输出结果:
shape: (7, 3)
┌─────────────────────┬────────┬────────┐
│ time ┆ groups ┆ values │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ f64 │
╞═════════════════════╪════════╪════════╡
│ 2021-12-16 00:00:00 ┆ a ┆ 1.0 │
│ 2021-12-16 00:30:00 ┆ a ┆ 2.0 │
│ 2021-12-16 01:00:00 ┆ a ┆ 3.0 │
│ 2021-12-16 01:30:00 ┆ b ┆ 4.0 │
│ 2021-12-16 02:00:00 ┆ b ┆ 5.0 │
│ 2021-12-16 02:30:00 ┆ a ┆ 6.0 │
│ 2021-12-16 03:00:00 ┆ a ┆ 7.0 │
└─────────────────────┴────────┴────────┘
上采样可以通过定义新的采样间隔来实现。通过上采样,我们在没有数据的地方添加了额外的行。因此,上采样本身给出了一个带有null的DataFrame。然后可以用填充策略或插值填充这些空值。
上采样策略
在这个例子中,我们将样本从原来的30分钟提升到15分钟,然后使用前向策略将空值替换为之前的非空值:
out1 = df.upsample(time_column="time", every="15m").fill_null(strategy="forward")
print(out1)
shape: (13, 3)
┌─────────────────────┬────────┬────────┐
│ time ┆ groups ┆ values │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ f64 │
╞═════════════════════╪════════╪════════╡
│ 2021-12-16 00:00:00 ┆ a ┆ 1.0 │
│ 2021-12-16 00:15:00 ┆ a ┆ 1.0 │
│ 2021-12-16 00:30:00 ┆ a ┆ 2.0 │
│ 2021-12-16 00:45:00 ┆ a ┆ 2.0 │
│ 2021-12-16 01:00:00 ┆ a ┆ 3.0 │
│ … ┆ … ┆ … │
│ 2021-12-16 02:00:00 ┆ b ┆ 5.0 │
│ 2021-12-16 02:15:00 ┆ b ┆ 5.0 │
│ 2021-12-16 02:30:00 ┆ a ┆ 6.0 │
│ 2021-12-16 02:45:00 ┆ a ┆ 6.0 │
│ 2021-12-16 03:00:00 ┆ a ┆ 7.0 │
└─────────────────────┴────────┴────────┘
在这个例子中,我们用线性插值来填充空值:
out2 = (df.upsample(time_column="time", every="15m").interpolate().fill_null(strategy="forward")
)
print(out2)
shape: (13, 3)
┌─────────────────────┬────────┬────────┐
│ time ┆ groups ┆ values │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ f64 │
╞═════════════════════╪════════╪════════╡
│ 2021-12-16 00:00:00 ┆ a ┆ 1.0 │
│ 2021-12-16 00:15:00 ┆ a ┆ 1.5 │
│ 2021-12-16 00:30:00 ┆ a ┆ 2.0 │
│ 2021-12-16 00:45:00 ┆ a ┆ 2.5 │
│ 2021-12-16 01:00:00 ┆ a ┆ 3.0 │
│ … ┆ … ┆ … │
│ 2021-12-16 02:00:00 ┆ b ┆ 5.0 │
│ 2021-12-16 02:15:00 ┆ b ┆ 5.5 │
│ 2021-12-16 02:30:00 ┆ a ┆ 6.0 │
│ 2021-12-16 02:45:00 ┆ a ┆ 6.5 │
│ 2021-12-16 03:00:00 ┆ a ┆ 7.0 │
└─────────────────────┴────────┴────────┘