NULL 処理#

NULL はデータにつきものです。1行に値が 1つ欠けるだけで、集約の結果も結合の結果も、型の判定まで変わってしまいます。Polars は NULL と NaN をはっきり区別し、「NULL に出会ったらどうするか」を決める引数も数多く用意しています。

この章では、まず NULL が計算に参加したときの規則を確認し、次に検出・削除・補完の関数を見て、最後に各メソッドに散らばっている NULL 関連の引数をまとめて確認します。

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

NULL に関する計算#

Polars は欠損を NULL として扱い、値とは別に「どの行が NULL か」をビットマスクで管理しています。Python の None は、データフレームに変換するときに NULL になります。基本ルールは 2つです。

  • ❶ 要素ごとの演算では、どちらかが NULL なら結果も NULL になります。

  • ❷ sum() や mean() などの集計では、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 を扱うための関数と式をまとめます。

  • drop_nulls — NULL を含む行を落とします。列ごとに要素数が変わるため、表の形を保ちたいときは ❶ のように implode() でリストにまとめます。

  • fill_null — NULL を指定した値で埋めます。

  • null_count — 列ごとの NULL の個数を数えます。

  • has_nulls — 列に NULL が含まれるかを判定します。

  • is_null — 要素ごとに NULL かどうかを返します。

  • is_not_null — 要素ごとに NULL でないかどうかを返します。

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 を飛ばして計算します。

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 なら呼び出し自体を飛ばします。

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 が混在しがちです。変換の前に、どちらへ統一するかを決めておきます。統一しないと、同じ列に「欠けている」と「数ではない」の 2つの状態が同居することになります。

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 の意味論です)。ただし「2つの表の中身が同じか」を判定したいときは、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 を結合キーとして一致とみなすかどうかを決めます。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 処理#

NaN(Not a Number)は浮動小数点型の特殊な値で、0/0 のように計算結果が定義できない場合を表します。

NULL とは別物です。NULL は「値が無い」、NaN は「値は浮動小数点数だが、数ではない」という状態です。is_null() では NaN を検出できませんし、sum() も NaN を飛ばしません。1つの NaN で列全体の合計が NaN になります。

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 に直接置き換える専用の式はないので、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