複数のデータフレームの結合#

複数のデータフレームを 1つにまとめる操作は、前処理の中心です。ログが日ごとに分かれていたり、指標がいくつかの次元テーブルに散っていたり、外部データを主表に組み込んだりと、最後は必ずどこかでつなぐことになります。

Polars の結合はいくつか種類があり、違いは「何を基準に突き合わせるか」「突き合わせられないときどうするか」にあります。この章では concat(位置で積む)、join(キーで突き合わせる)、join_asof(最も近いキーで突き合わせる)、join_where(条件で突き合わせる)と、その場で行・列を足すメソッドを扱います。

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

concat#

pl.concat() は複数のデータフレームを縦または横に結合します。結合のしかたは how で指定します。

  • vertical / vertical_relaxed — 縦に結合する(relaxed は型を上位の型にそろえる)

  • horizontal — 横に結合する(行数が同じであること)

  • horizontal_extend — 横に結合し、足りない行は NULL で埋める

  • diagonal / diagonal_relaxed — 列が一部だけ重なる場合に、縦横両方向へ結合する

  • align — 共通のキー列で行をそろえてから結合する

次の 2つのデータフレームを使って、各方法を確認します。

df1 = pl.DataFrame({"x":[1, 2, 3], "y":[2, 3, 1]})
df2 = pl.DataFrame({"x":[6, 2, 1, 5], "y":[12, 3, 2, 4]})
row(df1, df2)
shape: (3, 2)
xy
i64i64
12
23
31
shape: (4, 2)
xy
i64i64
612
23
12
54

縦結合#

列名もデータ型も一致している場合は、vertical でそのまま縦に積めます。

pl.concat([df1, df2], how='vertical')
shape: (7, 2)
xy
i64i64
12
23
31
612
23
12
54

列名は同じでデータ型が違う場合は vertical_relaxed を使います。結果は上位の型にそろいます。次のコードでは df2 の x を Float64 にしてから結合しているので、x は Float64 になります。

pl.concat([
    df1, 
    df2.with_columns(pl.col('x').cast(pl.Float64))
    ], 
    how='vertical_relaxed')
shape: (7, 2)
xy
f64i64
1.02
2.03
3.01
6.012
2.03
1.02
5.04

どのデータフレームから来た行かを区別したいときは、結合する前にキー列を足しておきます。次のコードは df1 と df2 に key 列を付けてから縦に積みます。

data = {"A":df1, "B":df2}
pl.concat([
    df.select(pl.lit(key).alias("key"), pl.all()) 
    for key, df in data.items()
])
shape: (7, 3)
keyxy
stri64i64
"A"12
"A"23
"A"31
"B"612
"B"23
"B"12
"B"54

横結合#

列名が違うデータフレームを横に並べるには horizontal を使います。行数をそろえる必要があるので、ここでは列名に接尾辞を付けて区別します。

Polars 2.0 では horizontal は行数が違うとエラーになります。短いほうを NULL で埋めたいときは horizontal_extend を指定します。

# 列名に 1 / 2 を付けて横に並べる(df2 のほうが 1 行多いので horizontal_extend)
pl.concat(
    [
        df1.rename(lambda name: f"{name}1"),
        df2.select(pl.all().name.suffix("2")),
    ],
    how="horizontal_extend",
)
shape: (4, 4)
x1y1x2y2
i64i64i64i64
12612
2323
3112
nullnull54

一部の列名だけが重なる場合は diagonal か diagonal_relaxed を使います。diagonal_relaxed は型を上位の型にそろえます。次のコードでは df1 に u、df2 に v を足し、重なる列は縦に、重ならない列は NULL で埋めて結合します。

縦と横結合#

dfs = [
    df1.with_columns(u=pl.col('x') + pl.col('y')),
    df2.with_columns(v=pl.col('x') * pl.col('y'))
]
pl.concat(dfs, how='diagonal')
shape: (7, 4)
xyuv
i64i64i64i64
123null
235null
314null
612null72
23null6
12null2
54null20

整列結合#

align は diagonal に似ていますが、共通の列をキーとして行をそろえてから結合します。次の例では x と y をキーにしているので、x=1, y=2 の行は u=3 と v=2 が 1行に統合されます。

pl.concat(dfs, how="align")
shape: (5, 4)
xyuv
i64i64i64i64
1232
2356
314null
54null20
612null72

pl.align_frames() を使うと、複数のデータフレームを指定した列でそろえられます。そろえたあとは、どのデータフレームも行数とキーの並びが一致します。

row(*pl.align_frames(*dfs, on=['x', 'y']))
shape: (5, 3)
xyu
i64i64i64
123
235
314
54null
612null
shape: (5, 3)
xyv
i64i64i64
122
236
31null
5420
61272

join#

join() は 2つのデータフレームをキーで突き合わせます。主な引数は次のとおりです。

df.join(
    other,                     # 結合する相手
    on=None,                   # 両方に共通するキー列(または式)
    how="inner",               # 結合方法
    left_on=None,              # 左だけのキー列
    right_on=None,             # 右だけのキー列
    suffix="_right",           # 重複した列名に付ける接尾辞
    validate="m:m",            # キーの対応関係の検証("m:m" / "m:1" / "1:m" / "1:1")
    nulls_equal=False,         # NULL 同士を一致とみなすか(2.0 で join_nulls から改名)
    coalesce=None,             # キー列を 1 列にまとめるか
    maintain_order=None,       # 行順を保つか("left" / "right" / "none" など)
)

how の種類は次のとおりです。

  • inner — 両方に一致する行だけ返す

  • left — 左の全行と、右の一致する行を返す

  • right — 右の全行と、左の一致する行を返す

  • full — どちらかに一致する行をすべて返す

  • semi — 左のうち右に一致する行を、左の列だけで返す

  • anti — 左のうち右に一致しない行を返す

Polars 2.0 では collect() が既定でストリーミングエンジンを使うため、join の行順は保証されません。順序に意味があるときは maintain_order を指定するか、最後に sort() してください。

df_left = pl.DataFrame({
    "id": [1, 2, 3, 4],
    "name": ["Alice", "Bob", "Charlie", "David"]
})

df_right = pl.DataFrame({
    "id": [3, 4, 5],
    "age": [23, 30, 40]
})

inner#

両方のデータフレームに存在する id を基準に内部結合します。次の例では、id が 3と4に一致する行だけが返りました。

row(
    df_left, df_right, 
    df_left.join(df_right, on="id", how="inner")    
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (2, 3)
idnameage
i64stri64
3"Charlie"23
4"David"30

left と right#

left は左の全行を残します。次の例では id が 1と2の行に対応するデータが右にないので、age は NULL になります。right はその逆です。

row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="left")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (4, 3)
idnameage
i64stri64
1"Alice"null
2"Bob"null
3"Charlie"23
4"David"30
row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="right")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (3, 3)
nameidage
stri64i64
"Charlie"323
"David"430
null540

full#

full は両方の全行を返します。左右どちらから来たかを区別するため、結合キーは 2列(id と id_right)になります。id が 1と2は左だけに、5は右だけに存在することが分かります。

row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="full")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (5, 4)
idnameid_rightage
i64stri64i64
1"Alice"nullnull
2"Bob"nullnull
3"Charlie"323
4"David"430
nullnull540

coalesce=True を指定すると、この 2つのキー列が 1列にまとまります。

row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="full", coalesce=True)
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (5, 3)
idnameage
i64stri64
1"Alice"null
2"Bob"null
3"Charlie"23
4"David"30
5null40

semi と anti#

semi は右に存在する行だけを、anti は右に存在しない行だけを返します。どちらも右側の列は含みません。

row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="semi")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (2, 2)
idname
i64str
3"Charlie"
4"David"
row(
    df_left, df_right,
    df_left.join(df_right, on="id", how="anti")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (2, 2)
idname
i64str
1"Alice"
2"Bob"

cross#

cross は 2つのデータフレームのデカルト積を返します。左の全行と右の全行をすべて組み合わせるため、on は指定しません。

row(
    df_left, df_right,
    df_left.join(df_right, how="cross")
)
shape: (4, 2)
idname
i64str
1"Alice"
2"Bob"
3"Charlie"
4"David"
shape: (3, 2)
idage
i64i64
323
430
540
shape: (12, 4)
idnameid_rightage
i64stri64i64
1"Alice"323
1"Alice"430
1"Alice"540
2"Bob"323
2"Bob"430
…………
3"Charlie"430
3"Charlie"540
4"David"323
4"David"430
4"David"540

validate#

結合キーの対応関係は 4通りに分類できます。

  • 1:1 — 左右ともキーがユニーク(例: 社員 ID と社員証番号)

  • 1:m — 左がユニーク、右は重複可(例: 部署と所属社員)

  • m:1 — 左は重複可、右がユニーク(例: 注文明細と商品マスタ)

  • m:m — 両方に重複を許す(既定)

一致する行が複数あると、既定では**すべての組み合わせ(デカルト積)**が結果に入ります。validate を指定すると、想定した関係を強制でき、違反したときにエラーになります。

次の 2つのデータフレームには、id == 3 の行がそれぞれ 2つあります。

df_left = pl.DataFrame({
    "id": [1, 2, 3, 4, 3],
    "name": ["Alice", "Bob", "Charlie", "David", "charlie"]
})

df_right = pl.DataFrame({
    "id": [3, 4, 5, 3],
    "age": [23, 30, 40, 46]
})

そのまま id で結合すると、id == 3 の行は 2 × 2 = 4行に増えます。

df_left.join(df_right, on="id")
shape: (5, 3)
idnameage
i64stri64
3"Charlie"23
3"Charlie"46
4"David"30
3"charlie"23
3"charlie"46

validate="1:1" を指定すると、キーが一対一でない時点でエラーになり、データの重複に気づけます。

%%capture_except
df_left.join(df_right, on="id", validate='1:1')
ComputeError: join keys did not fulfill 1:1 validation

update#

DataFrame.update() は、キーが一致する行の列を、別のデータフレームの値で上書きします。次のコードは id が一致する行の x と y を df_update の値に置き換えます。

df = pl.DataFrame({
    "id": [1, 2, 3, 4],
    "x": [10, 20, 30, 40],
    "y": [40, 30, 20, 10]
})

df_update = pl.DataFrame({
    "id": [1, 3],
    "x": [100, 300],
    "y": [-10, -20],
})

df_res = df.update(df_update, on='id')
row(df, df_update, df_res)
shape: (4, 3)
idxy
i64i64i64
11040
22030
33020
44010
shape: (2, 3)
idxy
i64i64i64
1100-10
3300-20
shape: (4, 3)
idxy
i64i64i64
1100-10
22030
3300-20
44010

join_asof#

join_asof は、時間や数値のような連続値で「近い行」を突き合わせる結合です。完全一致ではなく、片方の値に最も近い行を探すときに使います。時系列データの前処理でよく出てきます。

  • 片方がその時点のスナップショットで、もう片方が最も近い時点の値を持つ場合

  • 「前方一致」「後方一致」のように、探す向きを決められる場合

import polars as pl

df1 = pl.DataFrame(
    {
        "time": [1, 5, 10, 15, 20],
        "event": ["A", "B", "C", "D", "E"],
    }
)

df2 = pl.DataFrame(
    {
        "time": [2, 6, 12, 18],
        "price": [100, 105, 110, 115],
    }
)

result = df1.join_asof(df2, on="time", strategy="backward")
row(df1, df2, result)
shape: (5, 2)
timeevent
i64str
1"A"
5"B"
10"C"
15"D"
20"E"
shape: (4, 2)
timeprice
i64i64
2100
6105
12110
18115
shape: (5, 3)
timeeventprice
i64stri64
1"A"null
5"B"100
10"C"105
15"D"110
20"E"115

df1 の各行に対して、df2 の中で time が過去または同時刻で最も近い行を結合します。strategy="backward" がその指定で、"forward" にすると未来の値を選びます。

join_where#

join_where() は、列同士の比較条件をそのまま結合条件にできます。次の例では、df1 の time が df2 の time_span の範囲に入っている行を結合します。条件を複数渡すと、すべてを満たす行だけが残ります。

df1 = pl.DataFrame(
    {
        "id": [100, 101, 102],
        "time": [120, 140, 160],
    }
)
df2 = pl.DataFrame(
    {
        "t_id": [404, 498, 676, 742],
        "time_span": [(100, 110), (110, 130), (90, 100), (150, 170)],
    }
)
df_res = df1.join_where(
    df2,
    pl.col('time') >= pl.col('time_span').list.get(0),
    pl.col('time') <= pl.col('time_span').list.get(1)
)
row(df1, df2, df_res)
shape: (3, 2)
idtime
i64i64
100120
101140
102160
shape: (4, 2)
t_idtime_span
i64list[i64]
404[100, 110]
498[110, 130]
676[90, 100]
742[150, 170]
shape: (2, 4)
idtimet_idtime_span
i64i64i64list[i64]
100120498[110, 130]
102160742[150, 170]

ブール演算や is_between() も使えます。

df_res1 = df1.join_where(
    df2,
    (pl.col('time') >= pl.col('time_span').list.get(0)) & (pl.col('time') <= pl.col('time_span').list.get(1))
)

df_res2 = df1.join_where(
    df2,
    pl.col('time').is_between(pl.col('time_span').list.get(0), pl.col('time_span').list.get(1))
)
row(df_res1, df_res2)
shape: (2, 4)
idtimet_idtime_span
i64i64i64list[i64]
100120498[110, 130]
102160742[150, 170]
shape: (2, 4)
idtimet_idtime_span
i64i64i64list[i64]
100120498[110, 130]
102160742[150, 170]

join_where() は内部で 2つのデータフレームのデカルト積を作ってから条件で絞るため、行数が多いと非常に遅くなります。次の例は、2つの点群から距離が 0.01 未満のペアを探すもので、計算量は \(N^2\) に比例します。

n = 4000
np.random.seed(42)
df1 = pl.DataFrame(dict(
    x1=np.random.normal(size=n),
    y1=np.random.normal(size=n),
))
df2 = pl.DataFrame(dict(
    x2=np.random.normal(size=n),
    y2=np.random.normal(size=n),
))
%%time
df_res1 = (
    df1
    .join_where(
        df2, 
        (pl.col('x2') - pl.col('x1'))**2 + (pl.col('y2') - pl.col('y1'))**2 < 0.0001
    )
    .sort('x1', 'y1')
)
CPU times: total: 125 ms
Wall time: 19.4 ms

こういう処理は SciPy の KDTree を使うと速くなります。query_ball_tree() で範囲内の点のペアを求め、行番号をキーに df1 と df2 を結合します。

from scipy.spatial import KDTree
%%time
tree1 = KDTree(df1.to_numpy())
tree2 = KDTree(df2.to_numpy())
res = tree1.query_ball_tree(tree2, 0.01)

df_pair = (
    pl.DataFrame(pl.Series('index2', res, dtype=pl.List(pl.UInt32))) # resをデータフレームに変換
    .with_row_index('index1')
    .filter(pl.col('index2').list.len() > 0) # 範囲内の点が存在する行のみを残す
    .explode('index2') # リストを展開して1行1ペアにする
)
df_res2 = (
    df1
    .with_row_index('index1')
    .join(df_pair, on='index1')
    .join(
        df2.with_row_index('index2'), on='index2')
    .drop('index1', 'index2')
    .sort('x1', 'y1')
)
CPU times: total: 15.6 ms
Wall time: 13.2 ms
row(df_res1, df_pair, df_res2)
shape: (420, 4)
x1y1x2y2
f64f64f64f64
-2.065083-0.70744-2.05916-0.70436
-1.7690760.817306-1.7684150.825853
-1.523187-0.501234-1.514414-0.501647
-1.519346-0.035096-1.527461-0.03116
-1.4785220.932435-1.4823970.94037
…………
1.669070.3888191.6785740.385821
1.696456-0.0387431.692313-0.037887
1.7349370.1429911.7379850.135518
1.831459-0.5327251.831177-0.523104
1.9541570.5682731.9489630.563404
shape: (420, 2)
index1index2
u32u32
101832
102789
411502
44739
452018
……
39311196
39351035
39641214
3978715
39941588
shape: (420, 4)
x1y1x2y2
f64f64f64f64
-2.065083-0.70744-2.05916-0.70436
-1.7690760.817306-1.7684150.825853
-1.523187-0.501234-1.514414-0.501647
-1.519346-0.035096-1.527461-0.03116
-1.4785220.932435-1.4823970.94037
…………
1.669070.3888191.6785740.385821
1.696456-0.0387431.692313-0.037887
1.7349370.1429911.7379850.135518
1.831459-0.5327251.831177-0.523104
1.9541570.5682731.9489630.563404

res は二重リストで、tree1 の各点に対して、tree2 の中で距離 0.01 以内にある点の番号が入っています。次のコードは先頭 50点について、空でないものだけを表示します。

for i, index2 in enumerate(res[:50]):
    if index2:
        print(i, index2)
10 [1832, 2789]
41 [1502]
44 [739]
45 [2018]

extend と vstack#

DataFrame.extend() と DataFrame.vstack() は、既存のデータフレームに別のデータフレームの行を足すメソッドです。どちらも非推奨で、現在は pl.concat() を使うのが推奨です。ここでは挙動の違いだけ押さえておきます。

  • extend() — 元のデータフレームをその場で変更します(変更後のデータフレームも返します)。

  • vstack() — in_place で、その場で変更するか新しいデータフレームを返すかを選べます。

df1 = pl.DataFrame({"a": [1, 2], "b": [3, 4]})
df2 = pl.DataFrame({"a": [5, 6], "b": [7, 8]})

# clone()を使って元のデータフレームを複製
df_extend = df1.clone()
df_extend.extend(df2)  # extendで直接変更

df_vstack = df1.clone()
df_vstack.vstack(df2, in_place=True)  # vstackでその場で変更
row(df_extend, df_vstack)
shape: (4, 2)
ab
i64i64
13
24
57
68
shape: (4, 2)
ab
i64i64
13
24
57
68

両者の違いはメモリの持ち方にあります。

extend() は、追加するデータを元のメモリの末尾にコピーします。結合後のメモリは連続しますが、再配置が起きるので、ループの中で何度も呼ぶのは得策ではありません。

vstack() は 2つのデータフレームのメモリをそのまま残し、リンクでつなぎます。結果のメモリは不連続になり、あとで計算するときに遅くなることがあります。ループで vstack() して最後に rechunk() する、という使い方が定番でした。

いまは pl.concat() を使い、必要なら最後に rechunk() してください。

print(f'{df_extend.n_chunks() = }')           # extend後のチャンク数
print(f'{df_vstack.n_chunks() = }')          # vstack後のチャンク数
print(f'{df_vstack.rechunk().n_chunks() = }')  # rechunk後のチャンク数
df_extend.n_chunks() = 1
df_vstack.n_chunks() = 2
df_vstack.rechunk().n_chunks() = 1

hstack#

DataFrame.hstack() は 2つのデータフレームを横に並べます。in_place=True を指定すると、元のデータフレームに列を追加します。これも非推奨です。新しいコードでは pl.concat(..., how="horizontal") か with_columns() を使ってください。

df1 = pl.DataFrame({"a": [1, 2], "b": [3, 4]})
df2 = pl.DataFrame({"c": [5, 6], "d": [7, 8]})

# in-place操作なし(新しいデータフレームを返す)
df3 = df1.hstack(df2)

# in-place操作あり
df1.hstack(df2, in_place=True)
row(df3, df1)
shape: (2, 4)
abcd
i64i64i64i64
1357
2468
shape: (2, 4)
abcd
i64i64i64i64
1357
2468

merge_sorted#

merge_sorted() は、あらかじめキー列でソート済みの 2つのデータフレームを縦に結合し、キーの順序を保ったまま返します。入力がソート済みであることが前提です。

df1 = pl.DataFrame({"a": [3, 1, 5], "b": [6, 4, 2]})
df2 = pl.DataFrame({"a": [6, 2, 4], "b": [7, 5, 3]})

# "a" 列でソートしてからマージ
df3 = df1.sort("a").merge_sorted(df2.sort("a"), key="a")

# "b" 列でソートしてからマージ
df4 = df1.sort("b").merge_sorted(df2.sort("b"), key="b")

row(df3, df4)
shape: (6, 2)
ab
i64i64
14
25
36
43
52
67
shape: (6, 2)
ab
i64i64
52
43
14
25
36
67