複数のデータフレームの結合#
複数のデータフレームを 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)
|
shape: (4, 2)
|
縦結合#
列名もデータ型も一致している場合は、vertical でそのまま縦に積めます。
pl.concat([df1, df2], how='vertical')
| x | y |
|---|---|
| i64 | i64 |
| 1 | 2 |
| 2 | 3 |
| 3 | 1 |
| 6 | 12 |
| 2 | 3 |
| 1 | 2 |
| 5 | 4 |
列名は同じでデータ型が違う場合は vertical_relaxed を使います。結果は上位の型にそろいます。次のコードでは df2 の x を Float64 にしてから結合しているので、x は Float64 になります。
pl.concat([
df1,
df2.with_columns(pl.col('x').cast(pl.Float64))
],
how='vertical_relaxed')
| x | y |
|---|---|
| f64 | i64 |
| 1.0 | 2 |
| 2.0 | 3 |
| 3.0 | 1 |
| 6.0 | 12 |
| 2.0 | 3 |
| 1.0 | 2 |
| 5.0 | 4 |
どのデータフレームから来た行かを区別したいときは、結合する前にキー列を足しておきます。次のコードは 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()
])
| key | x | y |
|---|---|---|
| str | i64 | i64 |
| "A" | 1 | 2 |
| "A" | 2 | 3 |
| "A" | 3 | 1 |
| "B" | 6 | 12 |
| "B" | 2 | 3 |
| "B" | 1 | 2 |
| "B" | 5 | 4 |
横結合#
列名が違うデータフレームを横に並べるには 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",
)
| x1 | y1 | x2 | y2 |
|---|---|---|---|
| i64 | i64 | i64 | i64 |
| 1 | 2 | 6 | 12 |
| 2 | 3 | 2 | 3 |
| 3 | 1 | 1 | 2 |
| null | null | 5 | 4 |
一部の列名だけが重なる場合は 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')
| x | y | u | v |
|---|---|---|---|
| i64 | i64 | i64 | i64 |
| 1 | 2 | 3 | null |
| 2 | 3 | 5 | null |
| 3 | 1 | 4 | null |
| 6 | 12 | null | 72 |
| 2 | 3 | null | 6 |
| 1 | 2 | null | 2 |
| 5 | 4 | null | 20 |
整列結合#
align は diagonal に似ていますが、共通の列をキーとして行をそろえてから結合します。次の例では x と y をキーにしているので、x=1, y=2 の行は u=3 と v=2 が 1行に統合されます。
pl.concat(dfs, how="align")
| x | y | u | v |
|---|---|---|---|
| i64 | i64 | i64 | i64 |
| 1 | 2 | 3 | 2 |
| 2 | 3 | 5 | 6 |
| 3 | 1 | 4 | null |
| 5 | 4 | null | 20 |
| 6 | 12 | null | 72 |
pl.align_frames() を使うと、複数のデータフレームを指定した列でそろえられます。そろえたあとは、どのデータフレームも行数とキーの並びが一致します。
row(*pl.align_frames(*dfs, on=['x', 'y']))
shape: (5, 3)
|
shape: (5, 3)
|
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)
|
shape: (3, 2)
|
shape: (2, 3)
|
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)
|
shape: (3, 2)
|
shape: (4, 3)
|
row(
df_left, df_right,
df_left.join(df_right, on="id", how="right")
)
shape: (4, 2)
|
shape: (3, 2)
|
shape: (3, 3)
|
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)
|
shape: (3, 2)
|
shape: (5, 4)
|
coalesce=True を指定すると、この 2つのキー列が 1列にまとまります。
row(
df_left, df_right,
df_left.join(df_right, on="id", how="full", coalesce=True)
)
shape: (4, 2)
|
shape: (3, 2)
|
shape: (5, 3)
|
semi と anti#
semi は右に存在する行だけを、anti は右に存在しない行だけを返します。どちらも右側の列は含みません。
row(
df_left, df_right,
df_left.join(df_right, on="id", how="semi")
)
shape: (4, 2)
|
shape: (3, 2)
|
shape: (2, 2)
|
row(
df_left, df_right,
df_left.join(df_right, on="id", how="anti")
)
shape: (4, 2)
|
shape: (3, 2)
|
shape: (2, 2)
|
cross#
cross は 2つのデータフレームのデカルト積を返します。左の全行と右の全行をすべて組み合わせるため、on は指定しません。
row(
df_left, df_right,
df_left.join(df_right, how="cross")
)
shape: (4, 2)
|
shape: (3, 2)
|
shape: (12, 4)
|
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")
| id | name | age |
|---|---|---|
| i64 | str | i64 |
| 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)
|
shape: (2, 3)
|
shape: (4, 3)
|
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)
|
shape: (4, 2)
|
shape: (5, 3)
|
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)
|
shape: (4, 2)
|
shape: (2, 4)
|
ブール演算や 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)
|
shape: (2, 4)
|
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)
|
shape: (420, 2)
|
shape: (420, 4)
|
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)
|
shape: (4, 2)
|
両者の違いはメモリの持ち方にあります。
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)
|
shape: (2, 4)
|
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)
|
shape: (6, 2)
|