如何用Polars高效聚合时间序列为值对应的区间结构体列表?
高效提取Polars时间序列中常量值的连续区间列表
我有一个包含ID值的时间序列,序列由值保持恒定的连续块组成。需要高效提取每个值对应的、序列保持恒定的时间区间列表。数据以polars.DataFrame形式存储,包含date和value列,示例代码如下:
import polars as pl from datetime import datetime dates = pl.date_range( start=datetime.date.fromisoformat("2011-01-01"), end=datetime.date.fromisoformat("2011-01-10"), interval="1d", eager=True ) frame = pl.DataFrame({ "date": dates, "value": ["A", "B", "A", "B", "B", "A", "A", "A", "B", "B"] })
期望结果是一个DataFrame,每个value对应一组由起始/结束日期组成的结构体列表:
>>> shape: (2, 2) ┌───────┬───────────────────────────────────┐ │ value ┆ intervals │ │ --- ┆ --- │ │ str ┆ list[struct[2]] │ ╞═══════╪═══════════════════════════════════╡ │ A ┆ [{2011-01-01,2011-01-01}, {2011-… │ │ B ┆ [{2011-01-02,2011-01-02}, {2011-… │ └───────┴───────────────────────────────────┘
我当前的解法分四步:
- 新增布尔列标记块的起始和结束
def is_start(expr: pl.Expr) -> pl.Expr: return (expr.shift(1) != expr).fill_null(True).alias("is_start") def is_end(expr: pl.Expr) -> pl.Expr: return (expr.shift(-1) != expr).fill_null(True).alias("is_end") x = frame.with_columns(pl.col("value").pipe(is_start), pl.col("value").pipe(is_end))
- 按
value分组,聚合起止日期为列表
y = x.group_by("value").agg( pl.col("date").filter(pl.col("is_start")).alias("start_date"), pl.col("date").filter(pl.col("is_end")).alias("end_date") )
- 将列表展开为列
z = y.explode("start_date", "end_date")
- 再次按
value分组,聚合为区间结构体列表
u = z.group_by("value").agg( pl.struct("start_date", "end_date").alias("intervals") )
想知道是否有更简洁且高效的方式实现该转换,尤其是当前需要两次group_by,而中间结果已包含起止日期但未直接组合为区间结构体的情况。最终要将该转换应用于多个长时序,且已知每个序列的变化点较少,恳请提供优化建议。
优化方案:基于连续块分组的单次聚合
核心思路是先给连续相同的value块分配唯一标识,然后直接按value和块标识分组,提取每个块的起止日期,最后再按value聚合为区间列表。这样只需要一次完整的分组聚合流程,避免了两次group_by和中间的explode操作,效率更高,代码更简洁。
完整代码
import polars as pl from datetime import datetime # 生成示例数据 dates = pl.date_range( start=datetime.date.fromisoformat("2011-01-01"), end=datetime.date.fromisoformat("2011-01-10"), interval="1d", eager=True ) frame = pl.DataFrame({ "date": dates, "value": ["A", "B", "A", "B", "B", "A", "A", "A", "B", "B"] }) # 执行转换 result = ( frame # 为连续相同的value块生成唯一分组键 .with_columns( group_id=(pl.col("value") != pl.col("value").shift(1)).fill_null(True).cumsum() ) # 按value和group_id分组,提取每个块的起止日期并组合为结构体 .group_by(["value", "group_id"], maintain_order=True) .agg( pl.struct( start_date=pl.col("date").min(), end_date=pl.col("date").max() ).alias("interval") ) # 按value聚合,将同value的区间结构体合并为列表 .group_by("value", maintain_order=True) .agg(pl.col("interval").alias("intervals")) ) print(result)
代码解释
- 生成连续块标识:通过
(value != value.shift(1)).fill_null(True).cumsum()生成group_id,每次value变化时,布尔值为True,累加后得到新的分组ID,以此区分不同的连续块。 - 提取单个块的区间:按
value和group_id分组,用date.min()和date.max()直接获取每个连续块的起止日期,并组合为结构体。这里maintain_order=True保证区间顺序和原时序一致。 - 聚合同value的区间列表:最后按
value分组,将单个块的区间结构体聚合为列表,得到目标结果。
效率优势
- 避免了原方案中两次
group_by和explode带来的额外计算开销,尤其在长时序、变化点少的场景下,分组操作的次数更少,性能提升明显。 - 直接基于连续块分组,逻辑更直观,减少了中间临时列的生成和处理。
内容的提问来源于stack exchange,提问作者Benjamin Trendelkamp-Schroer
相关产品推荐
相关产品推荐

