返回

Polars浮点数列智能转整数:告别不必要的小数点

python

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 的浮点数?通常来自几个方面:

  1. 数据源本身就是浮点类型 :比如 CSV 文件、数据库,可能原始数据就是 float
  2. 计算结果 :某些上游计算(即使是整数间的除法也可能产生浮点数)导致列变成了浮点型。
  3. 缺失值(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}

方案一:迭代列 + 条件检查 + 类型转换

这是最直观的方法:遍历所有我们关心的列,对每个列进行检查,符合条件的就转换。

原理和作用:

  1. 选择目标列 :首先,我们只对浮点类型的列感兴趣。可以用 select 配合 pl.col(pl.Float64) 来选取这些列。为了避免修改不想动的列(比如这里的 date列),可以显式排除,或者只选择我们想检查的数值列。
  2. 检查条件 :对选中的每一列:
    • 它必须是浮点类型 (虽然我们已经筛选了,但多一步确认无妨)。
    • 它的所有非空值的小数部分都必须是0。Polars 提供了 fract() 方法获取小数部分。我们可以检查 (pl.col(col_name).fract() == 0.0)。考虑到浮点数精度问题,有时候直接比较 == 0.0 可能不够鲁棒,更安全的方式是检查绝对值是否小于一个极小值,例如 pl.col(col_name).fract().abs() < 1e-9。不过对于 .0 这种明确的情况,直接比较通常可行。
    • 同时,需要正确处理 null 值。如果一列全是 nullfract() 会产生 null。条件 (X.is_null() | (X.fract() == 0.0)).all() 能确保 null 值不影响判断逻辑(即 null 不算作“非整数”)。
    • 我们还需要一个额外的判断:如果一个浮点列全是 null (比如 d_all_null_float),它的小数部分检查也会通过。但我们可能不希望把一个全是 nullFloat64 列转换成 Int64,除非业务上有此需求。通常,全是 null 的列保持其原始可能的类型(或转为特定类型)更合理。所以,增加一个检查 col.is_not_null().any() 来确保列中至少有一个非空值,才进行转换。
  3. 执行转换 :如果条件满足,使用 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-011101.0nullnull           ┆ text1    │
│ 2025-01-022202.1nullnull           ┆ text2    │
│ 2025-01-03null303.0nullnull           ┆ text3    │
└────────────┴──────┴─────┴─────┴──────────────────┴────────────────┴──────────┘
{'date': Utf8, 'a': Int64, 'b': Int64, 'c': Float64, 'd_all_null_float': Float64, 'e_all_null_int': Int64, 'f_string': Utf8}

ab 被成功转换成了 Int64。列 c 因为包含 2.1,保持 Float64。列 d_all_null_float 因为全是 null,也被跳过转换,保持 Float64

进阶使用技巧:

  • 浮点精度fract().abs() < 1e-9fract() == 0.0 更能抵抗微小的浮点误差。对于 1.0000000001 这样的数,前者可能按需判定为整数,后者则不会。根据实际数据精度要求选择。
  • 选择特定浮点类型 :如果 DataFrame 中有 Float32Float64,可以用 pl.col(pl.FLOAT_DTYPES) 来选择所有浮点列。
  • 错误处理cast 操作在某些边缘情况下可能失败(虽然这里不太可能)。对于更复杂的转换,可以考虑 try_cast

方案二:表达式 + select + with_columns 的链式操作

Polars 的精髓在于其强大的表达式系统。我们可以构建一个转换表达式列表,一次性应用。

原理和作用:
这个方案与方案一的核心逻辑相似,但组织方式更“Polars化”。

  1. 识别列并构建表达式 :我们还是需要先判断哪些列需要转换。但这次,我们为每一列(或特定类型的列)构建一个 Polars 表达式。
    • 如果某列 X 符合转换为整数的条件,为其创建一个 pl.col("X").cast(pl.Int64) 表达式。
    • 如果不符合,则创建 pl.col("X") 表达式(即保持原样)。
  2. 批量应用 :将所有这些表达式收集到一个列表中,然后通过 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-011101.0nullnull           ┆ text1    │
│ 2025-01-022202.1nullnull           ┆ text2    │
│ 2025-01-03null303.0nullnull           ┆ 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 表达式的强大和声明式的风格。选择哪个,看个人喜好和具体场景的复杂度。对于这个问题,两种方法的性能差异可能不大,可读性和维护性是主要考量。