Polars浮点数列智能转整数:告别不必要的小数点
2025-05-05 17:53:21
Polars 浮点数列智能转整数:告别不必要的小数点
处理数据时,我们经常遇到这样的情况:明明看起来是整数的数据,偏偏被存成了浮点数,带着个碍眼的 .0
。比如,一个产品 ID 列,本该是 1
, 2
, 3
,却显示为 1.0
, 2.0
, 3.0
。这不仅占地方,有时候还会给后续的类型匹配带来小麻烦。
Pandas 里,我们可能会用 map
配合 is_integer()
来逐个元素判断转换。就像这样:
import pandas as pd
df_pd = pd.DataFrame({
"date": ["2025-01-01", "2025-01-02"],
"a": [1.0, 2.0],
"b_all_null": [None, None], # 全是null的浮点列
"c": [1.0, 2.1],
"d_with_null": [3.0, None, 4.0] # 包含null但其他是整数的浮点列
})
print("原始 Pandas DataFrame:")
print(df_pd)
print(df_pd.dtypes)
columns_to_check = df_pd.columns.difference(["date"])
# Pandas 的逻辑是元素级的,如果一列中既有1.0又有2.1,它会尝试转换1.0为1,2.1不变,结果列可能变成object类型
# 但通常我们希望的是:如果整列都是 xxx.0 的形式,才把整列转为int
# 为了更接近 Polars 的列式思维,我们这里模拟一个按列判断的逻辑
for col_name in columns_to_check:
if df_pd[col_name].dtype == 'float64':
# 检查是否所有非空值都是整数
is_all_integer_like = df_pd[col_name].dropna().apply(lambda x: x.is_integer()).all()
if is_all_integer_like:
# 如果全是空值,dropna().apply().all() 会是 True,但我们可能不想转换全是空的列
# 或者说,Pandas 的 to_numeric(downcast='integer') 或 astype(pd.Int64Dtype()) 更适合处理含NA的转换
if not df_pd[col_name].isnull().all(): # 避免转换全是NaN的列,或者按需处理
# pandas < 0.24没有专门的 Int64Dtype() 支持NA,astype(int)会报错
# 这里简单演示,不处理 NaN 转 Int 的复杂性,假设可以安全转
# 实际上,若要保持NaN,需用 Int64Dtype()
# df_pd[col_name] = df_pd[col_name].astype(int) # 如果有NaN会报错
# 更安全的做法
try:
# 尝试转换为可空整数类型,如果该列没有小数部分
# 为了演示,这里我们简化为:如果可以转,就转为普通int,假定无NA或NA已被处理
# 实践中,若要保留NA,用 Int64Dtype
df_pd[col_name] = df_pd[col_name].apply(lambda x: int(x) if pd.notnull(x) and x.is_integer() else x)
except TypeError: # 如果列中有非数字或无法转换的值
pass
print("\n转换后 Pandas DataFrame:")
print(df_pd)
print(df_pd.dtypes)
输出差不多是:
原始 Pandas DataFrame:
date a b_all_null c d_with_null
0 2025-01-01 1.0 NaN 1.0 3.0
1 2025-01-02 2.0 NaN 2.1 NaN
2 NaN NaN NaN NaN 4.0
原始 Pandas DataFrame dtypes:
date object
a float64
b_all_null object # Pandas自动推断
c float64
d_with_null float64
dtype: object
转换后 Pandas DataFrame:
date a b_all_null c d_with_null
0 2025-01-01 1 NaN 1.0 3.0
1 2025-01-02 2 NaN 2.1 NaN
2 NaN NaN NaN NaN 4.0
转换后 Pandas DataFrame dtypes:
date object
a int64 # 'a' 被转换
b_all_null object
c float64 # 'c' 保持不变
d_with_null float64 # 'd_with_null' 包含 NaN,未进行简单转换,按原逻辑仅转换非空整数。
dtype: object
注意Pandas的 is_integer()
是针对单个浮点数的方法。Polars 是一个列式计算库,它的玩法有点不一样,更讲究整列操作的效率。那么,在 Polars 里头,我们该怎么优雅地实现这个“智能转换”呢?
一、问题根源瞅一瞅
为啥会有这么多 .0
的浮点数?通常来自几个方面:
- 数据源本身就是浮点类型 :比如 CSV 文件、数据库,可能原始数据就是
float
。 - 计算结果 :某些上游计算(即使是整数间的除法也可能产生浮点数)导致列变成了浮点型。
- 缺失值(NaN)的存在 :如果一列有缺失值
NaN
,它通常会被表示为浮点类型(比如float64
),即使其他值都是整数。Pandas 在没有专门的Int64Dtype
等可空整数类型前,这是常见情况。Polars 则从一开始就良好地支持列内 null 值,且整数类型也可以包含 null。
知道了原因,咱们就能对症下药。
二、Polars 方案:列式思维,高效转换
Polars 强调列操作。我们的目标是:检查一个浮点数类型的列,如果它所有的非空值实际上都是整数(即小数部分为0),那么就把这一整列的数据类型从浮点型(如 Float64
)转换为整型(如 Int64
)。如果列中包含任何一个真正的小数(如 2.1
),或者全是 null
,那它就应该保持原样或者按需特殊处理。
下面提供几种在 Polars 中处理这类问题的思路和方法。
先准备一个 Polars DataFrame 作为例子:
import polars as pl
df = pl.DataFrame({
"date": ["2025-01-01", "2025-01-02", "2025-01-03"],
"a": [1.0, 2.0, None], # 可以转 int (包含null)
"b": [10.0, 20.0, 30.0], # 可以转 int (不含null)
"c": [1.0, 2.1, 3.0], # 不应该转 (有小数)
"d_all_null_float": pl.Series([None, None, None], dtype=pl.Float64), # 全是null的浮点列
"e_all_null_int": pl.Series([None, None, None], dtype=pl.Int64), # 全是null的整数列 (作对比)
"f_string": ["text1", "text2", "text3"] # 非数值列
})
print("原始 Polars DataFrame:")
print(df)
print(df.schema)
原始数据和类型:
原始 Polars DataFrame:
shape: (3, 6)
┌────────────┬──────┬──────┬─────┬──────────────────┬────────────────┬──────────┐
│ date ┆ a ┆ b ┆ c ┆ d_all_null_float ┆ e_all_null_int ┆ f_string │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ i64 ┆ str │
╞════════════╪══════╪══════╪═════╪══════════════════╪════════════════╪══════════╡
│ 2025-01-01 ┆ 1.0 ┆ 10.0 ┆ 1.0 ┆ null ┆ null ┆ text1 │
│ 2025-01-02 ┆ 2.0 ┆ 20.0 ┆ 2.1 ┆ null ┆ null ┆ text2 │
│ 2025-01-03 ┆ null ┆ 30.0 ┆ 3.0 ┆ null ┆ null ┆ text3 │
└────────────┴──────┴──────┴─────┴──────────────────┴────────────────┴──────────┘
{'date': Utf8, 'a': Float64, 'b': Float64, 'c': Float64, 'd_all_null_float': Float64, 'e_all_null_int': Int64, 'f_string': Utf8}
方案一:迭代列 + 条件检查 + 类型转换
这是最直观的方法:遍历所有我们关心的列,对每个列进行检查,符合条件的就转换。
原理和作用:
- 选择目标列 :首先,我们只对浮点类型的列感兴趣。可以用
select
配合pl.col(pl.Float64)
来选取这些列。为了避免修改不想动的列(比如这里的date
列),可以显式排除,或者只选择我们想检查的数值列。 - 检查条件 :对选中的每一列:
- 它必须是浮点类型 (虽然我们已经筛选了,但多一步确认无妨)。
- 它的所有非空值的小数部分都必须是0。Polars 提供了
fract()
方法获取小数部分。我们可以检查(pl.col(col_name).fract() == 0.0)
。考虑到浮点数精度问题,有时候直接比较== 0.0
可能不够鲁棒,更安全的方式是检查绝对值是否小于一个极小值,例如pl.col(col_name).fract().abs() < 1e-9
。不过对于.0
这种明确的情况,直接比较通常可行。 - 同时,需要正确处理
null
值。如果一列全是null
,fract()
会产生null
。条件(X.is_null() | (X.fract() == 0.0)).all()
能确保null
值不影响判断逻辑(即null
不算作“非整数”)。 - 我们还需要一个额外的判断:如果一个浮点列全是
null
(比如d_all_null_float
),它的小数部分检查也会通过。但我们可能不希望把一个全是null
的Float64
列转换成Int64
,除非业务上有此需求。通常,全是null
的列保持其原始可能的类型(或转为特定类型)更合理。所以,增加一个检查col.is_not_null().any()
来确保列中至少有一个非空值,才进行转换。
- 执行转换 :如果条件满足,使用
cast(pl.Int64)
将该列转换为整数类型。
代码示例:
df_transformed = df.clone() # 克隆一份数据进行操作,保持原df不变
# 要排除的列名,或者只选择要处理的列
columns_to_exclude = ["date", "f_string"] # 假设日期和字符串列不参与此转换
# 获取所有浮点列的名称
float_columns = []
for col_name, dtype in df_transformed.schema.items():
if col_name not in columns_to_exclude and dtype == pl.Float64:
float_columns.append(col_name)
print(f"找到的浮点列: {float_columns}")
cols_to_update = []
for col_name in float_columns:
series = df_transformed.get_column(col_name)
# 条件1: 该列至少有一个非空值 (避免转换全是null的列)
has_non_null_values = series.is_not_null().any()
if not has_non_null_values: # 如果全是null,跳过转换
print(f"列 '{col_name}' 全是null,跳过转换。")
continue
# 条件2: 所有非空值的小数部分都为0
# (series.is_null() | (series.fract() == 0.0)).all()
# 这个表达式对于全是null的列会返回True,所以上面的 has_non_null_values 判断很重要
all_are_integers_or_null = (series.is_null() | (series.fract().abs() < 1e-9)).all() # 使用一个小的容差
if all_are_integers_or_null:
print(f"列 '{col_name}' 符合条件,将被转换为 Int64。")
cols_to_update.append(pl.col(col_name).cast(pl.Int64))
else:
print(f"列 '{col_name}' 不符合条件 (可能包含实际小数或转换问题),保持 Float64。")
cols_to_update.append(pl.col(col_name)) # 保持原样
# 如果有些原始非浮点列也想包括在最终的 df 中,确保它们也被选中
# 这里我们仅更新已选中的 float_columns, 其他列通过 clone 保留
# 但是,如果 cols_to_update 只包含部分列, with_columns 会只更新这些,其他不动。
if cols_to_update: # 只有当有列需要更新时才执行
# 构建完整的列更新列表,确保未被转换的浮点列也以其原名保留
final_expressions = []
existing_float_cols_in_update = {expr.meta.output_name() for expr in cols_to_update}
for original_col_name in float_columns:
if original_col_name in existing_float_cols_in_update:
# 已经在 cols_to_update 里了,(可能已cast,可能保留原样)
# 找到对应的表达式加入 final_expressions
for expr in cols_to_update:
if expr.meta.output_name() == original_col_name:
final_expressions.append(expr)
break
else:
# 对于那些全空的列,它们不在cols_to_update里,但仍是浮点列,需要原样加回去
final_expressions.append(pl.col(original_col_name))
# 执行转换
if final_expressions: # 再次确认有表达式
df_transformed = df_transformed.with_columns(final_expressions)
print("\n方案一转换后的 Polars DataFrame:")
print(df_transformed)
print(df_transformed.schema)
代码输出解释:
找到的浮点列: ['a', 'b', 'c', 'd_all_null_float']
列 'a' 符合条件,将被转换为 Int64。
列 'b' 符合条件,将被转换为 Int64。
列 'c' 不符合条件 (可能包含实际小数或转换问题),保持 Float64。
列 'd_all_null_float' 全是null,跳过转换。
方案一转换后的 Polars DataFrame:
shape: (3, 6)
┌────────────┬──────┬─────┬─────┬──────────────────┬────────────────┬──────────┐
│ date ┆ a ┆ b ┆ c ┆ d_all_null_float ┆ e_all_null_int ┆ f_string │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ i64 ┆ f64 ┆ f64 ┆ i64 ┆ str │
╞════════════╪══════╪═════╪═════╪══════════════════╪════════════════╪══════════╡
│ 2025-01-01 ┆ 1 ┆ 10 ┆ 1.0 ┆ null ┆ null ┆ text1 │
│ 2025-01-02 ┆ 2 ┆ 20 ┆ 2.1 ┆ null ┆ null ┆ text2 │
│ 2025-01-03 ┆ null ┆ 30 ┆ 3.0 ┆ null ┆ null ┆ text3 │
└────────────┴──────┴─────┴─────┴──────────────────┴────────────────┴──────────┘
{'date': Utf8, 'a': Int64, 'b': Int64, 'c': Float64, 'd_all_null_float': Float64, 'e_all_null_int': Int64, 'f_string': Utf8}
列 a
和 b
被成功转换成了 Int64
。列 c
因为包含 2.1
,保持 Float64
。列 d_all_null_float
因为全是 null
,也被跳过转换,保持 Float64
。
进阶使用技巧:
- 浮点精度 :
fract().abs() < 1e-9
比fract() == 0.0
更能抵抗微小的浮点误差。对于1.0000000001
这样的数,前者可能按需判定为整数,后者则不会。根据实际数据精度要求选择。 - 选择特定浮点类型 :如果 DataFrame 中有
Float32
和Float64
,可以用pl.col(pl.FLOAT_DTYPES)
来选择所有浮点列。 - 错误处理 :
cast
操作在某些边缘情况下可能失败(虽然这里不太可能)。对于更复杂的转换,可以考虑try_cast
。
方案二:表达式 + select
+ with_columns
的链式操作
Polars 的精髓在于其强大的表达式系统。我们可以构建一个转换表达式列表,一次性应用。
原理和作用:
这个方案与方案一的核心逻辑相似,但组织方式更“Polars化”。
- 识别列并构建表达式 :我们还是需要先判断哪些列需要转换。但这次,我们为每一列(或特定类型的列)构建一个 Polars 表达式。
- 如果某列
X
符合转换为整数的条件,为其创建一个pl.col("X").cast(pl.Int64)
表达式。 - 如果不符合,则创建
pl.col("X")
表达式(即保持原样)。
- 如果某列
- 批量应用 :将所有这些表达式收集到一个列表中,然后通过
df.with_columns([...])
一次性应用所有转换。
代码示例:
df_transformed_expr = df.clone()
expressions_to_apply = []
columns_to_process = [name for name, dtype in df.schema.items() if dtype == pl.Float64 and name not in columns_to_exclude]
for col_name in df_transformed_expr.columns: # 遍历所有列
if col_name in columns_to_exclude: # 如果是被排除的列,直接原样保留
expressions_to_apply.append(pl.col(col_name))
continue
series = df_transformed_expr.get_column(col_name)
if series.dtype == pl.Float64: # 只处理浮点列
has_non_null_values = series.is_not_null().any()
if not has_non_null_values: # 全是null
print(f"列 '{col_name}' (方案二) 全是null,保持 Float64。")
expressions_to_apply.append(pl.col(col_name)) # 保持原样
continue
all_are_integers_or_null = (series.is_null() | (series.fract().abs() < 1e-9)).all()
if all_are_integers_or_null:
print(f"列 '{col_name}' (方案二) 符合条件,将应用 cast(pl.Int64)。")
expressions_to_apply.append(pl.col(col_name).cast(pl.Int64))
else:
print(f"列 '{col_name}' (方案二) 不符合条件,保持 Float64。")
expressions_to_apply.append(pl.col(col_name)) # 保持原样
else: # 非浮点列,直接原样保留
expressions_to_apply.append(pl.col(col_name))
df_transformed_expr = df_transformed_expr.select(expressions_to_apply) # 使用 select 更适合完全重构列
# 或者使用 with_columns 如果表达式名称和原列名一致,它会覆盖
# df_transformed_expr = df_transformed_expr.with_columns(expressions_to_apply)
print("\n方案二转换后的 Polars DataFrame:")
print(df_transformed_expr)
print(df_transformed_expr.schema)
代码输出解释:
输出结果和 schema
与方案一一致,表明达到了相同的目的。
列 'a' (方案二) 符合条件,将应用 cast(pl.Int64)。
列 'b' (方案二) 符合条件,将应用 cast(pl.Int64)。
列 'c' (方案二) 不符合条件,保持 Float64。
列 'd_all_null_float' (方案二) 全是null,保持 Float64。
方案二转换后的 Polars DataFrame:
shape: (3, 6)
┌────────────┬──────┬─────┬─────┬──────────────────┬────────────────┬──────────┐
│ date ┆ a ┆ b ┆ c ┆ d_all_null_float ┆ e_all_null_int ┆ f_string │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ i64 ┆ f64 ┆ f64 ┆ i64 ┆ str │
╞════════════╪══════╪═════╪═════╪══════════════════╪════════════════╪══════════╡
│ 2025-01-01 ┆ 1 ┆ 10 ┆ 1.0 ┆ null ┆ null ┆ text1 │
│ 2025-01-02 ┆ 2 ┆ 20 ┆ 2.1 ┆ null ┆ null ┆ text2 │
│ 2025-01-03 ┆ null ┆ 30 ┆ 3.0 ┆ null ┆ null ┆ text3 │
└────────────┴──────┴─────┴─────┴──────────────────┴────────────────┴──────────┘
{'date': Utf8, 'a': Int64, 'b': Int64, 'c': Float64, 'd_all_null_float': Float64, 'e_all_null_int': Int64, 'f_string': Utf8}
这种方法的优点:
- 更 "Polars 化" :一次性构建所有操作,然后交由 Polars 的查询优化器处理,理论上可能更高效,尤其当列很多时。
- 灵活性 :表达式可以组合更复杂的操作。例如,在转换的同时进行重命名
pl.col("old_name").cast(pl.Int64).alias("new_name")
。
进阶使用技巧:
- 选择器
cs
:Polars 提供了强大的列选择器(polars.selectors
,通常导入为cs
)。可以用cs.float()
一次性选择所有浮点列,或cs.by_dtype(pl.Float64)
。这能简化列的选择过程。
import polars.selectors as cs
df_selected_expr = df.clone()
# 表达式列表
expressions = []
# 对所有浮点列进行操作
expressions.extend(
pl.when(
(pl.col(cs.float()).is_not_null().any()) & # 至少有一个非空值
(pl.col(cs.float()).is_null() | (pl.col(cs.float()).fract().abs() < 1e-9)).all() # 所有非空值是整数
)
.then(pl.col(cs.float()).cast(pl.Int64)) # 则转换为Int64
.otherwise(pl.col(cs.float())) # 否则保持原样 (Float)
)
# 对所有非浮点列,保持原样
expressions.extend(pl.col(cs.string() | cs.integer() | cs.boolean() | cs.date() )) # Add other types as needed
# 需要注意,cs.float() 在 when.then.otherwise 中作为列的集合,其行为可能不像单列那样直接。
# when().then().otherwise() 通常是元素级的,而这里的判断是列级的。
# 因此,更稳妥的做法还是像方案二那样,先判断列,再为符合条件的列单独构建cast表达式。
# 以下是一个改进版的方案二,使用选择器来选列
cols_to_check_sel = df_selected_expr.select(cs.float().exclude(columns_to_exclude)).columns
update_expressions_sel = []
for col_name in df_selected_expr.columns:
if col_name in cols_to_check_sel:
series = df_selected_expr.get_column(col_name)
has_non_null_values = series.is_not_null().any()
if not has_non_null_values:
update_expressions_sel.append(pl.col(col_name)) # 全是null,保持
continue
all_are_integers_or_null = (series.is_null() | (series.fract().abs() < 1e-9)).all()
if all_are_integers_or_null:
update_expressions_sel.append(pl.col(col_name).cast(pl.Int64))
else:
update_expressions_sel.append(pl.col(col_name)) # 不符合,保持
else:
update_expressions_sel.append(pl.col(col_name)) # 非检查列,保持
df_selected_expr = df_selected_expr.with_columns(update_expressions_sel) # with_columns is fine if names match
print("\n方案二 (使用选择器辅助) 转换后的 Polars DataFrame:")
print(df_selected_expr)
print(df_selected_expr.schema)
这个使用选择器的版本,主要是在选择要检查的列 cols_to_check_sel
时更方便。其核心转换逻辑和方案二一致。when().then().otherwise()
结构在这里如果直接用于列级条件判断会比较 tricky,因为它主要设计为行级(元素级)的条件分支。所以,对于这种列级别的条件转换,先判断,后构造表达式列表并用 with_columns
是个清晰有效的模式。
这两种方案都能解决问题。方案一更像传统的编程思路,逐步执行。方案二(尤其是结合选择器)更能体现 Polars 表达式的强大和声明式的风格。选择哪个,看个人喜好和具体场景的复杂度。对于这个问题,两种方法的性能差异可能不大,可读性和维护性是主要考量。