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

3-1. メダリオンアーキテクチャとデータクリーニング(ブロンズ→シルバー)

🎯 この節の学習目標

1. メダリオンアーキテクチャとは

メダリオンアーキテクチャ(Medallion Architecture)は、レイクハウス上でデータを段階的に品質向上させていく設計パターンです。データをメダル(ブロンズ→シルバー→ゴールド)にたとえ、下流の層へ進むほど「よりきれいで、より使いやすい」データになります。この章(試験の22%を占めるデータ変換とモデリング)の土台となる考え方であり、第2章で取り込んだ生データがどこに着地し、どう加工されていくかの全体地図でもあります。

データソース業務システム / ログ / API / ファイル
取り込み(第2章:Auto Loader、COPY INTO など)
🥉 ブロンズ層(Bronze)生データをほぼそのまま保存。スキーマは緩く、履歴を保持
クリーニング・型変換・重複排除(本節と 3-4)
🥈 シルバー層(Silver)クリーニング済み・型が整った信頼できるデータ
結合・集計・ビジネスロジック(3-2〜3-4)
🥇 ゴールド層(Gold)集計済み・提供用。BI/分析/ML がそのまま使える形(3-6)

図:メダリオンアーキテクチャの3層構造とデータの流れ

データの状態主な処理主な利用者
ブロンズソースの生データそのまま(raw)。取り込み時刻やソースファイル名などのメタデータを付与することが多い取り込み(追記中心)。変換は最小限データエンジニア(再処理・監査の起点)
シルバークリーニング済み。null 処理・型統一・重複排除・命名の標準化が済んだ状態フィルタ、クレンジング、結合、正規化データエンジニア、データサイエンティスト
ゴールドビジネス単位で集計・整形された提供用データ集計、ディメンショナルモデリング、KPI 算出BI/分析チーム、業務ユーザー

📝 試験のポイント

「なぜブロンズに生のまま残すのか」が問われます。答えは再処理可能性(replayability)です。変換ロジックに誤りが見つかっても、ブロンズに生データが残っていれば、ソースに再アクセスせずシルバー以降を作り直せます。「ブロンズで積極的にクリーニングして容量を節約する」という選択肢は誤りです。また、各層の説明の対応付け(「クリーニング済みで分析の土台になる層はどれか」→シルバー)も定番です。

2. ブロンズ層を読み、シルバー層へ:全体の流れ

ここからは、ECサイトを模した架空の注文データ bronze.orders_raw を例に、ブロンズ→シルバーの変換を実装していきます。ブロンズには次のような「汚れ」が典型的に含まれます。

まずブロンズを読み込みます。PySpark と SQL のどちらでも同じテーブルを操作できます。

# PySpark:ブロンズテーブルを DataFrame として読む
df_bronze = spark.read.table("bronze.orders_raw")
df_bronze.printSchema()   # スキーマを確認して「汚れ」を把握する
-- SQL:同じテーブルをクエリで確認
SELECT * FROM bronze.orders_raw LIMIT 10;
DESCRIBE bronze.orders_raw;

3. null 処理:dropna・fillna・coalesce

null の扱いは「捨てる」「埋める」「代替値で補う」の3択です。どの列の null がビジネス上許容できないかを先に決めてから選びます。

手法PySparkSQL 相当使いどころ
捨てるdf.dropna(subset=[...])WHERE col IS NOT NULLキー列(注文IDなど)が null の行は分析に使えないので除外
固定値で埋めるdf.fillna(値, subset=[...])COALESCE(col, 既定値) / IFNULL数量の null を 0、区分の null を "unknown" にするなど
他列で補うcoalesce(col1, col2, ...)(関数)COALESCE(col1, col2, ...)「配送先がなければ請求先を使う」のような優先順位付きの補完

💡 具体例:null 処理の3パターン

from pyspark.sql import functions as F

df = spark.read.table("bronze.orders_raw")

# (1) キー列が null の行は削除(subset で対象列を限定するのが実務の基本)
df = df.dropna(subset=["order_id", "customer_id"])

# (2) 固定値で埋める:数量の null は 0、地域の null は "unknown"
df = df.fillna({"quantity": 0, "region": "unknown"})

# (3) coalesce:配送先住所がなければ請求先住所で補完
df = df.withColumn(
    "ship_address",
    F.coalesce(F.col("ship_address"), F.col("billing_address"))
)
-- SQL で同じことを行う場合
SELECT
  order_id,
  customer_id,
  COALESCE(quantity, 0)                      AS quantity,
  COALESCE(region, 'unknown')                AS region,
  COALESCE(ship_address, billing_address)    AS ship_address
FROM bronze.orders_raw
WHERE order_id IS NOT NULL
  AND customer_id IS NOT NULL;

df.dropna()引数なしで呼ぶと「いずれかの列が null の行をすべて削除」になり、意図せず大量の行が消えることがあります。subset で対象列を明示するのが安全です。

4. データ型の標準化:cast・to_date・to_timestamp

ブロンズでは JSON や CSV 由来の列が文字列型のまま入っていることが多く、シルバーでは正しい型に揃えるのが重要な仕事です。型が揃っていないと、比較・集計・結合のすべてで事故が起きます(例:文字列の "9""10" より大きいと判定される)。

💡 具体例:型変換の基本セット

from pyspark.sql import functions as F

df = (df
    # 文字列 → 数値(try_cast:変換できない値は null になる)
    .withColumn("amount", F.col("amount").try_cast("double"))
    .withColumn("quantity", F.col("quantity").try_cast("int"))
    # 文字列 → 日付/タイムスタンプ(フォーマットを明示)
    .withColumn("order_date", F.to_date(F.col("order_date"), "yyyy-MM-dd"))
    .withColumn("updated_at", F.to_timestamp(F.col("updated_at"),
                                             "yyyy-MM-dd HH:mm:ss"))
)
-- SQL 版:TRY_CAST と to_date / to_timestamp
SELECT
  TRY_CAST(amount AS DOUBLE)                              AS amount,
  TRY_CAST(quantity AS INT)                               AS quantity,
  to_date(order_date, 'yyyy-MM-dd')                       AS order_date,
  to_timestamp(updated_at, 'yyyy-MM-dd HH:mm:ss')         AS updated_at
FROM bronze.orders_raw;

Databricks Runtime 17.0 以降では ANSI モードが既定で有効になっており、cast は変換できない値(例:"abc" を数値へ)に対してエラー(CAST_INVALID_INPUT)で失敗します。不正値を null に落として処理を続けたい場合は try_cast(SQL では TRY_CAST(amount AS DOUBLE)、PySpark では F.col("amount").try_cast("double")F.expr("try_cast(amount AS double)"))を使います。その場合も「try_cast 後に null が増えていないか」を確認する習慣が品質を守ります(検証クエリのパターンは 3-6 で扱います)。

📝 試験のポイント

to_date日付(DATE 型)to_timestamp日時(TIMESTAMP 型)を返す、という対応を押さえてください。また「文字列の日付列を日付型に変換する最も適切な方法」を問う問題では、フォーマット文字列を指定した to_date(col, 'yyyy-MM-dd') のような選択肢が正解側になります。substring で切り出して連結し直すような選択肢は、動いたとしても標準的な方法ではありません。

5. 文字列の整形:trim・lower・regexp_replace

文字列の表記ゆれは、結合キーの不一致や重複判定の失敗を招きます。シルバーに入れる前に代表的な整形を済ませます。

from pyspark.sql import functions as F

df = (df
    .withColumn("city", F.trim(F.col("city")))            # 前後の空白を除去
    .withColumn("city", F.lower(F.col("city")))           # 小文字に統一
    .withColumn("email", F.trim(F.lower(F.col("email"))))
    # 電話番号からハイフン等の記号を除去(正規表現)
    .withColumn("phone", F.regexp_replace(F.col("phone"), "[^0-9]", ""))
)
-- SQL 版
SELECT
  lower(trim(city))                     AS city,
  lower(trim(email))                    AS email,
  regexp_replace(phone, '[^0-9]', '')   AS phone
FROM bronze.orders_raw;

ほかに upper(大文字化)、initcap(先頭大文字化)、lpad/rpad(桁揃え)なども整形でよく使います。

6. シルバー層への書き込みと冪等性(MERGE)

変換した DataFrame をシルバーへ書き込みます。最も単純なのは上書き・追記ですが、ジョブは失敗して再実行されるものという前提に立つと、「何回実行しても結果が同じになる」冪等な書き込みが品質と再現性の要になります。単純な append は再実行のたびに同じデータが二重に積まれてしまいます。

書き込み方式コード再実行したら
追記(append)df.write.mode("append").saveAsTable(...)重複が発生(冪等でない)
全上書き(overwrite)df.write.mode("overwrite").saveAsTable(...)結果は同じ(冪等)だが、全件書き直しでコスト大
MERGE(upsert)MERGE INTO ... WHEN MATCHED / NOT MATCHEDキー一致なら更新、なければ挿入。再実行しても重複しない(冪等)

💡 具体例:MERGE による冪等なシルバー更新

-- 変換結果を一時ビューとして登録(PySpark 側)
-- df_clean.createOrReplaceTempView("orders_clean")

MERGE INTO silver.orders AS t
USING orders_clean AS s
  ON t.order_id = s.order_id
WHEN MATCHED THEN
  UPDATE SET *
WHEN NOT MATCHED THEN
  INSERT *;
# PySpark(Delta API)で同じ MERGE を書く場合
from delta.tables import DeltaTable

silver = DeltaTable.forName(spark, "silver.orders")
(silver.alias("t")
    .merge(df_clean.alias("s"), "t.order_id = s.order_id")
    .whenMatchedUpdateAll()
    .whenNotMatchedInsertAll()
    .execute())

キー(ここでは order_id)が一致する行は最新の内容で更新され、存在しない行だけが挿入されます。同じ入力で何度実行してもテーブルの最終状態は変わらないため、リトライやバックフィル(過去分の再処理)に安全に対応できます。

📝 試験のポイント

「パイプラインが途中失敗して再実行したら行が重複した。どう設計すべきだったか」→ MERGE による upsert で冪等にするが正解の型です。なお MERGE では、ソース側の1行がターゲットの複数行に一致するのは問題ありませんが、ターゲットの1行にソースの複数行が一致するとエラーになります。MERGE の前にソース側の重複排除(3-4)が必要になる、という接続も意識しておきましょう。

✅ この節のまとめ

練習問題

問1. メダリオンアーキテクチャにおけるブロンズ層の説明として最も適切なものはどれか。

  1. BIツールから直接参照される、集計済みの提供用データを保持する
  2. ソースの生データをほぼそのまま保持し、下流の再処理の起点となる
  3. null 処理と型変換が完了した、分析の土台となるデータを保持する
  4. ストレージ節約のため、クリーニングで不要と判断した列を削除して保存する
解答と解説を見る

正解:B

ブロンズは生データの保存層で、変換ロジックの誤りが後で見つかってもソースに再アクセスせずやり直せる「再処理可能性」を担います。Aはゴールド層、Cはシルバー層の説明です。Dのようにブロンズ段階で列を削り込むと再処理の起点としての価値が失われるため、設計として不適切です。

問2. ブロンズテーブルの order_date 列は "2026-08-01" 形式の文字列である。シルバー層で DATE 型に標準化する方法として最も適切なものはどれか。

  1. F.to_date(F.col("order_date"), "yyyy-MM-dd") で変換する
  2. F.to_timestamp(F.col("order_date")) で変換すれば DATE 型になる
  3. F.substring(F.col("order_date"), 1, 4) で年だけ取り出して保存する
  4. 文字列のままでも日付として比較できるので変換は不要である
解答と解説を見る

正解:A

フォーマットを明示した to_date が DATE 型への標準的な変換です。Bの to_timestamp が返すのは TIMESTAMP 型であり、DATE 型にはなりません。Cは情報を失うだけで型の標準化になっていません。Dは "yyyy-MM-dd" 形式なら偶然正しく並ぶ場合もありますが、日付演算(月の加算など)ができず、型を揃えるというシルバー層の目的に反します。

問3. 注文データの ship_address が null の場合は billing_address の値を使い、どちらも null なら "unknown" としたい。最も適切な式はどれか。

  1. F.coalesce(F.col("ship_address"), F.col("billing_address"), F.lit("unknown"))
  2. df.dropna(subset=["ship_address"])
  3. df.fillna("unknown")
  4. F.when(F.col("ship_address") == "", F.col("billing_address"))
解答と解説を見る

正解:A

coalesce は左から順に評価し、最初の非 null 値を返すため、「優先順位付きの補完」をそのまま表現できます。Bは補完ではなく行の削除です。Cは全列の null を "unknown" で埋めてしまい、billing_address による補完という要件を満たしません。Dは空文字と null を混同しているうえ、otherwise がないため条件を満たさない行が null になってしまいます。

問4. ブロンズ→シルバーの日次ジョブが途中で失敗し、再実行したところシルバーテーブルに同じ注文が2行ずつ存在するようになった。今後の再発を防ぐ最も適切な設計はどれか。

  1. 書き込みを mode("append") のまま、失敗時は手動で重複行を削除する運用にする
  2. 注文IDをキーとした MERGE INTO ... WHEN MATCHED THEN UPDATE / WHEN NOT MATCHED THEN INSERT に変更し、書き込みを冪等にする
  3. 再実行を禁止するルールをチームに周知する
  4. シルバーテーブルを毎回 DROP してから append する
解答と解説を見る

正解:B

キーに基づく MERGE(upsert)なら、同じ入力で何度実行しても既存行は更新・新規行は挿入となり、重複は発生しません(冪等)。Aは手作業に依存し根本解決になりません。Cはジョブが失敗する現実を無視した運用であり、リトライできないパイプラインはかえって脆弱です。Dは実質的な全上書きを遠回りに行うもので、DROP と append の間に読み取りが入ると空のテーブルが見えるなど、新たな問題を生みます。