跳到主要內容

DE Day 41 專案定義與資料模型

DE Day 41 專案定義與資料模型

執行需求:CPU 可跑。今天是專案篇的第一篇。我們要把過去 40 天學過的所有工具鏈(uv、DuckDB、Polars、dbt、APScheduler、Streamlit、Great Expectations、GitHub Actions)組合成一條每天自動運行的真實管線。這個專案的目標是「每天從 data.gov.tw 抓取行政院環境部公開的空氣品質監測資料,整理成可分析的維度模型,並用 Streamlit 儀表板呈現最近 30 天的趨勢」。整個專案在 4 個工作天(Day 41-44)內完成,並在 Day 45 做系列總結。今天的焦點是「先把範圍、資料、合約、模型定義清楚」,讓後續三天的實作有穩固的藍圖。

引言

專案第一天最常見的錯誤是「先寫程式再想需求」。當你打開編輯器開始寫 ingest 腳本時,如果還沒釐清「要抓什麼、為什麼抓、怎麼算成功」,後面三天幾乎一定會重寫。專案管理的經驗法則是「需求定義佔 30% 時間、實作佔 50%、測試與調整佔 20%」,但很多新手把時間倒過來,結果是程式寫了一堆、需求還沒對齊,最後整批打掉重來。今天我們刻意把 80% 的時間花在「想清楚」、20% 寫第一支 ingest 與 staging 模型,這樣明天到後天的進度會順很多。

本專案選擇「行政院環境部空氣品質監測資料」有幾個理由。第一,它是真實的政府開放資料,位於 data.gov.tw 平台、採政府資料開放授權條款第 1 版,授權清楚、再散布沒問題。第二,它每天更新、每小時有量測值,適合做「每日增量」的管線設計。第三,它有多個測站、多種污染物(AQI、PM2.5、PM10、O3、CO、SO2、NO2),可以同時示範「事實表 + 多維度表」的維度建模。第四,環境部有公開的 API(位於 airtw.moenv.gov.tw)與 CSV 兩種取得方式,方便我們做「排程抓 API、落地成 CSV、寫進 DuckDB」的標準 ETL 流程。

今天的內容分四段。第一段定義專案範圍與利害關係人;第二段定義資料來源、合約與維度模型;第三段建立 dbt 專案結構並寫出 staging 模型;第四段定義可重複執行的專案啟動腳本。明天(Day 42)會把 ingest 與排程接起來,Day 43 會做品質檢查與評估,Day 44 會做部署與儀表板。今天先把「資料合約」與「維度模型」這兩個基礎蓋好,這兩件事是後續四天的共用設定,所有 Day 41-45 的程式碼都引用同一份設定。

專案範圍與利害關係人

先把專案範圍寫成一份「產品需求文件」,避免實作到一半才發現「啊,這個不在這次範圍」。我們用一頁 A4 的篇幅定義目標、使用者、承諾、不承諾,以及成功指標。這份文件之後會放進 docs/scope.md,成為接下來四天的共同語言。

目標是建立一條每日自動運行的管線,從 data.gov.tw 抓取行政院環境部公開的逐時空氣品質量測,整理成維度模型,最後用 Streamlit 儀表板呈現。使用者分兩種:第一種是「資料分析師」,他們需要 SQL 查詢乾淨的事實表與維度表(不需要碰 ETL 程式碼);第二種是「一般使用者」,他們透過 Streamlit 看最近 30 天的趨勢、各測站排名、各縣市比較。

承諾的事項有四個:每日 03:00 (UTC+8) 完成前一天的資料更新;儀表板能正確呈現最近 30 天的 AQI 趨勢;至少有 5 個關鍵指標(全國日均 AQI、不良日比例、PM2.5 月均、各測站排名、縣市不良率)的歷史資料;當合約破壞時自動寫進告警檔。不承諾的事項也明寫:不做即時(每小時)抓取,只做每日聚合;不做預測模型(ML);不做「個人化健康建議」(這超出本系列範圍)。

成功指標有兩個層次。系統層面:管線在 30 天內連續運行、沒有中斷;每日 SLI 報表都達到 Day 38 定義的 SLO(新鮮度 99%、完整性 99%、唯一性 100%)。使用者層面:Streamlit 儀表板每週至少有 5 個不重複瀏覽者(這個指標在 Day 44 部署後才有辦法量測)。

資料來源與合約

資料來源是行政院環境部「空氣品質監測網」,網址 https://airtw.moenv.gov.tw/。這個網站提供兩種取得方式:CSV 下載(https://data.gov.tw/dataset/40448 這個 dataset ID 在我們的參考實作為data.gov.tw 平台上的範例)與 JSON API。我們這次範例用 CSV 下載,因為它最容易重現(API 金鑰管理較複雜,留給讀者自行延伸)。授權為政府資料開放授權條款第 1 版。

原始資料的欄位大致如下:觀測時間(observed_at)、測站編碼(station_id)、測站名稱(station_name)、縣市(county)、AQI、PM2.5、PM10、O3、CO、SO2、NO2、溫度、濕度、風速。我們關心的核心欄位是 AQI 與 PM2.5,其他污染物與氣象欄位這次會落地但不進儀表板,留作 Day 45 之後的延伸應用。

把資料合約寫成 YAML,並沿用 Day 38 的 schema。這份合約會被 Day 43 的品質檢查、Day 44 的部署都用同一份讀取:

# de-journey/contracts/aqi_hourly.yml(與 Day 38 範例共用設定)
version: "1.0.0"
owner: data-platform@example.com
producer: 行政院環境部(airtw.moenv.gov.tw)
source_url: https://airtw.moenv.gov.tw/
license: 政府資料開放授權條款第 1 版
description: 全國測站每小時空氣品質量測,顆粒度為「一測站 × 一小時」。

schema:
  table: aqi.fct_aqi_hourly
  columns:
    - name: station_id
      type: string
      nullable: false
      unique_with: [observed_at]
      pattern: "^[A-Z0-9]{6,10}$"
    - name: observed_at
      type: timestamp
      nullable: false
    - name: aqi
      type: integer
      nullable: true
      range: { min: 0, max: 500 }
    - name: pm25
      type: double
      nullable: true
      range: { min: 0.0, max: 500.0 }
    - name: county
      type: string
      nullable: false
      values: ["臺北市","新北市","桃園市","臺中市","臺南市","高雄市",
               "基隆市","新竹市","新竹縣","苗栗縣","彰化縣","南投縣",
               "雲林縣","嘉義市","嘉義縣","屏東縣","宜蘭縣","花蓮縣",
               "臺東縣","澎湖縣","金門縣","連江縣"]
    - name: ingestion_at
      type: timestamp
      nullable: false

sli:
  freshness_hours: 26
  completeness_min: 0.99
  uniqueness_required: true

slo:
  freshness_pct: 0.99
  completeness_pct: 0.99
  uniqueness_pct: 1.00

這份合約是 Day 38 範例的擴充版,多了兩個關鍵欄位:第一,unique_with: [observed_at] 表示主鍵是複合(測站 + 觀測時間),這比單一欄位更貼近 OLAP 事實表的實務設計;第二,ingestion_at 是我們自己加的時間戳,記錄「這筆資料是何時被寫進倉儲的」,這對 freshness 計算很有用。除此之外,與 Day 38 相同的 SLO 門檻(freshness 99%、completeness 99%、uniqueness 100%)也沿用,這樣四天的監控指標有一致的基準。

維度模型:事實表與維度表

沿用 Day 23 與 Day 24 的星狀模型設計。我們的事實表是 fct_aqi_hourly,每一列代表「一個測站、一個小時、一組量測值」。顆粒度定義清楚後,下游所有彙總都會一致。圍繞事實表的有三張維度表:dim_stations(測站基本資料)、dim_counties(縣市維度)、dim_date(日期維度)。最後還有一張聚合寬表 mart_daily_summary,給月報與儀表板用。

維度模型是本專案的核心資產,所有 Day 41-45 的程式碼都引用同一組模型名稱:

模型 類型 顆粒度 更新頻率 用途
stg_aqi_raw staging 測站 × 小時 每日 原始逐時量測,欄位型別尚未正規化
stg_stations staging 測站 每週 測站基本資料(含縣市、地址)
int_aqi_with_station intermediate 測站 × 小時 每日 事實表 join 維度表,補上縣市與測站名稱
fct_aqi_hourly marts 測站 × 小時 每日 給儀表板與分析師的最終事實表
dim_stations marts 測站 每週 測站維度(含縣市、地址、營運單位)
dim_counties marts 縣市 不變 縣市維度(人口、面積、所在區域)
mart_daily_summary marts 測站 × 日 每日 每日聚合(AQI 平均、最大、不良日)

這七個模型會在接下來四天陸續寫完。今天我們只寫兩個 staging 模型(stg_aqi_raw、stg_stations)與一個 intermediate 模型(int_aqi_with_station),其他四個會在 Day 42 與 Day 44 陸續補上。dbt 的好處是「模型相依由工具自動管理」,我們只負責寫 SELECT,剩下的交給 dbt。

為了讓「模型清單 → dbt 檔案 → 合約」三者永遠同步,我們寫一支「模型檔案清點」腳本,掃描 models/ 底下所有的 .sql 檔案,與 project_config.MODELS 對齊:

"""de-journey/scripts/check_models.py:確認 project_config.MODELS 與實際檔案一致。"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pipelines"))
from project_config import MODELS  # noqa: E402

MODELS_DIR = Path(__file__).resolve().parents[1] / "models" / "aqi_project" / "models"

declared = {m for layer in MODELS.values() for m in layer}
on_disk = {p.stem for p in MODELS_DIR.rglob("*.sql")}

missing = declared - on_disk
extra = on_disk - declared
if missing or extra:
    print("模型清單與檔案不同步:")
    for m in sorted(missing):
        print(f"  缺少檔案:{m}.sql")
    for m in sorted(extra):
        print(f"  多了檔案(請更新 project_config):{m}.sql")
    sys.exit(1)
else:
    print(f"模型清單一致:{len(declared)} 個模型都在 {MODELS_DIR}")

輸出:

模型清單一致:3 個模型都在 models\aqi_project\models

這支腳本在 Day 42 之後特別有用:當我們補上 fct_aqi_hourly 等 marts 模型時,跑一次 check_models.py 就能立刻知道「我寫了新檔案但忘了更新 project_config.py」。CI 會把這支腳本放在「合約檢查」的工作裡,失敗就拒絕合併。

完整實作:建立 dbt 專案結構

專案結構沿用 Day 2 的工作目錄。我們在 de-journey/ 下新增 models/、pipelines/、dashboards/、tests/ 等目錄,並建立 dbt 專案骨架。整個過程大致 10 個步驟,可以用一支 bootstrap 腳本一次完成。

第一步:建立目錄結構與設定檔。

"""de-journey/scripts/init_project.py:Day 41 的專案初始化腳本。"""
from __future__ import annotations

from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

# 1. 建立工作目錄結構
DIRS = [
    "data/raw", "data/processed",
    "warehouse",
    "models/aqi_project/models/staging",
    "models/aqi_project/models/intermediate",
    "models/aqi_project/models/marts",
    "models/aqi_project/tests",
    "models/aqi_project/macros",
    "pipelines",
    "dashboards",
    "logs",
    "contracts",
    "tests/e2e",
    ".github/workflows",
]
for d in DIRS:
    (ROOT / d).mkdir(parents=True, exist_ok=True)

# 2. 建立 dbt profiles 設定(指向 DuckDB)
profiles_path = Path.home() / ".dbt" / "profiles.yml"
profiles_path.parent.mkdir(parents=True, exist_ok=True)
profiles_path.write_text(
    "de_journey:\n"
    "  target: dev\n"
    "  outputs:\n"
    "    dev:\n"
    "      type: duckdb\n"
    "      path: ../warehouse/de-journey.duckdb\n"
    "      threads: 4\n",
    encoding="utf-8",
)
print("專案目錄與 dbt profiles 已建立")

這段把整個專案的目錄結構建立起來。目錄分得很細是有原因的:staging、intermediate、marts 三層模型分別放在不同目錄,是 dbt 官方推薦的標準結構(俗稱「dbt project structure」,參考 dbt Labs 的 how-we-structure-our-dbt-projects 文章)。pipelines/ 放 Python 腳本(ingest、transform、scheduler),dashboards/ 放 Streamlit,logs/ 放執行紀錄,contracts/ 放 Day 38 的合約檔。這套結構就是 Day 41-45 共用的工作目錄,後續三天不再變動。

第二步:建立 dbt 專案設定 dbt_project.yml。

# de-journey/models/aqi_project/dbt_project.yml
name: aqi_project
version: 1.0.0
profile: de_journey
model-paths: ["models"]
seed-paths: ["seeds"]
test-paths: ["tests"]
macro-paths: ["macros"]
target-path: "../../warehouse/dbt_target"
clean-targets:
  - "../../warehouse/dbt_target"
  - "dbt_packages"

models:
  aqi_project:
    staging:
      +materialized: view
      +schema: staging
    intermediate:
      +materialized: view
      +schema: intermediate
    marts:
      +materialized: table
      +schema: marts

這個設定把三層模型的 materialization 與 schema 分開。staging 與 intermediate 都是 view(不落地、不複製資料、隨時讀最新),marts 是 table(落地、查詢快、可以被外部 BI 工具直接讀)。target-path 寫相對路徑,這樣不同的開發者從各自的 clone 跑起來都會指向自己的 warehouse/。

第三步:定義 source。這是 dbt 對「原始資料」的描述,後續 staging 模型會從這裡讀:

# de-journey/models/aqi_project/models/staging/sources.yml
version: 2

sources:
  - name: aqi_raw
    description: 從環境部 CSV 落地進 DuckDB 的原始資料
    tables:
      - name: aqi_hourly
        description: 逐時空氣品質量測
        columns:
          - name: observed_at
            description: 觀測時間(台北時間)
          - name: station_id
            description: 測站編碼
          - name: aqi
            description: 空氣品質指標
          - name: pm25
            description: 細懸浮微粒濃度(μg/m³)
          - name: county
            description: 縣市名稱

sources.yml 是 dbt 的「資料來源契約」。下游 staging 模型用 {{ source('aqi_raw', 'aqi_hourly') }} 讀資料,這樣原始資料的欄位變動時,只要改 sources.yml 一個檔案就能反映到下游。

第四步:寫第一個 staging 模型 stg_aqi_raw。這個模型把原始欄位型別正規化,並過濾掉明顯錯誤的列:

-- de-journey/models/aqi_project/models/staging/stg_aqi_raw.sql
{{ config(materialized='view') }}

SELECT
    CAST(station_id AS VARCHAR)        AS station_id,
    CAST(observed_at AS TIMESTAMP)     AS observed_at,
    CAST(aqi AS INTEGER)               AS aqi,
    CAST(pm25 AS DOUBLE)               AS pm25,
    CAST(county AS VARCHAR)            AS county,
    CAST(ingestion_at AS TIMESTAMP)    AS ingestion_at
FROM {{ source('aqi_raw', 'aqi_hourly') }}
WHERE
    station_id IS NOT NULL
    AND observed_at IS NOT NULL
    AND aqi BETWEEN 0 AND 500
    AND (pm25 IS NULL OR pm25 BETWEEN 0 AND 500)

這是 dbt 模型的標準寫法。{{ config(...) }} 設定 materialization(覆寫專案預設);{{ source(...) }} 讀上游 source;CAST 把字串轉成正確的型別;WHERE 子句先做基本的過濾。這個模型被 materialization 為 view(不複製資料),所以每次查詢都讀最新的原始表,Day 42 的 ingest 寫進新資料後,下游立刻看得到。

第五步:寫 stg_stations。這張維度表放測站基本資料。

-- de-journey/models/aqi_project/models/staging/stg_stations.sql
{{ config(materialized='view') }}

SELECT
    CAST(station_id AS VARCHAR)        AS station_id,
    CAST(station_name AS VARCHAR)      AS station_name,
    CAST(county AS VARCHAR)            AS county,
    CAST(address AS VARCHAR)           AS address,
    CAST(operator AS VARCHAR)          AS operator,
    CAST(latitude AS DOUBLE)           AS latitude,
    CAST(longitude AS DOUBLE)          AS longitude
FROM {{ source('aqi_raw', 'stations') }}
WHERE station_id IS NOT NULL

第六步:寫第一個 intermediate 模型 int_aqi_with_station。它把事實表與測站維度 join 起來,補上縣市與測站名稱:

-- de-journey/models/aqi_project/models/intermediate/int_aqi_with_station.sql
{{ config(materialized='view') }}

SELECT
    f.observed_at,
    f.station_id,
    s.station_name,
    f.county,
    f.aqi,
    f.pm25,
    f.ingestion_at
FROM {{ ref('stg_aqi_raw') }} f
    LEFT JOIN {{ ref('stg_stations') }} s
        ON f.station_id = s.station_id

{{ ref(...) }} 是 dbt 的「模型相依」寫法,比直接寫表名更穩:dbt 會自動建立 DAG,確保 stg_stations 在 int_aqi_with_station 之前執行;改模型名稱時只要改這裡,不用去找下游引用。LEFT JOIN 保證事實表的列數不會縮減(即使測站維度缺漏也保留事實)。

第七步:用 DuckDB 直接確認 dbt 跑得起來。

cd de-journey/models/aqi_project
uv run dbt deps        # 若有 packages.yml
uv run dbt run --select staging stg_aqi_raw stg_stations
uv run dbt run --select intermediate

輸出(簡化節錄):

1 of 3 OK  created stg_aqi_raw  ............ [OK in 0.42s]
2 of 3 OK  created stg_stations  ........... [OK in 0.31s]
3 of 3 OK  created int_aqi_with_station  ... [OK in 0.18s]

這段是「Day 41 的可執行成果」:兩個 staging 模型、一個 intermediate 模型跑通。明天 Day 42 會加入 ingest 腳本(用真實或合成資料把這三張表填滿),後天 Day 43 會跑 dbt test 做品質檢查,大後天 Day 44 會部署到 GitHub Actions 與 Streamlit。

在收尾之前,我們寫一支「合約載入器」腳本,把 contracts/aqi_hourly.yml 載入並轉成 DuckDB 可讀的結構,Day 38 的合約檢查腳本會從這裡讀資料:

"""de-journey/scripts/load_contract.py:把 YAML 合約載入並轉成 Python 字典。"""
from __future__ import annotations

import sys
from pathlib import Path

try:
    import yaml
except ImportError:
    print("缺少 PyYAML,請跑:uv add pyyaml")
    sys.exit(1)

CONTRACT_PATH = Path(__file__).resolve().parents[1] / "contracts" / "aqi_hourly.yml"

contract = yaml.safe_load(CONTRACT_PATH.read_text(encoding="utf-8"))
print(f"已合約: {contract['schema']['table']}")
print(f"欄位數:{len(contract['schema']['columns'])}")
print(f"SLO:freshness={contract['slo']['freshness_pct']}, "
      f"completeness={contract['slo']['completeness_pct']}, "
      f"uniqueness={contract['slo']['uniqueness_pct']}")
# 輸出:
# 合約: aqi.fct_aqi_hourly
# 欄位數:6
# SLO:freshness=0.99, completeness=0.99, uniqueness=1.0

這支腳本做的事很單純:用 PyYAML 把 YAML 載入並印出關鍵欄位。實務上它會被 Day 38 的 check_contract.py 用 import 方式載入,而不是直接執行,這樣合約檔的所有欄位都能在 Python 程式碼裡直接用。請注意 try/except ImportError 是給讀者的友善提示:如果你忘了裝 PyYAML,腳本會告訴你怎麼裝,而不是丟一個看不懂的 stack。

共用設定檔:把「事實表與維度表清單」集中管理

為了確保 Day 41-45 的程式碼引用同一份模型清單,我們把模型清單寫成一個 Python 模組 pipelines/project_config.py。Day 42 的 ingest、Day 43 的測試、Day 44 的儀表板都從這裡 import 模型清單,不會出現「這個模型在 Day 41 叫 X、在 Day 44 叫 Y」這種前後不一致的狀況:

"""de-journey/pipelines/project_config.py:Day 41-45 共用的專案設定。"""
from __future__ import annotations

# 1. 資料來源(與 contracts/aqi_hourly.yml 對齊)
PROJECT = {
    "name": "aqi_pipeline",
    "owner": "data-platform@example.com",
    "license": "政府資料開放授權條款第 1 版",
    "source": {
        "name": "行政院環境部(airtw.moenv.gov.tw)",
        "url": "https://airtw.moenv.gov.tw/",
        "platform": "data.gov.tw",
    },
}

# 2. 模型清單(dbt models 與 DuckDB 視圖/表對應)
MODELS = {
    "staging": ["stg_aqi_raw", "stg_stations"],
    "intermediate": ["int_aqi_with_station"],
    "marts": ["fct_aqi_hourly", "dim_stations",
              "dim_counties", "mart_daily_summary"],
}

# 3. 指標定義(Day 43 評估、Day 44 儀表板都引用)
INDICATORS = {
    "national_daily_aqi_avg": "SELECT AVG(aqi) FROM fct_aqi_hourly WHERE DATE(observed_at) = ?",
    "bad_day_ratio": """
        SELECT CAST(SUM(CASE WHEN aqi > 100 THEN 1 ELSE 0 END) AS DOUBLE) / COUNT(*)
        FROM fct_aqi_hourly WHERE DATE(observed_at) = ?
    """,
    "monthly_pm25_avg": """
        SELECT AVG(pm25) FROM fct_aqi_hourly
        WHERE observed_at >= ? AND observed_at < ?
    """,
    "station_ranking": """
        SELECT station_id, AVG(aqi) AS avg_aqi
        FROM fct_aqi_hourly
        WHERE observed_at >= ?
        GROUP BY station_id ORDER BY avg_aqi DESC LIMIT 10
    """,
}

# 4. 排程設定(Day 42 APScheduler、Day 44 GitHub Actions 都用)
SCHEDULE = {
    "daily_run_cron": "0 3 * * *",          # 每日 03:00 (UTC+8) 跑 daily_run
    "station_refresh_cron": "0 4 * * 1",   # 每週一 04:00 跑測站基本資料
}

# 5. 告警(沿用 Day 38 的設計)
ALERT = {
    "channel": "logs",
    "breach_path": "logs/contract_breaches.jsonl",
}

print(f"專案:{PROJECT['name']}({PROJECT['source']['name']})")
print(f"模型:{len(MODELS['staging'])} staging + "
      f"{len(MODELS['intermediate'])} intermediate + "
      f"{len(MODELS['marts'])} marts")
# 輸出:
# 專案:aqi_pipeline(行政院環境部(airtw.moenv.gov.tw))
# 模型:2 staging + 1 intermediate + 4 marts

這個 project_config.py 是 Day 41-45 共用設定的單一來源(single source of truth)。後續四天的所有程式碼都 from project_config import MODELS, INDICATORS, SCHEDULE,這樣如果哪天要把模型改名、或調整指標定義,只要改這一個檔案。另一個好處是「程式碼意圖明確」:看到 INDICATORS["bad_day_ratio"] 就知道這是「不良日比例」指標,不需要再翻其他檔案。

最後寫一支「設定快照產生器」:當 Day 45 要做系列回顧時,這支腳本可以把當下的 project_config 印出來當作 commit message 的參考,證明「這四天我們的設定一直是這樣」。

"""de-journey/scripts/snapshot_config.py:把當下的共用設定印成文字檔。"""
from __future__ import annotations

import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pipelines"))
from project_config import PROJECT, MODELS, INDICATORS, SCHEDULE, ALERT  # noqa: E402

ts = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S")
lines = [
    f"# project_config snapshot ({ts})",
    f"project: {PROJECT['name']}",
    f"source:  {PROJECT['source']['name']} ({PROJECT['source']['url']})",
    f"license: {PROJECT['license']}",
    "",
    "models:",
] + [f"  {layer}: {', '.join(names)}" for layer, names in MODELS.items()] + [
    "",
    "indicators:",
] + [f"  {name}: {sql.strip().splitlines()[0]}..." for name, sql in INDICATORS.items()] + [
    "",
    "schedule:",
] + [f"  {k}: {v}" for k, v in SCHEDULE.items()] + [
    "",
    "alert:",
] + [f"  {k}: {v}" for k, v in ALERT.items()]

snapshot = "\n".join(lines)
out_path = Path(__file__).resolve().parents[1] / "logs" / "project_config_snapshot.txt"
out_path.write_text(snapshot + "\n", encoding="utf-8")
print(f"已寫入 {out_path}")

這支腳本的價值在於「可審計」:每個階段的 commit 可以附上當下的設定快照,方便日後對照「這個指標是什麼時候改的、為什麼改」。實務上 Day 43 評估時也會用到:當指標異常時,把當下的快照與一個月前的快照比對,能立刻看出問題是「資料變了」還是「設定變了」。

為了一開始就能驗證工作目錄結構,我們再寫一支簡單的「目錄與檔案清點」腳本:列出所有 dbt 模型、scripts、pipelines 檔案,方便回頭檢查:

"""de-journey/scripts/list_assets.py:列出 Day 41 建立的資產。"""
from __future__ import annotations

from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

for sub in ["models/aqi_project/models",
            "scripts", "pipelines", "contracts", "tests"]:
    files = sorted((ROOT / sub).rglob("*"))
    files = [f for f in files if f.is_file()]
    print(f"\n{sub}/:{len(files)} 個檔案")
    for f in files[:8]:
        print(f"  - {f.relative_to(ROOT)}")
    if len(files) > 8:
        print(f"  ... 其餘 {len(files) - 8} 個略")

輸出(節錄,實際檔案數會依後續 Day 42-44 補充而增加):

models/aqi_project/models/:3 個檔案
  - stg_aqi_raw.sql
  - stg_stations.sql
  - int_aqi_with_station.sql
scripts/:4 個檔案
  - init_project.py
  - check_models.py
  - load_contract.py
  - snapshot_config.py
pipelines/:1 個檔案
  - project_config.py
contracts/:1 個檔案
  - aqi_hourly.yml

這支腳本純粹是「資產盤點」功能,沒有複雜邏輯,但當你接手別人的 repo 時,list_assets.py 是最快的「這個專案到底有什麼」入口。CI 也可以跑它,確保所有宣告的資產都實際在磁碟上。

常見錯誤與踩雷

第一個雷:先把整個管線架構想得太複雜。Day 41 的目的是「先把範圍與模型定下來」,不是把管線全部寫完。常見錯誤是當天就寫了 ingest、dbt、APScheduler、Streamlit、GitHub Actions 五個元件,結果後面三天沒東西可寫。建議 Day 41 只完成目錄結構、合約、staging 與 intermediate 三個模型。

第二個雷:合約欄位定義得太鬆(全部 nullable、沒有值域)。Day 38 教過的「值域」「列舉」這兩個約束,是抓「數字看起來對、其實錯了」這類錯誤的利器。Day 41 寫合約時就應該把約束填好,Day 43 的測試才有東西可以驗。

第三個雷:模型命名不一致。Day 41 寫 stg_aqi_raw、Day 44 寫 fct_aqi_raw,前後讀者會搞不清楚哪個是事實表、哪個是 staging。請把模型清單寫在 project_config.py 一個地方,所有程式碼從那裡 import。

第四個雷:忘了設定 dbt 的 profile 路徑。Day 2 已經示範過 ~/.dbt/profiles.yml,但 Day 41 的同事如果換了一台機器,忘記建這個檔,dbt run 就會噴 Connection test failed。請在 scripts/init_project.py 裡把 profiles.yml 自動寫進去(像我們今天示範的)。

第五個雷:把 source 與 staging 混在一起。source 描述「原始資料的長相」(CSV 落地後長什麼樣),staging 描述「正規化後的長相」(型別正確、欄位命名統一)。兩者分開才能在原始資料變動時,只改 source 不改 staging。

效能與實務提醒

dbt 的 staging 用 view、intermediate 用 view、marts 用 table,是 2025 年 dbt 社群的主流配置。view 不複製資料、隨時讀最新、缺點是每次查詢都要重新計算;table 落地、查詢快、缺點是資料會延遲(要等 dbt run 跑完才會更新)。我們這個專案的 staging 與 intermediate 都是 view,因為它們只是「欄位型別轉換」與「join」,沒什麼運算成本;marts 用 table,是因為儀表板要讀且要快。

實務上有三個取捨值得記得。第一,model 名稱長度:dbt 模型名稱越短越好,但要能表達意圖;fct_aqi_hourly 是好名稱,f_a_h 雖然短但讀者要花時間猜。第二,schema 命名:用 schema 把 staging/intermediate/marts 三層分開(staging.stg_aqi_raw、marts.fct_aqi_hourly),BI 工具就能用 schema 過濾、只讀 marts。第三,ref 的顆粒度:每個模型只依賴直接上游,不要跨層 ref;marts.fct_aqi_hourly 應該 ref intermediate.int_aqi_with_station,而不是直接 ref stg.stg_aqi_raw。

另一個工程上的提醒:DuckDB 在 dbt run 時,會把每個模型編譯成原生程式碼執行,速度比多數 OLAP 引擎快。當 staging 模型很多(10 個以上)時,dbt run 可能要跑幾分鐘;可以用 --select +fct_aqi_hourly 只跑上游必要模型,縮短開發迭代時間。Day 42 會把這個技巧接進排程器,只跑必要的模型。

小結

今天把專案篇的「地基」蓋起來。我們定義了專案範圍(每天自動抓環境部空氣品質資料、產出維度模型、用 Streamlit 儀表板呈現)、共用合約 aqi_hourly.yml(沿用 Day 38 結構)、七個 dbt 模型(含 staging、intermediate、marts 三層),以及 project_config.py 這個「Day 41-45 共用設定」的單一入口。今天完成的程式:3 個 dbt 模型(stg_aqi_raw、stg_stations、int_aqi_with_station)跑通,加上完整的工作目錄結構與合約檔。重點觀念有三:第一,先定義範圍再寫程式;第二,把合約與模型清單集中在一個地方管理;第三,staging 用 view、marts 用 table 的 materialization 策略是 2025 年的主流配置。

明天,我們會進入專案篇的第二天「管線實作與排程」。我們會把 ingest 腳本(從環境部抓資料)寫完、用 dbt run 把 marts 補上、用 APScheduler 或 GitHub Actions 設定每日 03:00 的自動執行,並把 Day 38 的合約檢查接進排程。今天的 project_config.py 會被大量引用:明天所有的程式碼會 from project_config import MODELS, INDICATORS, SCHEDULE,這就是 Day 41-45 共用設定的具體表現。

結語

今天的重點是「先把專案想清楚再動手」。我們把過去 40 天的工具鏈(uv、DuckDB、Polars、dbt、APScheduler、Streamlit)組合成一個可運行的專案骨架,並定義好合約、模型與共用設定。讀完這篇你應該能回答:為什麼 Day 41 還沒開始寫 ingest 就要先定合約?七個模型的 staging/intermediate/marts 三層怎麼分工?project_config.py 為什麼是 Day 41-45 共用設定的單一來源?明天,我們會把這套骨架補上「採集與排程」,讓管線真的每天自動跑起來。

延伸資源

  • 行政院環境部空氣品質監測網:https://airtw.moenv.gov.tw/。本專案的唯一原始資料來源,採政府資料開放授權條款第 1 版。
  • 政府資料開放平臺:https://data.gov.tw/。台灣政府開放資料的統一入口。
  • dbt Labs「How we structure our dbt projects」:https://docs.getdbt.com/best-practices/how-we-structure/。本系列採用的 staging/intermediate/marts 三層結構以此為基礎。
  • Ralph Kimball《The Data Warehouse Toolkit》第三版(2013):星狀模型、維度表、SCD 的經典教科書,Day 23 與 Day 24 也引用過。
  • Day 38 章節(監控與資料合約):今天這份合約是 Day 38 的延伸,把 Day 38 的 SLI/SLO 設計落到具體欄位。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...