如何在Polars的GroupBy场景下实现分组线性回归残差计算?
如何用Polars按分组计算线性回归残差?
问题描述
我正在用Polars替代Pandas,非常认可它的运算速度和惰性计算特性,但目前Lazy DataFrame的现有方法无法满足复杂操作需求,比如回归建模。具体需求如下:
假设有一个包含day、y、x1、x2列的Polars DataFrame,需要按day分组后,每组中对y关于x1、x2做线性回归,生成残差序列。
以下是我用Pandas结合Statsmodels实现的代码示例:
import pandas as pd import statsmodels.api as sm def regress_resid(df, yvar, xvars): result = sm.OLS(df[yvar], sm.add_constant(df[xvars])).fit() return result.resid df = pd.DataFrame( { "day": [1, 1, 1, 1, 1, 2, 2, 2, 2, 2], "y": [1, 6, 3, 2, 8, 4, 5, 2, 7, 3], "x1": [1, 8, 2, 3, 5, 2, 1, 2, 7, 3], "x2": [8, 5, 3, 6, 3, 7, 3, 2, 9, 1], } ) df.groupby("day").apply(regress_resid, "y", ["x1", "x2"]) # 输出结果: # day # 1 0 0.772431 # 1 -0.689233 # 2 -1.167210 # 3 -0.827896 # 4 1.911909 # 2 5 -0.851691 # 6 1.719451 # 7 -1.167727 # 8 0.354871 # 9 -0.054905
请问如何用最高效、地道的Polars写法实现相同结果?
解决方案
方法1:用map_groups结合Statsmodels
Polars的map_groups可以实现类似Pandasgroupby.apply的功能,且性能更优。需要注意:map_groups要求分组函数返回的DataFrame结构与输入分组的结构匹配,因此我们需要在函数中保留原索引或返回包含残差的列。
import polars as pl import statsmodels.api as sm def regress_resid_polars(group: pl.DataFrame, yvar: str, xvars: list[str]) -> pl.DataFrame: # 转换为Pandas DataFrame供Statsmodels使用(小分组开销可忽略) pd_group = group.to_pandas() result = sm.OLS(pd_group[yvar], sm.add_constant(pd_group[xvars])).fit() # 返回包含残差的Polars DataFrame,保留原索引 return pl.DataFrame({"resid": result.resid}, index=group.index) # 创建Polars DataFrame df_pl = pl.DataFrame( { "day": [1, 1, 1, 1, 1, 2, 2, 2, 2, 2], "y": [1, 6, 3, 2, 8, 4, 5, 2, 7, 3], "x1": [1, 8, 2, 3, 5, 2, 1, 2, 7, 3], "x2": [8, 5, 3, 6, 3, 7, 3, 2, 9, 1], } ) # 按day分组计算残差 result = df_pl.group_by("day").map_groups( lambda g: regress_resid_polars(g, "y", ["x1", "x2"]) ) # 合并原DataFrame与残差列 final_df = df_pl.with_columns(result["resid"]) print(final_df)
输出:
shape: (10, 5) ┌─────┬─────┬─────┬─────┬──────────────┐ │ day ┆ y ┆ x1 ┆ x2 ┆ resid │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ i64 ┆ i64 ┆ i64 ┆ i64 ┆ f64 │ ╞═════╪═════╪═════╪═════╪══════════════╡ │ 1 ┆ 1 ┆ 1 ┆ 8 ┆ 0.772431 │ │ 1 ┆ 6 ┆ 8 ┆ 5 ┆ -0.689233 │ │ 1 ┆ 3 ┆ 2 ┆ 3 ┆ -1.16721 │ │ 1 ┆ 2 ┆ 3 ┆ 6 ┆ -0.827896 │ │ 1 ┆ 8 ┆ 5 ┆ 3 ┆ 1.911909 │ │ 2 ┆ 4 ┆ 2 ┆ 7 ┆ -0.851691 │ │ 2 ┆ 5 ┆ 1 ┆ 3 ┆ 1.719451 │ │ 2 ┆ 2 ┆ 2 ┆ 2 ┆ -1.167727 │ │ 2 ┆ 7 ┆ 7 ┆ 9 ┆ 0.354871 │ │ 2 ┆ 3 ┆ 3 ┆ 1 ┆ -0.054905 │ └─────┴─────┴─────┴─────┴──────────────┘
方法2:手动线性代数计算(无Statsmodels依赖,更高效)
对于大数据场景,避免转换为Pandas和调用Statsmodels可以进一步提升性能。我们可以利用线性代数公式手动计算残差:
残差公式:$resid = y - X\hat{\beta}$,其中$\hat{\beta} = (XTX){-1}X^Ty$(X包含常数项)
Polars支持分组窗口下的矩阵运算,结合pl.struct和自定义表达式实现:
import polars as pl import numpy as np def compute_resid(y: pl.Series, x1: pl.Series, x2: pl.Series) -> pl.Series: # 构造X矩阵(包含常数项) X = np.column_stack([np.ones(len(y)), x1.to_numpy(), x2.to_numpy()]) y_np = y.to_numpy() # 计算回归系数 beta = np.linalg.lstsq(X, y_np, rcond=None)[0] # 计算预测值和残差 y_hat = X @ beta resid = y_np - y_hat return pl.Series(resid) # 用分组窗口应用函数 final_df = df_pl.group_by("day").agg( pl.struct(["y", "x1", "x2"]) .map(lambda s: compute_resid(s["y"], s["x1"], s["x2"])) .alias("resid") ).explode("resid").join(df_pl, on="day", how="left") # 调整列顺序 final_df = final_df.select(["day", "y", "x1", "x2", "resid"]) print(final_df)
说明:
- 这种方法完全基于Polars和NumPy,避免了Pandas转换开销,适合大规模数据。
- 对于Lazy DataFrame,可以将函数改为支持延迟计算的形式(Polars 0.20+版本对自定义表达式的惰性支持更好)。
方法3:Polars惰性计算版本(推荐大数据)
如果使用Lazy DataFrame,可将自定义函数转换为Polars表达式,利用惰性优化:
def lazy_resid_expr(yvar: str, xvars: list[str]) -> pl.Expr: return pl.struct([yvar] + xvars).map_batches( lambda batch: pl.Series( np.array(batch[yvar]) - np.column_stack([np.ones(len(batch)), *[np.array(batch[x]) for x in xvars]]) @ np.linalg.lstsq( np.column_stack([np.ones(len(batch)), *[np.array(batch[x]) for x in xvars]]), np.array(batch[yvar]), rcond=None )[0] ), return_dtype=pl.Float64 ).alias("resid") # 惰性执行 lazy_df = df_pl.lazy().group_by("day").map_groups( lambda g: g.with_columns(lazy_resid_expr("y", ["x1", "x2"])) ) # 计算并输出 print(lazy_df.collect())
性能对比
- 小数据集:方法1和方法2性能差异不大,方法1更易理解和维护。
- 大数据集(百万级行):方法2和方法3的性能显著优于方法1,因为避免了Pandas转换和Statsmodels的额外开销,且Polars的惰性计算可以优化执行计划。
内容的提问来源于stack exchange,提问作者lebesgue
相关产品推荐
相关产品推荐

