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

3-3. 列・行・テーブル構造の操作(追加・削除・分割・rename・filter・explode)

🎯 この節の学習目標

1. 列の操作:withColumn・withColumnRenamed・drop・select

シルバー層の整備では、列を「増やす・名前を直す・捨てる・選ぶ」操作が基本の道具になります。4つのメソッドの役割をまず整理します。

メソッド役割SQL での同等操作
withColumn("列名", 式)列の追加(同名列があれば置き換え)SELECT *, 式 AS 列名
withColumnRenamed("旧", "新")列の改名SELECT 旧 AS 新
drop("列名")列の削除SELECT で列挙しない(SQL には直接の DROP 列選択はない)
select(...)必要な列だけを選択(式や改名も同時に可)SELECT 列1, 列2, ...

💡 具体例:列操作の基本セット

from pyspark.sql import functions as F

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

df = (df
    # 追加:金額 = 単価 × 数量
    .withColumn("total_amount", F.col("unit_price") * F.col("quantity"))
    # 追加:条件分岐で区分列を作る(SQL の CASE 相当)
    .withColumn("size_class",
        F.when(F.col("total_amount") >= 10000, "large")
         .when(F.col("total_amount") >= 1000, "medium")
         .otherwise("small"))
    # 改名
    .withColumnRenamed("cust_id", "customer_id")
    # 削除(複数列まとめて)
    .drop("temp_col", "legacy_flag")
)

# select は「選ぶ」と同時に式・改名もできる
df_slim = df.select(
    "order_id",
    F.col("customer_id"),
    (F.col("unit_price") * F.col("quantity")).alias("total_amount"),
)
-- SQL 版:CASE 式と別名で同じ形を作る
SELECT
  order_id,
  cust_id AS customer_id,
  unit_price * quantity AS total_amount,
  CASE
    WHEN unit_price * quantity >= 10000 THEN 'large'
    WHEN unit_price * quantity >= 1000  THEN 'medium'
    ELSE 'small'
  END AS size_class
FROM silver.orders;

PySpark の when().otherwise() は SQL の CASE WHEN に対応します。otherwise を省略すると、どの条件にも合わない行は null になる点に注意してください。

📝 試験のポイント

withColumn は「追加」だけでなく、既存と同名の列を指定すると置き換え(上書き)になります。3-1 の型変換で withColumn("amount", col("amount").cast("double")) と書けたのはこのためです。また、DataFrame の変換は元の DataFrame を変更せず、新しい DataFrame を返す(イミュータブル)ことも押さえてください。df.withColumn(...) を呼んだだけで戻り値を受け取らなければ、何も変わりません。

2. 行の絞り込み:filter と where

filter()where()完全に同じ動作の別名です。SQL の WHERE 句に慣れた人向けに where が用意されています。条件は列式でも SQL 文字列でも書けます。

# 列式で書く(& = AND、| = OR、~ = NOT。各条件を括弧で囲む)
df.filter((F.col("status") == "completed") & (F.col("total_amount") > 1000))

# SQL 文字列でも書ける
df.where("status = 'completed' AND total_amount > 1000")

# null 判定は isNull / isNotNull(== None ではない)
df.filter(F.col("region").isNotNull())

# IN 相当は isin
df.filter(F.col("region").isin("east", "west"))

列式で複数条件を書くときは、各条件を括弧で囲み、&| を使うのが決まりです(Python の and/or は使えません)。

3. 列の分割:split

split(列, 区切りの正規表現) は文字列を分割して配列(array)を返す関数です。配列の要素は getItem(n) または添字 [n](0始まり)で取り出します。

💡 具体例:氏名列を姓と名に分割する

from pyspark.sql import functions as F

# full_name = "Yamada Taro" のような列を分割
df = (df
    .withColumn("name_parts", F.split(F.col("full_name"), " "))
    .withColumn("last_name",  F.col("name_parts").getItem(0))
    .withColumn("first_name", F.col("name_parts").getItem(1))
    .drop("name_parts")
)
-- SQL 版:split と添字アクセス(SQL の配列添字は 0 始まり)
SELECT
  split(full_name, ' ')[0] AS last_name,
  split(full_name, ' ')[1] AS first_name
FROM silver.customers;

第2引数は正規表現として解釈されます。ピリオドやパイプ(.|)で区切る場合は \\. のようにエスケープが必要です。

4. 配列の行展開:explode と explode_outer

半構造化データ(JSON など)を取り込むと、1行の中に配列が入っていることがよくあります(例:1つの注文に複数の商品を含む items 配列)。explode は配列の要素ごとに行を複製して展開し、「1行に配列」を「要素ごとに1行」へ変換します。

💡 具体例:注文内の商品配列を行に展開する

from pyspark.sql import functions as F

# orders: order_id | items(array<struct<sku:string, qty:int>>)
df_items = (orders
    .select("order_id", F.explode(F.col("items")).alias("item"))
    .select(
        "order_id",
        F.col("item.sku").alias("sku"),
        F.col("item.qty").alias("qty"),
    )
)
-- SQL 版:explode をそのまま SELECT に書ける
SELECT
  order_id,
  item.sku,
  item.qty
FROM orders,
  LATERAL explode(items) AS t(item);

-- より簡潔に、SELECT 句内で使う書き方も可能
SELECT order_id, explode(items) AS item FROM orders;

展開前が3注文で items がそれぞれ 2, 1, 3 要素なら、展開後は 6 行になります。行数が変わる操作なので、後続の集計(3-4)の粒度が「注文単位」から「商品明細単位」に変わることを意識してください。

📝 試験のポイント

explode配列が null または空の行を結果から落とします。「商品が1つもない注文も行として残したい」場合は explode_outer を使うと、その行が null 要素として1行残ります。「explode したら行数が入力より減った/一部の ID が消えた」という状況の原因と対策(explode_outer)は、選択肢の識別ポイントになります。関連関数として posexplode(要素の位置番号付きで展開)もあります。

5. ネスト構造の扱い:struct のドット参照と from_json

5-1. struct 列はドットで掘る

JSON 由来のデータでは、列の中に struct(構造体)が入っていることがあります(例:customer 列の中に nameaddress)。struct のフィールドはドット記法でそのまま参照できます。

# customer: struct<name:string, address:struct<city:string, zip:string>>
df.select(
    F.col("customer.name"),          # ドット記法(ネストも掘れる)
    F.col("customer.address.city"),
    F.col("customer").getField("name"),  # getField でも同じ
)
-- SQL でも同じくドット記法
SELECT customer.name, customer.address.city FROM silver.orders;

5-2. from_json:JSON 文字列を struct に変換する

ブロンズ層では、JSON がただの文字列列として入っていることがあります。この場合はスキーマを指定して from_json でパースすると、struct 列としてドット参照できるようになります。

💡 具体例:JSON 文字列列のパース

from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, StringType, IntegerType

# payload 列 = '{"device":"sensor-1","temp":23,"loc":{"city":"osaka"}}'
schema = StructType([
    StructField("device", StringType()),
    StructField("temp", IntegerType()),
    StructField("loc", StructType([StructField("city", StringType())])),
])

df = (raw
    .withColumn("data", F.from_json(F.col("payload"), schema))
    .select(
        F.col("data.device").alias("device"),
        F.col("data.temp").alias("temp"),
        F.col("data.loc.city").alias("city"),
    )
)
-- SQL 版:スキーマは DDL 文字列で指定する
SELECT
  parsed.device,
  parsed.temp,
  parsed.loc.city
FROM (
  SELECT from_json(payload,
    'device STRING, temp INT, loc STRUCT<city: STRING>') AS parsed
  FROM bronze.events
);

スキーマ指定に対して JSON 側にないフィールドは null になり、パースに失敗した行も null になります。なお SQL では、JSON 文字列に対して payload:device のようなコロン記法(JSON パス)で場当たり的に値を取り出す方法もありますが、シルバー層で型を確定させるなら from_json でスキーマを与えるのが基本です。

6. まとめて使う:ブロンズ→シルバーの構造整形

本節の操作を組み合わせると、半構造化データの整形は次のような1本のパイプラインになります。

df_silver = (spark.read.table("bronze.events_raw")
    .withColumn("data", F.from_json("payload", schema))       # JSON文字列→struct
    .select("event_id", "ingest_time", "data.*")              # structを平坦化
    .filter(F.col("device").isNotNull())                      # 行の絞り込み
    .withColumn("readings", F.explode_outer("readings"))      # 配列→行展開
    .withColumnRenamed("temp", "temperature_c")               # 命名の標準化
    .drop("debug_info")                                       # 不要列の削除
)

select("data.*") のように struct 名に .* を付けると、struct の全フィールドを一気にトップレベル列へ平坦化(フラット化)できます。

✅ この節のまとめ

練習問題

問1. 既存の DataFrame df に、unit_price * quantity を計算した新しい列 total を追加する正しいコードはどれか。

  1. df.withColumn("total", F.col("unit_price") * F.col("quantity"))
  2. df.withColumnRenamed("total", "unit_price * quantity")
  3. df.select("total")
  4. df.total = df.unit_price * df.quantity
解答と解説を見る

正解:A

列の追加は withColumn("新列名", 式) です。Bの withColumnRenamed は既存列の改名専用で、式による計算はできません。Cの select("total") は存在しない列の選択となりエラーです。Dは pandas 風の代入構文で、イミュータブルな Spark DataFrame では機能しません。

問2. orders テーブルの items 列は商品の配列で、注文によっては空配列の場合がある。商品ごとに1行へ展開しつつ、商品が1つもない注文も行として残したい。適切な関数はどれか。

  1. explode
  2. explode_outer
  3. split
  4. flatten
解答と解説を見る

正解:B

explode は配列が null または空の行を結果から落とすため、「商品ゼロの注文も残す」要件には explode_outer(該当行を null 要素として残す)が必要です。Cの split は文字列を配列に分割する関数で、行展開はしません。Dの flatten は「配列の配列」を一段の配列に平坦化する関数であり、行への展開とは別物です。

問3. customer 列が struct<name:string, address:struct<city:string>> 型のとき、都市名を取り出す方法として正しいものはどれか。

  1. F.col("customer.address.city")
  2. F.split(F.col("customer"), "address.city")
  3. F.explode(F.col("customer"))
  4. F.col("customer[address][city]")
解答と解説を見る

正解:A

struct のフィールドはドット記法でネストの深さに関係なく参照できます(getField("address").getField("city") でも同じです)。Bの split は文字列分割の関数で struct には使えません。Cの explode は配列(または map)を行に展開する関数で、struct には適用できません。Dのような角括弧の文字列指定は struct のフィールド参照構文ではありません。

問4. ブロンズテーブルの payload 列には JSON がただの文字列として格納されている。シルバー層で各フィールドを型付きの列として扱うための最初のステップとして最も適切なものはどれか。

  1. withColumnRenamedpayload 列を json という名前に変更する
  2. スキーマを定義し、from_json(col("payload"), schema) で struct 列に変換する
  3. explode(col("payload")) で行に展開する
  4. cast("struct") で文字列を struct 型にキャストする
解答と解説を見る

正解:B

JSON 文字列を構造化するには、スキーマ(StructType または DDL 文字列)を与えて from_json でパースします。変換後はドット記法や select("data.*") で各フィールドにアクセスできます。Aは名前が変わるだけで中身は文字列のままです。Cの explode は配列用であり、文字列には適用できません。Dのような cast で文字列から任意の struct へ直接変換することはできません。