第3章 データ変換とモデリング / 想定学習時間:30〜40分 / 最終確認:2026年8月

3-2. DataFrameの結合(inner/left/broadcast/cross join・union)

🎯 この節の学習目標

1. 結合の基本形:join の種類

シルバー層のデータは複数のテーブルに分かれています(例:注文 orders と顧客 customers)。これらを突き合わせて1つの DataFrame にするのが結合(join)です。PySpark では df1.join(df2, 結合条件, 結合タイプ) の形で書きます。

orders = spark.read.table("silver.orders")
customers = spark.read.table("silver.customers")

# inner join(既定):両方に存在するキーの行だけ残る
df = orders.join(customers, on="customer_id", how="inner")

# left join:左(orders)は全行残り、相手がなければ null で埋まる
df = orders.join(customers, on="customer_id", how="left")
-- SQL 版
SELECT o.*, c.customer_name, c.region
FROM silver.orders o
INNER JOIN silver.customers c
  ON o.customer_id = c.customer_id;

SELECT o.*, c.customer_name
FROM silver.orders o
LEFT JOIN silver.customers c
  ON o.customer_id = c.customer_id;
結合タイプhow の指定残る行典型的な用途
inner"inner"(既定)両方にキーが存在する行のみマスタに存在する取引だけを分析対象にする
left(left outer)"left"左の全行+一致した右の列(不一致は null)全注文を残しつつ顧客属性を付与する
right(right outer)"right"右の全行+一致した左の列left の左右逆。実務では left に書き換えることが多い
full(full outer)"full"両方の全行(不一致側は null)2つのデータの差分・突合チェック
crosscrossJoin()全行×全行(直積)全組み合わせの生成(日付×店舗のカレンダー表など)。行数が爆発するため要注意
left semi"left_semi"右に一致がある左の行のみ(右の列は付かない)「存在チェック」でのフィルタ
left anti"left_anti"右に一致がない左の行のみ「マスタに存在しないレコード」の抽出

💡 具体例:複数キーでの結合と cross join

# 複数キー結合:リストで渡す
df = orders.join(returns, on=["order_id", "line_no"], how="left")

# 列名が左右で異なる場合は条件式で書く
df = orders.join(
    customers,
    orders["customer_id"] == customers["cust_id"],
    "inner"
)

# cross join:全組み合わせ(明示的に crossJoin を使う)
calendar = dates.crossJoin(stores)   # 日付 × 店舗の全組み合わせ

結合条件を on="customer_id" のように列名で渡すとキー列は1つにまとまりますが、条件式(==)で渡すと両方の DataFrame のキー列が残り、列名の曖昧さエラーの原因になります。条件式で結合した後は不要な側を drop するのが定石です。

2. broadcast join:小さいテーブルを配って高速化

通常の結合(shuffle join)では、同じキーの行を同じワーカーに集めるために、両方のテーブルをネットワーク越しに再配置(シャッフル)します。シャッフルはネットワーク転送とディスクI/Oを伴う高コストな処理です。

ここで片方のテーブルが十分小さい場合、小さいテーブル全体を全ワーカーにコピー(ブロードキャスト)してしまえば、大きいテーブルは動かさずその場で結合できます。これが broadcast join(ブロードキャスト結合)です。

shuffle join大テーブルも小テーブルも、キーごとに全ワーカー間で再配置 → ネットワーク転送が大量に発生
片方が小さいなら…
broadcast join小テーブルの全量を各ワーカーへ配布。大テーブルは移動ゼロでその場結合 → シャッフル回避

図:shuffle join と broadcast join のデータ移動の違い

💡 具体例:broadcast ヒントの付け方

from pyspark.sql.functions import broadcast

orders = spark.read.table("silver.orders")        # 数億行の大テーブル
regions = spark.read.table("silver.region_master") # 数百行の小テーブル

# 小さい側を broadcast() で包む
df = orders.join(broadcast(regions), on="region_code", how="left")
-- SQL ではヒント構文で指定
SELECT /*+ BROADCAST(r) */ o.*, r.region_name
FROM silver.orders o
LEFT JOIN silver.region_master r
  ON o.region_code = r.region_code;

実は明示しなくても、テーブルサイズが spark.sql.autoBroadcastJoinThreshold(既定 10MB)以下なら Spark が自動で broadcast join を選びます。この閾値の調整は 3-5 で扱います。

📝 試験のポイント

「大きなファクトテーブルと小さなディメンションテーブル(マスタ)の結合を高速化したい」→ 小さい側を broadcast するが正解の型です。理由まで問われたら「シャッフルを回避できるから」。逆に、大きいテーブルを broadcast するのは誤りです。全ワーカーのメモリに載せることになり、OOM(メモリ不足)を招きます。broadcast はドライバ経由で配布されるため、ドライバメモリにも負荷がかかる点も覚えておきましょう。データスキュー(偏り)対策として broadcast join が効く場面は 6-2 で再登場します。

3. union:縦方向の連結

join が「横に列を増やす」操作なら、union は「縦に行を積む」操作です。スキーマが同じ2つの DataFrame(例:2026年の注文と2025年の注文)を1つにまとめます。

df_all = df_2026.union(df_2025)

# 列の「順序」ではなく「名前」で対応付けたい場合
df_all = df_2026.unionByName(df_2025)

# 片方にしかない列を null で許容する場合
df_all = df_2026.unionByName(df_2025, allowMissingColumns=True)
-- SQL 版:UNION ALL は重複を残す、UNION は重複を除去する
SELECT * FROM sales_2026
UNION ALL
SELECT * FROM sales_2025;
操作対応付け重複行の扱い注意点
union()列の位置(名前は見ない)残す(SQL の UNION ALL 相当)列順が違うと黙って誤った対応付けになる
unionByName()列名残す列順の違いに安全。列集合が違う場合は allowMissingColumns=True
unionAll()列の位置残すDataFrame API では union の別名(同義)。非推奨で、union を使う

📝 試験のポイント

混同しやすいのが SQL と DataFrame API の差です。SQL の UNION は重複を除去し、UNION ALL は残します。一方 DataFrame API の union() は重複を除去しません(UNION ALL 相当)。かつて存在した unionAll() は現在 union() と同義の別名になっています。重複を除去したければ union() の後に distinct()dropDuplicates()(3-4)を明示的に呼びます。また、「列の順序が異なる2つの DataFrame を安全に連結する」→ unionByName() が正解の型です。

4. 結合の落とし穴:行の膨張と null キー

4-1. 重複キーによる行の膨張

結合は「1対1」とは限りません。キーが左に m 行・右に n 行あると、結果は m×n 行になります。たとえば注文テーブルに customer_id=101 が3行、顧客テーブルに誤って同じ顧客が2行入っていると、結合後はその顧客の注文が 3×2=6 行に「膨張」します。集計すると売上が2倍にカウントされる、という典型的な事故です。

💡 具体例:膨張の検知と予防

# 結合前:右テーブルのキーの一意性を確認する
customers.groupBy("customer_id").count().filter("count > 1").show()

# 一意でなければ先に重複排除(3-4 で詳述)してから結合する
customers_unique = customers.dropDuplicates(["customer_id"])
df = orders.join(customers_unique, on="customer_id", how="left")

# 結合後:行数が変わっていないか検算する
assert df.count() == orders.count()

「left join なのに行数が増えた」と感じたら、まず右側のキー重複を疑います。left join が保証するのは「左の行が消えない」ことだけで、「増えない」ことは保証しません。

4-2. null キーは一致しない

SQL の比較演算では NULL = NULL は真になりません。そのためキーが null の行は、どの行とも結合されません。inner join では null キーの行が黙って消え、left join では右側が全て null のまま残ります。「結合したのに件数が減った」「マスタの値が付かない行がある」ときは、キーの null を疑ってください(null 処理は 3-1 で学んだとおりです)。

# 結合前に null キーの件数を把握する
orders.filter(F.col("customer_id").isNull()).count()

# 要件に応じて:除外する / 代替キーで埋める / left join で残す
orders_valid = orders.filter(F.col("customer_id").isNotNull())

✅ この節のまとめ

練習問題

問1. 全ての注文レコードを保持しつつ、顧客マスタに存在する場合のみ顧客名を付与したい。顧客マスタに存在しない注文も分析対象に残す必要がある。適切な結合はどれか。

  1. orders.join(customers, "customer_id", "inner")
  2. orders.join(customers, "customer_id", "left")
  3. orders.join(customers, "customer_id", "left_semi")
  4. orders.crossJoin(customers)
解答と解説を見る

正解:B

「左(注文)の全行を残し、一致すれば右の列を付与、しなければ null」は left join そのものです。Aの inner ではマスタに存在しない注文が消えてしまい、要件に反します。Cの left semi は「右に一致がある左の行」だけを返すフィルタであり、顧客名の列は付与されないうえ不一致の注文も消えます。Dは全組み合わせの直積で、要件と無関係に行数が爆発します。

問2. 数億行のファクトテーブルと数百行の地域マスタの結合が遅い。最も効果的な改善策はどれか。

  1. ファクトテーブル側に broadcast() を付けて結合する
  2. 地域マスタ側に broadcast() を付けて結合し、シャッフルを回避する
  3. 結合を cross join に変更してから filter で絞り込む
  4. 両方のテーブルを collect() でドライバに集めて Python で結合する
解答と解説を見る

正解:B

broadcast join は「小さい側」を全ワーカーに配布することで、大テーブルのシャッフル(ネットワーク越しの再配置)を丸ごと回避します。Aは巨大テーブルを全ワーカーのメモリに配ることになり OOM を招く誤りです。Cの cross join は直積を作ってから絞る最悪の手で、計算量が桁違いに増えます。Dはドライバのメモリに全データを載せる操作で、分散処理の利点を捨てるうえドライバの OOM で失敗します。

問3. PySpark の DataFrame API における union() の説明として正しいものはどれか。

  1. SQL の UNION と同様に、連結後の重複行を自動的に除去する
  2. 列名に基づいて2つの DataFrame の列を対応付ける
  3. 列の位置に基づいて連結し、重複行は除去しない(SQL の UNION ALL 相当)
  4. unionAll() とは異なる結果を返す
解答と解説を見る

正解:C

DataFrame API の union() は列の位置で対応付け、重複を除去しません。重複を除きたい場合は distinct() を明示的に呼びます(Aは SQL の UNION との混同)。Bは unionByName() の説明です。Dについて、unionAll() は現在 union() の同義の別名であり、結果は同じです。

問4. 注文テーブル(100万行)と顧客テーブルを customer_id で left join したところ、結果が103万行になった。最も可能性の高い原因はどれか。

  1. left join は常に左の行数より多くの行を返す仕様である
  2. 顧客テーブルに customer_id が重複する行があり、一致した注文行が複数行に膨張した
  3. 注文テーブルの customer_id に null が含まれており、null 同士が結合された
  4. シャッフルのパーティション数が不足している
解答と解説を見る

正解:B

キーが左に m 行・右に n 行あると結合結果は m×n 行になるため、右(顧客)側のキー重複は行の膨張として現れます。left join が保証するのは左の行が「消えない」ことだけで、「増えない」ことではありません(Aは仕様の誤解)。Cについて、null キーはどの行とも一致しないため行は増えません。Dのパーティション数は性能に影響しても結果の行数は変えません。対策は、結合前に右側を dropDuplicates(["customer_id"]) 等で一意化することです。