Databricks Certified Data Engineer Associate 教科書
第3章 データ変換とモデリング(Data Transformation and Modeling, 22%)
🎯 この節の学習目標
withColumn / withColumnRenamed / drop / select による列の追加・改名・削除・選択を実装できるfilter/where による行の絞り込みと、split による列の分割、explode による配列の行展開を実装できるfrom_json による JSON 文字列のパースを、SQL の同等操作と対比して説明できるシルバー層の整備では、列を「増やす・名前を直す・捨てる・選ぶ」操作が基本の道具になります。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(...) を呼んだだけで戻り値を受け取らなければ、何も変わりません。
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 は使えません)。
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引数は正規表現として解釈されます。ピリオドやパイプ(. や |)で区切る場合は \\. のようにエスケープが必要です。
半構造化データ(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(要素の位置番号付きで展開)もあります。
JSON 由来のデータでは、列の中に struct(構造体)が入っていることがあります(例:customer 列の中に name と address)。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;
ブロンズ層では、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 でスキーマを与えるのが基本です。
本節の操作を組み合わせると、半構造化データの整形は次のような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 の全フィールドを一気にトップレベル列へ平坦化(フラット化)できます。
✅ この節のまとめ
withColumn(追加/同名なら置き換え)、withColumnRenamed(改名)、drop(削除)、select(選択+式+改名)。DataFrame はイミュータブルで、変換は新しい DataFrame を返す。filter と where は同じ。列式では条件を括弧で囲み &/|/~ を使う。null 判定は isNull/isNotNull。split は文字列を配列にする(第2引数は正規表現)。要素は getItem(n) や [n] で取得。explode は配列を要素ごとの行に展開する。null/空配列の行は消えるので、残したいときは explode_outer。getField)で参照し、select("s.*") で平坦化できる。JSON 文字列はスキーマを与えて from_json で struct 化する。when().otherwise() は SQL の CASE WHEN に対応する。問1. 既存の DataFrame df に、unit_price * quantity を計算した新しい列 total を追加する正しいコードはどれか。
df.withColumn("total", F.col("unit_price") * F.col("quantity"))df.withColumnRenamed("total", "unit_price * quantity")df.select("total")df.total = df.unit_price * df.quantity正解:A
列の追加は withColumn("新列名", 式) です。Bの withColumnRenamed は既存列の改名専用で、式による計算はできません。Cの select("total") は存在しない列の選択となりエラーです。Dは pandas 風の代入構文で、イミュータブルな Spark DataFrame では機能しません。
問2. orders テーブルの items 列は商品の配列で、注文によっては空配列の場合がある。商品ごとに1行へ展開しつつ、商品が1つもない注文も行として残したい。適切な関数はどれか。
explodeexplode_outersplitflatten正解:B
explode は配列が null または空の行を結果から落とすため、「商品ゼロの注文も残す」要件には explode_outer(該当行を null 要素として残す)が必要です。Cの split は文字列を配列に分割する関数で、行展開はしません。Dの flatten は「配列の配列」を一段の配列に平坦化する関数であり、行への展開とは別物です。
問3. customer 列が struct<name:string, address:struct<city:string>> 型のとき、都市名を取り出す方法として正しいものはどれか。
F.col("customer.address.city")F.split(F.col("customer"), "address.city")F.explode(F.col("customer"))F.col("customer[address][city]")正解:A
struct のフィールドはドット記法でネストの深さに関係なく参照できます(getField("address").getField("city") でも同じです)。Bの split は文字列分割の関数で struct には使えません。Cの explode は配列(または map)を行に展開する関数で、struct には適用できません。Dのような角括弧の文字列指定は struct のフィールド参照構文ではありません。
問4. ブロンズテーブルの payload 列には JSON がただの文字列として格納されている。シルバー層で各フィールドを型付きの列として扱うための最初のステップとして最も適切なものはどれか。
withColumnRenamed で payload 列を json という名前に変更するfrom_json(col("payload"), schema) で struct 列に変換するexplode(col("payload")) で行に展開するcast("struct") で文字列を struct 型にキャストする正解:B
JSON 文字列を構造化するには、スキーマ(StructType または DDL 文字列)を与えて from_json でパースします。変換後はドット記法や select("data.*") で各フィールドにアクセスできます。Aは名前が変わるだけで中身は文字列のままです。Cの explode は配列用であり、文字列には適用できません。Dのような cast で文字列から任意の struct へ直接変換することはできません。