ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Polars数据操作指南:行列选择与转换的核心技巧与性能优化

Polars数据操作指南:行列选择与转换的核心技巧与性能优化 在实际数据处理项目中行列选择与转换是数据清洗、特征工程和结果输出的核心操作。对于使用 Polars 库的开发者而言虽然其 API 设计理念与 Pandas 有显著差异但掌握其高效、直观的行列操作方法是发挥其性能优势的关键。很多从 Pandas 迁移过来的开发者初期容易陷入“用 Pandas 的思维写 Polars 代码”的误区导致代码冗长或性能不佳。本文将围绕 Polars 中的行列选择与转换系统性地梳理其核心概念、常用方法、性能考量以及从 Pandas 迁移时的最佳实践帮助你写出更地道、更高效的 Polars 代码。1. 理解 Polars 的数据选择哲学表达式与惰性求值在深入具体操作前必须先理解 Polars 与 Pandas 在数据操作范式上的根本区别。Pandas 倾向于命令式、即时求值的操作而 Polars 的核心是表达式Expression和惰性求值Lazy Evaluation。1.1 什么是表达式在 Polars 中一个表达式代表一个待执行的计算操作它本身不立即产生结果。例如pl.col(column_name)就是一个选择某列的表达式。你可以对表达式进行链式操作如pl.col(age) * 2或pl.col(score).mean()。这种设计使得 Polars 可以在内部对整个计算链进行优化然后再执行。import polars as pl # 创建一个简单的 DataFrame df pl.DataFrame({ name: [Alice, Bob, Charlie], age: [25, 30, 35], score: [85, 92, 78] }) # 定义一个表达式选择“age”列并计算其平均值 age_mean_expr pl.col(age).mean() print(age_mean_expr) # 输出的是一个表达式对象不是结果 # col(age).mean()1.2 惰性求值的工作流程惰性求值允许你将一系列操作选择、过滤、聚合、转换组合成一个逻辑计划Polars 的查询引擎会优化这个计划例如谓词下推、投影下推最后再一次性执行。这通常比逐步执行多个即时操作要高效得多。# 即时求值Eager模式每一步都立即执行 df_eager df.select([name, age]).filter(pl.col(age) 28) print(df_eager) # 惰性求值Lazy模式先构建计划再执行 df_lazy df.lazy().select([name, age]).filter(pl.col(age) 28).collect() print(df_lazy)对于行列选择与转换理解这一点至关重要在 Polars 中你通常是在构建一个由表达式组成的计算图而不是直接操作数据。这影响了从列选择到条件过滤等一系列操作的写法。2. 核心操作列的选择与创建列操作是数据处理的基础。Polars 提供了多种灵活且强大的列选择方式。2.1 基础列选择select方法df.select()是选择列的核心方法。它接受一个或多个表达式、字符串列名或列名列表。# 选择单列返回一个包含单列的 DataFrame df.select(name) df.select(pl.col(name)) # 使用表达式功能相同但更灵活 # 选择多列 df.select([name, age]) df.select(pl.col(name), pl.col(age)) # 使用多个表达式 # 使用通配符选择列 df.select(pl.col(^name|age$)) # 正则表达式选择以name开头或age结尾的列 df.select(pl.col(*)) # 选择所有列2.2 按数据类型选择列在数据清洗时经常需要批量操作同一类型的列例如将所有浮点数转换为整数或选择所有字符串列。# 假设 df 有多种数据类型 df pl.DataFrame({ id: [1, 2, 3], name: [a, b, c], value_f64: [1.1, 2.2, 3.3], value_i64: [10, 20, 30], flag: [True, False, True] }) # 选择所有数值列整数和浮点数 numeric_cols df.select(pl.col(pl.NUMERIC_DTYPES)) print(numeric_cols.columns) # [id, value_f64, value_i64] # 选择所有字符串列 string_cols df.select(pl.col(pl.Utf8)) print(string_cols.columns) # [name] # 排除某些类型的列 non_string_cols df.select(pl.exclude(pl.Utf8)) print(non_string_cols.columns) # [id, value_f64, value_i64, flag]2.3 创建与转换列with_columns与select的差异这是 Polars 新手最容易混淆的地方。df.with_columns()用于添加新列或替换现有列并保留所有原始列。df.select()则用于选择列的子集未被选中的列会被丢弃。# 使用 with_columns 创建新列保留所有旧列 df_new df.with_columns( (pl.col(age) 1).alias(age_next_year), # 创建新列 (pl.col(score).cast(pl.Float64)).alias(score_float) # 转换类型并创建新列 ) print(df_new.columns) # [name, age, score, age_next_year, score_float] # 使用 with_columns 替换现有列 df_replaced df.with_columns( (pl.col(age) 1).alias(age) # 新列名与旧列名相同即替换 ) print(df_replaced[age]) # 值已变为 [26, 31, 36] # 使用 select 选择并转换列不保留未选择的列 df_selected df.select( name, (pl.col(age) * 2).alias(double_age) ) print(df_selected.columns) # 只有 [name, double_age]关键决策点如果你想在现有数据基础上增加或修改列用with_columns如果你想创建一个只包含特定可能经过转换的列的新 DataFrame用select。3. 行的选择与过滤行过滤通常基于列的条件表达式。Polars 的过滤语法直观且强大。3.1 基础条件过滤filter方法df.filter()接受一个布尔表达式返回满足条件的行。# 简单条件年龄大于28 df.filter(pl.col(age) 28) # 多条件组合使用 , |, ~ 代替 and, or, not df.filter((pl.col(age) 25) (pl.col(score) 90)) df.filter((pl.col(name) Alice) | (pl.col(name) Bob)) # 判断值是否在列表中 df.filter(pl.col(name).is_in([Alice, Charlie])) # 字符串匹配 df.filter(pl.col(name).str.contains(li)) # 包含“li” df.filter(pl.col(name).str.starts_with(A)) # 以“A”开头3.2 处理空值Null的过滤Polars 对空值的处理非常严格需要特别注意。df_with_nulls pl.DataFrame({ x: [1, 2, None, 4], y: [a, None, c, d] }) # 选择某列为空的行 null_rows df_with_nulls.filter(pl.col(x).is_null()) print(null_rows) # shape: (1, 2) # ┌──────┬──────┐ # │ x ┆ y │ # │ --- ┆ --- │ # │ i64 ┆ str │ # ╞══════╪══════╡ # │ null ┆ c │ # └──────┴──────┘ # 选择某列非空的行 non_null_rows df_with_nulls.filter(pl.col(y).is_not_null()) print(non_null_rows.shape) # (3, 2) # 注意条件表达式中的空值传播 # 在 Polars 中任何与 null 的比较结果都是 null而 filter 会过滤掉结果为 null 的行。 result df_with_nulls.filter(pl.col(x) 2) print(result) # 只输出 x4 的行xNone 的行因为 (None 2) 的结果是 null 而被过滤。3.3 按索引选择行slice与head/tail虽然 Polars 没有显式的“索引”概念如 Pandas 的.iloc但可以通过行位置进行选择。# 选择前 n 行 df.head(2) # 选择后 n 行 df.tail(2) # 选择行的范围起始位置 长度 df.slice(1, 2) # 从第1行开始0-based取2行 # 在惰性模式下slice 非常高效因为它可以下推到扫描器 df.lazy().slice(1000, 500).collect() # 只扫描第1000到1499行4. 数据类型转换确保计算正确性的基石数据类型错误是运行时错误的常见来源。Polars 要求显式且正确的类型转换。4.1 使用cast方法进行类型转换cast是转换列数据类型的主要方法。df pl.DataFrame({ str_num: [1, 2, 3], int_val: [10, 20, 30], float_val: [1.5, 2.5, 3.5] }) # 将字符串转换为整数 df df.with_columns( pl.col(str_num).cast(pl.Int64).alias(str_num_as_int) ) # 将整数转换为浮点数 df df.with_columns( pl.col(int_val).cast(pl.Float64).alias(int_as_float) ) # 将浮点数转换为整数会截断小数部分 df df.with_columns( pl.col(float_val).cast(pl.Int64).alias(float_as_int_truncated) ) print(df.select(float_val, float_as_int_truncated)) # ┌───────────┬─────────────────────────┐ # │ float_val ┆ float_as_int_truncated │ # │ --- ┆ --- │ # │ f64 ┆ i64 │ # ╞═══════════╪═════════════════════════╡ # │ 1.5 ┆ 1 │ # │ 2.5 ┆ 2 │ # │ 3.5 ┆ 3 │ # └───────────┴─────────────────────────┘4.2 解析字符串为特定类型对于格式复杂的字符串如日期、时间需要使用专门的解析函数而不是简单的cast。df pl.DataFrame({ date_str: [2023-01-01, 2023-12-31, invalid], number_with_commas: [1,000, 2,500.50, 300] }) # 安全地解析日期解析失败会得到 null df df.with_columns( pl.col(date_str).str.to_date(strictFalse).alias(parsed_date) ) # 移除千分位分隔符并转换为浮点数 df df.with_columns( pl.col(number_with_commas) .str.replace_all(,, ) .cast(pl.Float64, strictFalse) .alias(parsed_number) ) print(df)4.3 常见类型转换场景与陷阱场景错误做法正确做法说明含非数字字符的字符串转数字col.str.cast(pl.Int64)col.str.replace(非数字,).cast(pl.Int64)或pl.col(col).cast(pl.Int64, strictFalse)直接cast会失败需先清洗或使用非严格模式。布尔值转换col.cast(pl.Utf8)可能得到”true”col.cast(pl.Utf8)得到”true”若需”1”/”0”用col.cast(pl.Int8).cast(pl.Utf8)明确最终需要的字符串格式。大整数转浮点数直接转换可能导致精度丢失评估精度要求或考虑使用Decimal类型浮点数无法精确表示所有大整数。转换整个 DataFrame使用循环逐列转换使用df.with_columns([pl.col(dtype).cast(new_dtype) for dtype in old_dtypes])利用 Polars 的批量操作能力。重要提示在生产环境中对于来源不确定的数据建议在转换时使用strictFalse参数或配合fill_null和fill_nan方法以避免因个别脏数据导致整个操作失败。# 安全的转换方式 safe_converted df.with_columns( pl.col(risky_column).cast(pl.Int64, strictFalse).fill_null(-1) )5. 高级选择与转换技巧掌握基础后一些高级技巧能极大提升代码的简洁性和效率。5.1 使用select进行条件列选择你可以根据列名、数据类型甚至列值来动态选择列。# 选择列名包含特定字符串的列 df.select(pl.col(^.*score.*$)) # 使用正则表达式选择列名包含“score”的列 # 根据列的数据类型进行转换 # 例如将所有数值列转换为 Float32 df_numeric_to_float df.with_columns([ pl.col(dtype).cast(pl.Float32) for dtype in (pl.Int64, pl.Float64) for col_name in df.select(pl.col(dtype)).columns ])5.2 使用map与apply进行自定义行转换虽然 Polars 鼓励使用向量化操作但对于复杂的自定义逻辑map_elements原apply是必要的。注意这通常比向量化操作慢。df pl.DataFrame({text: [hello world, POLARS, Data Analysis]}) # 使用 str 命名空间的方法向量化快 df df.with_columns( pl.col(text).str.to_uppercase().alias(uppercase), pl.col(text).str.len_bytes().alias(length) ) # 使用 map_elements 进行自定义函数处理逐行慢 def custom_parser(text: str) - str: # 一些复杂的无法用内置表达式实现的逻辑 if world in text: return contains_world else: return text[:5] df df.with_columns( pl.col(text).map_elements(custom_parser, return_dtypepl.Utf8).alias(parsed) )5.3 行列转置与透视虽然 Polars 主要处理列式数据但也提供了行列转换的工具。# 宽表变长表melt wide_df pl.DataFrame({ id: [1, 2], A_2022: [100, 200], B_2022: [150, 250], A_2023: [110, 210], B_2023: [160, 260] }) long_df wide_df.melt(id_varsid, variable_namemetric_year, value_namevalue) print(long_df) # 这常用于时间序列分析或绘图前的数据准备。 # 长表变宽表pivot # 假设 long_df 是上面的结果我们将其转回宽表 pivoted_df long_df.pivot( indexid, columnsmetric_year, valuesvalue, aggregate_functionfirst # 因为每个(id, metric_year)组合是唯一的 )6. 性能优化与最佳实践将 Polars 用对地方才能发挥其性能优势。6.1 优先使用表达式避免逐行操作这是最重要的原则。Polars 的表达式引擎可以对整个操作链进行优化。# 不推荐使用 map_elements 进行逐行数值计算 df df.with_columns( pl.col(value).map_elements(lambda x: x * 2 10, return_dtypepl.Int64).alias(computed) ) # 推荐使用表达式向量化 df df.with_columns( (pl.col(value) * 2 10).alias(computed) )6.2 在惰性模式下进行复杂的数据准备对于多步的筛选、选择和转换使用惰性 API (lazy()) 可以让 Polars 优化整个查询计划。# 复杂的数据处理流程 query (df.lazy() .filter(pl.col(age) 18) .with_columns([ (pl.col(income) / 12).alias(monthly_income), pl.col(join_date).dt.year().alias(join_year) ]) .select([name, monthly_income, join_year]) .sort(monthly_income, descendingTrue) ) # 直到调用 .collect() 才真正执行此时优化已生效 result query.collect(streamingTrue) # 对于大数据集可以使用流式处理6.3 选择正确的数据类型错误的数据类型会浪费内存并降低性能。场景推荐类型说明小范围整数如状态码pl.Int8,pl.UInt8,pl.Int16,pl.UInt16比默认的Int64节省大量内存。分类文本如国家、性别pl.Categorical大幅减少内存占用加速分组和过滤。精确小数如货币pl.Decimal避免浮点数精度问题。仅包含日期的数据pl.Date比pl.Datetime更节省空间。# 优化数据类型示例 df_optimized df.with_columns([ pl.col(status_code).cast(pl.UInt8), pl.col(country).cast(pl.Categorical), pl.col(price).cast(pl.Decimal(scale2)) ])6.4 常见问题排查清单当行列选择或转换未按预期工作时可按此清单排查。问题现象可能原因检查与解决select后列消失了误用了select而非with_columns确认意图如需保留所有列并新增用with_columns。转换失败报类型错误数据中存在不符合目标类型的值如字符串“N/A”转整数1. 使用strictFalse参数。2. 先使用str.replace或fill_null清理数据。3. 用is_not_null()和is_null()检查脏数据。过滤条件未生效条件逻辑错误或涉及null值的比较1. 检查条件中的逻辑运算符, 性能比 Pandas 还慢在 Polars 中使用了逐行循环如map_elements或频繁复制 DataFrame1. 将操作改写为表达式。2. 使用惰性评估 (lazy())。3. 检查是否在循环中重复创建 DataFrame。结果顺序不符合预期Polars 不保证行顺序除非显式排序在操作链的最后使用.sort()来确保输出顺序。7. 从 Pandas 迁移的思维转换对于熟悉 Pandas 的开发者改变以下思维习惯能更快适应 Polars。忘记.iloc和.locPolars 没有基于位置的索引器。选择行用filter和slice选择列用select。链式操作是常态Polars 的方法大多返回新的DataFrame或LazyFrame鼓励链式调用。避免将中间结果赋值给变量除非需要复用。assign变成with_columnsPandas 的df.assign(new_col...)对应 Polars 的df.with_columns(pl.col(...).alias(new_col))。apply是最后的选择Pandas 里常见的df[col].apply(func)在 Polars 中是性能瓶颈。优先寻找内置的表达式或str、dt命名空间下的方法。关注数据类型Polars 对类型更严格。在读取数据后和进行计算前主动检查和转换数据类型。通过理解 Polars 的表达式哲学掌握select、with_columns、filter等核心方法并在实践中遵循性能最佳实践你可以高效地完成各类行列选择与转换任务。对于复杂的数据管道始终从惰性求值的角度思考让查询优化器为你工作。开始尝试将旧有的 Pandas 脚本重写为 Polars 表达式是巩固这些概念的最佳方式。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进