NULL 处理

NULL 处理#

NULL 是数据处理里绕不开的情况:一行里少一个值,聚合结果、连接结果、甚至类型判断都会跟着变。Polars 把 NULL 和 NaN 分得很清,也提供了不少参数来控制“遇到 NULL 该怎么办”。

这一章先讲 NULL 参与计算时的规则,再看检测、删除、填补的具体函数,最后把散落在各个方法里的 NULL 相关参数集中过一遍。

理解 NULL 的关键,是把它当成“缺失”而不是“某个特殊值”:它不参与逐元素运算,聚合时会自动跳过,比较时也不等于自身。后面那些参数,大多是在这三条规则上做微调。

import polars as pl
import numpy as np
from helper.jupyter import row

Polars 把缺失当作 NULL,用一张位图单独记录“哪一行是 NULL”,而不是像有些系统那样用一个特殊值占位。Python 的 None 在建成数据框时同样会变成 NULL。

基本规则有两条:❶ 逐元素运算时,只要一边是 NULL,结果就是 NULL;❷ sum()、mean() 这类聚合会自动忽略 NULL。两条合起来,就解释了为什么“含 NULL 的列求和会变少,平均值却不受影响”。

位图的好处是 NULL 不占用真实值的位置,数值列仍然是连续的数值;代价是每次计算都要额外检查这一位,所以 NULL 很多时性能会下降。

df = pl.DataFrame(dict(
    A=[1, 2, None, None],
    B=[5, None, 6, None],
))

df1 = df.select(A_plus_B=pl.col("A") + pl.col("B"))  # ❶ NULL + 数值 还是 NULL
df2 = df.select(
    A_sum=pl.col("A").sum(),  # ❷ NULL 不参与计数
    B_mean=pl.col("B").mean(),
)
row(df, df1, df2)
shape: (4, 2)
AB
i64i64
15
2null
null6
nullnull
shape: (4, 1)
A_plus_B
i64
6
null
null
null
shape: (1, 2)
A_sumB_mean
i64f64
35.5

处理 NULL 的函数和表达式如下:前两个负责去掉或补上,null_count 与 has_nulls 负责统计和判断,最后两个逐元素做判断。

  • drop_nulls:丢掉含 NULL 的行。因为各列剩下的行数会不同,想保持表的形状时就像 ❶ 那样用 implode() 收进列表。

  • fill_null:用指定的值填补 NULL。

  • null_count:统计每列有多少个 NULL,排查数据质量时先看这个。

  • has_nulls:判断列里是否含 NULL,适合快速筛出“有问题的列”。

  • is_null:逐元素判断是否为 NULL。

  • is_not_null:逐元素判断是否不为 NULL。

前两组作用在整列上,后两组返回布尔列;需要按 NULL 筛选行时,通常用 is_null() 或 is_not_null() 配合 filter()。

df = pl.DataFrame({"A": [1, 2, None, None, 5], "B": [None, 3, 5, None, 7]})

# 去掉每列的 NULL,收成列表列
df1 = df.select(pl.all().drop_nulls().implode())  # ❶

# NULL 填 0
df2 = df.with_columns(pl.all().fill_null(0))

# 每列的 NULL 个数
df3 = df.select(pl.all().null_count())

# 列里是否有 NULL
df4 = df.select(pl.all().has_nulls())

# 逐元素判断是否 NULL
df5 = df.select(pl.all().is_null())

row(df, df1, df2, df3, df4, df5)
shape: (5, 2)
AB
i64i64
1null
23
null5
nullnull
57
shape: (1, 2)
AB
list[i64]list[i64]
[1, 2, 5][3, 5, 7]
shape: (5, 2)
AB
i64i64
10
23
05
00
57
shape: (1, 2)
AB
u32u32
22
shape: (1, 2)
AB
boolbool
truetrue
shape: (5, 2)
AB
boolbool
falsetrue
falsefalse
truefalse
truetrue
falsefalse

coalesce() 依次看几个候选,返回第一个不是 NULL 的值,可以理解成“取第一个有效值”。下面的代码先看 A,再看 B * 2,两个都是 NULL 时用 100 作为默认值。

df.with_columns(
    C=pl.coalesce("A", pl.col("B") * 2, 100)
)
shape: (5, 3)
ABC
i64i64i64
1null1
232
null510
nullnull100
575

interpolate() 用前后的值插补 NULL,适合时间序列这种相邻点相关的数据,method 决定插补方式。

  1. 线性插补(method='linear',默认):前后两点连直线,适合变化平缓的量。

  2. 最近邻插补(method='nearest'):用最近的已有值,适合阶梯状变化或类别型数值。

下面的代码对所有列都做两种插补,分别加 _interp 和 _nearest 后缀。

df.with_columns(
    pl.all().interpolate().name.suffix("_interp"),
    pl.all().interpolate(method="nearest").name.suffix("_nearest"),
)
shape: (5, 6)
ABA_interpB_interpA_nearestB_nearest
i64i64f64f64i64i64
1null1.0null1null
232.03.023
null53.05.025
nullnull4.06.057
575.07.057

同样的功能在 DataFrame 上也有方法,区别是它们直接作用在整张表上,不用写表达式。

  • DataFrame.drop_nulls():删掉含 NULL 的行。

  • DataFrame.fill_null():用指定的值填补 NULL。

  • DataFrame.null_count():返回每列的 NULL 个数。

row(
    df.drop_nulls(),
    df.fill_null(0),
    df.null_count()
)
shape: (2, 2)
AB
i64i64
23
57
shape: (5, 2)
AB
i64i64
10
23
05
00
57
shape: (1, 2)
AB
u32u32
22

fill_null 给出 strategy 时,可以用列的统计量或前后顺序来填,这在“缺失和位置有关”的数据里比填固定值合理得多。limit 则可以限制填补的个数,避免把一大段缺失强行填成一条直线。

  • "forward":用前一个值填(前向填充)。

  • "backward":用后一个值填(后向填充)。

  • "min" / "max":用列的最小值 / 最大值填。

  • "mean":用列的平均值填。

  • "one" / "zero":全部置 1 / 0。

df = pl.DataFrame({
    "A": [1.0, None, None, 4],
    "B": [None, 2.0, None, None],
})

df_forward = df.fill_null(strategy="forward")   # 前向填充
df_backward = df.fill_null(strategy="backward")  # 后向填充
df_mean = df.fill_null(strategy="mean")         # 用平均值填
df_limit = df.fill_null(strategy="forward", limit=1)  # 前向填充最多 1 个

row(df, df_forward, df_backward, df_mean, df_limit)
shape: (4, 2)
AB
f64f64
1.0null
null2.0
nullnull
4.0null
shape: (4, 2)
AB
f64f64
1.0null
1.02.0
1.02.0
4.02.0
shape: (4, 2)
AB
f64f64
1.02.0
4.02.0
4.0null
4.0null
shape: (4, 2)
AB
f64f64
1.02.0
2.52.0
2.52.0
4.02.0
shape: (4, 2)
AB
f64f64
1.0null
1.02.0
null2.0
4.0null

与 NULL 相关的参数#

表达式和 DataFrame 方法里有一批参数决定 NULL 怎么处理,它们散在各个方法里,很难一次记全。下面选出几个有代表性的,配合实际输出看。

sort 系列的 nulls_last 决定 NULL 放在末尾还是开头。

df = pl.DataFrame({"a": [1, 3, None, 2]})

# nulls_last=True 把 NULL 放末尾,False 放开头
df1 = df.with_columns(
    last=pl.col("a").sort(nulls_last=False),
    first=pl.col("a").sort(nulls_last=True),
)
df1
shape: (4, 3)
alastfirst
i64i64i64
1null1
312
null23
23null

concat_str 和 ewm_* 的 ignore_nulls 设为 True 时会跳过 NULL 计算:拼接时不会把 NULL 变成空字符串,指数平滑时也不会因为一个缺失就整段失效。

df = pl.DataFrame({
    "a": [1, None, 2, 3],
    "b": [None, 10, 20, 30],
})

df.select(
    ignore=pl.concat_str(('a', 'b'), separator='-', ignore_nulls=True),
    not_ignore=pl.concat_str(('a', 'b'), separator='-', ignore_nulls=False),
)
shape: (4, 2)
ignorenot_ignore
strstr
"1"null
"10"null
"2-20""2-20"
"3-30""3-30"

diff() 的 null_behavior 决定第一行的差分怎么处理:'drop' 从结果里去掉,'ignore' 留成 NULL。

前者让结果短一行,后者保持行数对齐,取哪个取决于后面要不要按行对齐。

df = pl.DataFrame({
  "a": [10, 12, 11, None, 7, 10]
})

df1 = df.select(pl.col("a").diff(null_behavior="drop"))
df2 = df.select(pl.col('a').diff(null_behavior='ignore'))
row(df, df1, df2)
shape: (6, 1)
a
i64
10
12
11
null
7
10
shape: (5, 1)
a
i64
2
-1
null
null
3
shape: (6, 1)
a
i64
null
2
-1
null
null
3

map_elements() 的 skip_nulls 决定是否把 NULL 传给用户函数:False 传 None 进去,函数要自己处理,True 则直接跳过这次调用,结果仍然是 NULL。后者通常更快,也更不容易在函数里遗漏分支。

df = pl.DataFrame({
  "a": ["a", "b", None, "c"]
})

def f(x):
    if x is None:
        return ":O"
    else:
        return x.upper()

df.with_columns(
    no_skip=pl.col("a").map_elements(f, skip_nulls=False, return_dtype=pl.String),
    skip=pl.col("a").map_elements(f, skip_nulls=True, return_dtype=pl.String),
)
shape: (4, 3)
ano_skipskip
strstrstr
"a""A""A"
"b""B""B"
null":O"null
"c""C""C"

从 NumPy 数组建表时,nan_to_null=True 会把 NaN 转成 NULL,pl.from_numpy() 与 pl.from_pandas() 也有同样的参数。

pandas 里 NaN 和 None 经常混用,所以转换之前先决定要不要统一,否则同一列里会同时出现“缺失值”和“非数”两种状态。

data = np.array([1.0, 2.0, float("nan"), 4.0])

df1 = pl.DataFrame({"a":data})
df2 = pl.DataFrame({"a":data}, nan_to_null=True)
row(df1, df2)
shape: (4, 1)
a
f64
1.0
2.0
NaN
4.0
shape: (4, 1)
a
f64
1.0
2.0
null
4.0

DataFrame.equals() 的 null_equal 决定 NULL 之间是否相等。默认 NULL 不等于 NULL,这是 SQL 的语义,但判断两张表“内容是否一样”时,通常希望 NULL 和 NULL 视为相同。

df1 = pl.DataFrame({"a": [None, 2, 3]})
df2 = pl.DataFrame({"a": [None, 2, 3]})

print(f"{df1.equals(df2, null_equal=True) = }")   # NULL 视为相等
print(f"{df1.equals(df2, null_equal=False) = }")  # NULL 视为不相等
df1.equals(df2, null_equal=True) = True
df1.equals(df2, null_equal=False) = False

DataFrame.join() 的 nulls_equal 决定 NULL 能否作为键列匹配。默认不匹配,理由和 SQL 一样:NULL 表示“不知道”,两个不知道的值不能断定相等。

Polars 2.0 把这个参数从 join_nulls 改名成了 nulls_equal。

df1 = pl.DataFrame({
    "key": [None, 2, 3],
    "val1": ["A", "B", "C"],
})
df2 = pl.DataFrame({
    "key": [None, 2, 4],
    "val2": ["x", "y", "z"],
})

df_join_null = df1.join(df2, on="key", how="left", nulls_equal=True)
df_not_join_null = df1.join(df2, on="key", how="left", nulls_equal=False)

row(df1, df2, df_join_null, df_not_join_null)
shape: (3, 2)
keyval1
i64str
null"A"
2"B"
3"C"
shape: (3, 2)
keyval2
i64str
null"x"
2"y"
4"z"
shape: (3, 3)
keyval1val2
i64strstr
null"A""x"
2"B""y"
3"C"null
shape: (3, 3)
keyval1val2
i64strstr
null"A"null
2"B""y"
3"C"null

how="full" 时情况稍复杂,因为两边都可能出现“没有匹配项”的行。

  • nulls_equal=True:左右两边的 NULL 视为一致,结果是 4 行,键为 NULL 和 2 的行对上了,左边的 3 和右边的 4 各自保留。

  • nulls_equal=False:NULL 之间不匹配,结果是 5 行,只有 2 对上,左边留下 NULL 和 3,右边留下 NULL 和 4。

df_join_null = df1.join(df2, on="key", how="full", nulls_equal=True)
df_not_join_null = df1.join(df2, on="key", how="full", nulls_equal=False)

row(df1, df2, df_join_null, df_not_join_null)
shape: (3, 2)
keyval1
i64str
null"A"
2"B"
3"C"
shape: (3, 2)
keyval2
i64str
null"x"
2"y"
4"z"
shape: (4, 4)
keyval1key_rightval2
i64stri64str
null"A"null"x"
2"B"2"y"
nullnull4"z"
3"C"nullnull
shape: (5, 4)
keyval1key_rightval2
i64stri64str
nullnullnull"x"
2"B"2"y"
nullnull4"z"
3"C"nullnull
null"A"nullnull

mean_horizontal 和 sum_horizontal 的 ignore_nulls 决定横向(按行)计算时要不要跳过 NULL。这个参数只影响横向聚合,不改变列的纵向统计。

df = pl.DataFrame({
  "a": [1, None, 3],
  "b": [4, 5, None]
})

df1 = df.mean_horizontal(ignore_nulls=True)
df2 = df.mean_horizontal(ignore_nulls=False)
row(df, df1, df2)
shape: (3, 2)
ab
i64i64
14
null5
3null
shape: (3,)
mean
f64
2.5
5.0
3.0
shape: (3,)
mean
f64
2.5
null
null

write_csv() 的 null_value 指定 NULL 写出成什么字符串。默认写成空,读回来还是 NULL。但和外系统交换数据时,对方可能要求显式的 NULL 或 NaN。

df = pl.DataFrame({"a": [1, None, 3]})

# NULL 写成字符串 "NULL"
df.write_csv("data/output.csv", null_value="NULL")

with open("data/output.csv") as f:
    print(f.read())
a
1
NULL
3

DataFrame.update() 的 include_nulls 决定要不要用来源里的 NULL 覆盖:True 连 NULL 一起更新,等于“整行替换”,False 只用非 NULL 的值,相当于“局部修补”。

df1 = pl.DataFrame({
  "a": [None, 2, 3, 4],
  "b": [10, 20, 30, 40],
})

df2 = pl.DataFrame({
  "a": [1, None, 3, 10]
})

df_inc_nulls = df1.update(df2, include_nulls=True)
df_not_inc_nulls = df1.update(df2, include_nulls=False)
row(df1, df2, df_inc_nulls, df_not_inc_nulls)
shape: (4, 2)
ab
i64i64
null10
220
330
440
shape: (4, 1)
a
i64
1
null
3
10
shape: (4, 2)
ab
i64i64
110
null20
330
1040
shape: (4, 2)
ab
i64i64
110
220
330
1040

read_csv / scan_csv 的 null_values 指定读取时哪些字符串算 NULL。CSV 里常见的 NA、N/A、null 都应列入,否则它们会原样变成字符串,后面的类型转换就会出错。

pl.read_csv('data/output.csv', null_values=['NULL'])
shape: (3, 1)
a
i64
1
null
3

NaN(Not a Number)是浮点型的特殊值,表示算不出定义结果的情况,比如 0/0。

它和 NULL 是两回事:NULL 是“没有值”,NaN 是“值是浮点数,但不是一个数”。因此 is_null() 认不出 NaN,sum() 也不会跳过它,一个 NaN 就能把整列的和变成 NaN。

判断 NaN 要用 is_nan(),填补要用 fill_nan()。如果业务上不区分两者,可以在读入或计算后先用 fill_nan(None) 把 NaN 转成 NULL,再按 NULL 的规则处理。

import numpy as np

df = pl.DataFrame(dict(
    A=[0.0, 1.0, 2.0, None, 3.0],      # 含 NULL
    B=[0.0, 1.0, 2.0, np.nan, 3.0],    # 含 NaN
))

# 直接求和:NaN 会把结果毁掉,NULL 会被忽略
df1 = df.select(pl.all().sum())

# 先把 NaN 变成 NULL 再求和
df2 = df.select(pl.all().fill_nan(None).sum())

row(df, df1, df2)
shape: (5, 2)
AB
f64f64
0.00.0
1.01.0
2.02.0
nullNaN
3.03.0
shape: (1, 2)
AB
f64f64
6.0NaN
shape: (1, 2)
AB
f64f64
6.06.0

Inf(无穷大)同样不是 NULL,而是浮点型的另一个特殊值。Polars 没有专门把它变成 NULL 的表达式,用 replace() 转换即可。

df.select(
    r1=(1 / pl.col("A")).mean(),                        # 混进 Inf 后平均值会被带坏
    r2=(1 / pl.col("A")).replace(np.inf, None).mean(),  # 先把 Inf 变 NULL 再求平均
)
shape: (1, 2)
r1r2
f64f64
inf0.611111