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)
|
shape: (4, 1)
|
shape: (1, 2)
|
处理 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)
|
shape: (1, 2)
|
shape: (5, 2)
|
shape: (1, 2)
|
shape: (1, 2)
|
shape: (5, 2)
|
coalesce() 依次看几个候选,返回第一个不是 NULL 的值,可以理解成“取第一个有效值”。下面的代码先看 A,再看 B * 2,两个都是 NULL 时用 100 作为默认值。
df.with_columns(
C=pl.coalesce("A", pl.col("B") * 2, 100)
)
| A | B | C |
|---|---|---|
| i64 | i64 | i64 |
| 1 | null | 1 |
| 2 | 3 | 2 |
| null | 5 | 10 |
| null | null | 100 |
| 5 | 7 | 5 |
interpolate() 用前后的值插补 NULL,适合时间序列这种相邻点相关的数据,method 决定插补方式。
线性插补(
method='linear',默认):前后两点连直线,适合变化平缓的量。最近邻插补(
method='nearest'):用最近的已有值,适合阶梯状变化或类别型数值。
下面的代码对所有列都做两种插补,分别加 _interp 和 _nearest 后缀。
df.with_columns(
pl.all().interpolate().name.suffix("_interp"),
pl.all().interpolate(method="nearest").name.suffix("_nearest"),
)
| A | B | A_interp | B_interp | A_nearest | B_nearest |
|---|---|---|---|---|---|
| i64 | i64 | f64 | f64 | i64 | i64 |
| 1 | null | 1.0 | null | 1 | null |
| 2 | 3 | 2.0 | 3.0 | 2 | 3 |
| null | 5 | 3.0 | 5.0 | 2 | 5 |
| null | null | 4.0 | 6.0 | 5 | 7 |
| 5 | 7 | 5.0 | 7.0 | 5 | 7 |
同样的功能在 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)
|
shape: (5, 2)
|
shape: (1, 2)
|
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)
|
shape: (4, 2)
|
shape: (4, 2)
|
shape: (4, 2)
|
shape: (4, 2)
|
与 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
| a | last | first |
|---|---|---|
| i64 | i64 | i64 |
| 1 | null | 1 |
| 3 | 1 | 2 |
| null | 2 | 3 |
| 2 | 3 | null |
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),
)
| ignore | not_ignore |
|---|---|
| str | str |
| "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)
|
shape: (5, 1)
|
shape: (6, 1)
|
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),
)
| a | no_skip | skip |
|---|---|---|
| str | str | str |
| "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)
|
shape: (4, 1)
|
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)
|
shape: (3, 2)
|
shape: (3, 3)
|
shape: (3, 3)
|
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)
|
shape: (3, 2)
|
shape: (4, 4)
|
shape: (5, 4)
|
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)
|
shape: (3,)
|
shape: (3,)
|
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)
|
shape: (4, 1)
|
shape: (4, 2)
|
shape: (4, 2)
|
read_csv / scan_csv 的 null_values 指定读取时哪些字符串算 NULL。CSV 里常见的 NA、N/A、null 都应列入,否则它们会原样变成字符串,后面的类型转换就会出错。
pl.read_csv('data/output.csv', null_values=['NULL'])
| 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)
|
shape: (1, 2)
|
shape: (1, 2)
|
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 再求平均
)
| r1 | r2 |
|---|---|
| f64 | f64 |
| inf | 0.611111 |