JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

資料管線與可重現性

如果你無法重跑它,你就無法信任它——把一堆一次性步驟,變成有腳本、有版本控制、可重現的管線。

無法重跑的分析之恐怖

週五下午四點。你的主管走過來說:「上個月的營收數字很棒——可以把這週的資料加進去重跑一次嗎?」你的心一沉。三週前你打開了一個試算表,手動刪掉幾列明顯壞掉的資料,複製了一欄、排了序、做了一張樞紐分析表,然後把那個關鍵數字打進投影片裡。這些步驟你一個都沒記下來。現在你連同一個數字都跑不出第二次,更別說更新它了。那股寒意,正是這篇指南要幫你避免的。

我們先用白話把目標講清楚。一份分析是可重現的(reproducible),意思是別人——或半年後的你自己——拿同一份原始資料、走同一套步驟,就能得到一模一樣的結果。注意「一模一樣」這四個字:相同的輸入進去,相同的數字出來,每次都一樣,完全不靠記憶。這和可複製性(replicability)不同,後者問的是「換一份全新的資料集,會不會得到類似的結論」。可複製性關乎一個發現是否為真;可重現性則是更基本的承諾——你自己的工作究竟跑不跑得起來。本篇談的是可重現性,它是其他一切的地基。

為什麼要這麼在意?有四個理由。第一,信任:沒人能重現的數字只是傳言,不是結果。第二,除錯:當一個數字看起來不對勁,唯有能逐步重走它的產生過程,你才找得到錯在哪。第三,協作:同事應該能在你的成果上接著做,而不必你花一小時從頭講解。第四,更新:商業問題從來不會只問一次——「用這個月的資料重跑一次」才是常態,不是例外。

資料工作是一個迴圈,不是一次性的動作。你會蒐集、清理、探索、建模、做決策——然後下個月帶著新資料再走一遍。這個迴圈的每一圈都必須能重跑,否則每一圈都會悄悄變成一件全新的手工製品,你既無法信任、也無法互相比較。

資料科學迴圈的環狀示意圖:提問、蒐集資料、清理、探索、建模、決策,然後回到提問。

手工作業有一個令人不安的算術。假設你的分析有 20 個手動步驟,而每次重跑時,你都能把每一步做得一模一樣——同樣的篩選、同樣的排序、選到同一格——可靠度是 95%。那麼整條鏈跑出完全相同結果的機率,就是 0.95 自乘 20 次。在看公式前先抓住這個直覺:每個小心翼翼的步驟幾乎都對,但錯誤會層層累積。

\Pr(\text{whole analysis reproduces}) = p^{\,n} = 0.95^{20} \approx 0.36

即使每步都有 95% 的可靠度,20 個手動步驟也只有約 36% 的機率能重現——大約每三次就有兩次拿不到相同答案。自動化不是吹毛求疵,而是讓這個算式對你有利的唯一辦法。

腳本勝過點擊

資料工作中最重要的一個習慣是:每一次轉換都應該是檔案裡的程式碼、能從頭到尾一次跑完,而不是一連串你得用力記住的滑鼠點擊。點擊不留痕跡;一行程式碼則是你所做之事的永久、精確、可分享的紀錄。當步驟都活在腳本(script)裡——一個從上到下讓電腦執行的純文字指令檔——重跑整份分析就變成一道指令,而不是一整個下午小心翼翼地重新點擊。

比較兩個世界。手動版:「我依日期排序、刪掉訂單編號空白的列、移除重複,然後做了一張樞紐表。」這句話不能執行、無法檢查、也無法精確重複。腳本版:同樣的邏輯寫成程式碼,任何人都能讀、能跑、能比對差異。下面就是把那段手動故事,變成一個小而誠實的可重現清理腳本。它讀取原始檔、絕不修改它,並另外寫出一個乾淨的檔案。

import pandas as pd

# raw data is read-only: we read it, we never overwrite it
raw = pd.read_csv("data/raw/orders.csv")

clean = (
    raw
    .drop_duplicates(subset="order_id")        # remove exact duplicate orders
    .dropna(subset=["order_id", "amount"])      # drop rows we cannot use
    .assign(amount=lambda d: d["amount"].astype(float))
)

# write a NEW derived file; the raw file is left untouched
clean.to_csv("data/clean/orders.csv", index=False)
你以前用手做的同一套清理,現在用程式碼寫一次。跑一百遍,每一遍都產生位元組完全相同的輸出。

一個腳本很好;一串能互相重建的腳本更好。一個小小的 Makefile(一個食譜檔,內容是「要產生這個輸出,就跑那道指令,但只在它的輸入有變動時才跑」)讓你用單一指令,就能依正確順序、從原始資料一路重建到最終報表。下面那些相依關係的意思是:乾淨檔依賴原始檔與清理腳本,而報表又依賴乾淨檔。

# `make all` rebuilds every output from raw data, in dependency order
all: reports/revenue.csv

data/clean/orders.csv: data/raw/orders.csv clean_orders.py
	python clean_orders.py

reports/revenue.csv: data/clean/orders.csv revenue.py
	python revenue.py
一道指令——`make all`——重跑整條鏈。改了原始檔再跑一次,就只有它下游的步驟會被重建。(每行食譜都用真正的 TAB 縮排;Makefile 對此堅持不讓。)
  1. 把你為了得到那個數字、手動做的每一個步驟,依序寫下來。
  2. 把每個步驟翻譯成腳本裡的一行或幾行程式碼。
  3. 讀取原始資料、寫出衍生資料——絕不覆蓋來源。
  4. 把試算表刪掉。若某個步驟不在腳本裡,那它就等於沒發生。
  5. 從乾淨狀態跑一次腳本,確認你得到相同的數字。

ETL 與 ELT:你什麼時候清理?

當你的步驟都變成腳本後,一個設計問題就浮現了:在這趟旅程的哪個環節,你才清理與重塑資料?兩個經典答案各有名字。ETL 是 Extract、Transform、Load(萃取、轉換、載入):你把資料從來源抽出,在途中先轉換(清理、合併、重塑),再把成品載入到一個集中的儲存處。ELT 把後兩個字母對調——Extract、Load、Transform(萃取、載入、轉換):你抽出資料,先把原始資料原封不動載入集中儲存處,然後才在那裡轉換它。名詞本身可看ETL 與 ELT;本節談的是「順序」為什麼重要。

用一家網路商店把它說具體。你有訂單躺在 App 的資料庫裡,廣告花費則躺在行銷 API 後面。在 ETL 的世界裡,一個 Python 工作會去抓這兩邊的資料,在記憶體中清理並合併,然後把一張整齊的「每日行銷投資報酬率」表寫進儲存處。在 ELT 的世界裡,你把這兩個來源——原始、未經修改——倒進一座資料倉儲(一個專為分析打造的集中式資料庫),然後寫 SQL 在倉儲裡,把原始表轉換成乾淨的 ROI 表。

ETL 在資料落地前就先轉換;ELT 先讓原始資料落地,再就地轉換。這個翻轉之所以發生,是因為雲端倉儲讓儲存變便宜、運算變強大——於是「先把一切原封保留、之後再用 SQL 清理」反而更省事。

並排的兩張流程圖:ETL(萃取、轉換、再載入)與 ELT(萃取、先載入原始資料、再在倉儲內轉換)。

為什麼業界大致從 ETL 翻轉到 ELT?雲端倉儲(想想 BigQuery、Snowflake)讓儲存變便宜、讓欄式運算快得驚人。一旦儲存原始資料幾乎不花錢,「先載入、後轉換」對可重現性就有兩大好處:你永遠保留原始資料,所以當某個轉換被發現做錯時,你可以從頭重新衍生出乾淨表;而且轉換邏輯以可讀的 SQL 形式存在,全團隊都能檢視、版本控制、重跑。下面是一個極小的 ELT 轉換。

-- ELT: the raw table is already loaded; we transform it with SQL
create or replace table analytics.clean_orders as
select
    order_id,
    cast(amount as float64) as amount,
    date(created_at)        as order_date
from raw.orders
where order_id is not null;
這張乾淨表本身就由一段活在版本控制裡的程式碼所定義。任何人都能讀懂 `clean_orders` 究竟是怎麼建出來的,並隨時從未經改動的 `raw.orders` 重新建好它。

資料倉儲的近親是資料湖(data lake)——一個更便宜的儲存處,在資料被結構化之前,先存放任何形狀的原始檔案(日誌、圖片、JSON)。許多團隊用資料湖作為原始資料的落地點、用倉儲做乾淨的分析,而像 dbt 這樣的工具,則把這些 SQL 轉換組織成一個整齊、有測試、懂相依關係的專案。你今天不必精通這些工具;你需要的是這個觀念:保留原始資料、用有版本的程式碼轉換,絕不用一次性的點擊。

以管線思考(以及步驟的有向無環圖)

當你有好幾個互相餵食的腳本時,你就有了一條資料管線(data pipeline)的雛形——一組自動化步驟,把原始輸入變成可信任的輸出,依排程執行或由事件觸發。讓管線變得可管理的心智模型是 DAG:directed acyclic graph,有向無環圖。聽起來嚇人,但它的意思不過是「一張用箭頭連起步驟的圖,而且箭頭永遠不會繞回去」。每個步驟是一個節點;從步驟 A 指向步驟 B 的箭頭,代表 B 需要 A 的輸出。

想像這家店的管線。原始訂單流入一個「清理訂單」步驟,再流入一個「每日營收」步驟。另外,原始廣告資料流入一個「清理廣告」步驟。最後,乾淨訂單與乾淨廣告一起流入一個「行銷 ROI 報表」。畫出來,就是一棵由箭頭構成、沒有任何循環的小樹。它的好處極大:當原始廣告來源一有變動,系統就知道它只需要重建廣告那條分支與最終報表——而不必動到沒變的訂單那一側。你不再用手把全部重建一遍,而是只重建確實變動的部分。

把管線畫成 DAG:每個方塊是一個步驟、每個箭頭是一個相依關係,沒有任何回圈。排程器會讀這張圖,算出該以什麼順序執行——更關鍵的是,當某個輸入變動時,要重跑的最小步驟集合是哪些。

一張管線階段的有向無環圖,箭頭從原始輸入、經過清理與轉換、連到最終報表。

那麼,誰來準時跑這張圖、又盯著它有沒有失敗?一個編排器(orchestrator)——像 Airflow、Dagster、Prefect 這類工具。見排程編排:它是讀你那張 DAG 的指揮,替每個步驟排程、在失敗時重試、並在昨晚那次執行壞掉時通知你。你宣告步驟與它們的相依關係,編排器則算出順序與重建邏輯,這樣你就不必把它全記在腦子裡。

綱要與資料合約

一條管線的可信度,頂多等於流經它的資料的可信度;而那個安靜的殺手,是「形狀無預警改變」的資料。防線是一個綱要(schema)——一張表被約定好的形狀:它的欄位名稱、每一欄的資料型別(整數、浮點數、文字、日期)、哪些欄絕不能為空,以及哪一欄是能唯一辨識一列的主鍵。綱要把你對資料模糊的期望,變成明確、可檢查的規則。

再在上面加一個承諾,你就得到一份資料合約(data contract):在「產生資料的團隊」(比如負責記錄每筆訂單的工程師)與「使用資料的團隊」(你,分析師)之間的協議。這份合約白紙黑字地說:「order_id 永遠是唯一、非空的整數;amount 永遠是以美元計的非負數字;currency 永遠是 TWD、USD、JPY 其中之一。」當大家都同意、而這份協議又由自動化檢查來執行時,上游的變動就再也無法悄悄腐蝕你的數字。

為什麼這件事這麼要緊?想像 App 團隊在一次看似無害的重構中,開始把 amount 以「分」而不是「美元」傳送。沒有任何東西當掉。那一欄仍是非負數字,所以一條天真的管線照單全收——而下游每一個營收數字,突然就大了 100 倍。一個知道預期範圍的合約測試,本可以在當晚就抓到它,趕在它出現在執行長面前的投影片之前。

import pandera as pa
from pandera import Column, Check

orders_contract = pa.DataFrameSchema({
    "order_id": Column(int, unique=True, nullable=False),
    "amount":   Column(float, Check.in_range(0, 100000)),   # dollars, sane range
    "currency": Column(str, Check.isin(["TWD", "USD", "JPY"])),
})

orders_contract.validate(clean)   # raises an error the moment the data breaks the contract
把資料合約寫成程式碼。在資料進入你管線的邊界放上這樣一個檢查,上游一旦有破壞性的變動,就會立刻大聲失敗,而不是無聲無息地毒害你的結果。
  1. 把綱要寫下來:欄位名稱、型別、可否為空,以及主鍵。
  2. 加上範圍與類別檢查,把「一個合理的值長什麼樣」編寫進去。
  3. 在管線的邊界、每次執行時,都自動跑這些檢查。
  4. 讓檢查失敗就中止管線——大聲失敗勝過無聲的損壞。
  5. 把合約和程式碼一起做版本控制,讓它的歷史也看得見。

同時為資料與程式碼做版本控制

要精確重現一個過去的結果,你需要找回的不是一樣、而是三樣東西:當時跑的程式碼、它跑在哪份資料上,以及它在什麼環境裡跑。三者缺一,「三月那個數字」就變得無法復原。程式碼是最簡單的部分——它放進 git,這個標準工具會記錄每個文字檔的每一個版本,讓你能簽出任何過去日期當時一模一樣的程式碼。

資料比較難。git 是為小型文字檔設計的,不是為數 GB 的資料集,碰到它就會噎住。標準答案是資料的版本控制:你不把資料塞進 git 本身,而是把那份確切資料快照的一個小指紋(內容雜湊值)存進 git,真正的位元組則放在便宜的儲存處。像 DVC、lakeFS 這類工具,以及 Delta、Iceberg 這類帶有「時光旅行(time travel)」功能的倉儲表格式,做的都是這件事的某種版本。重點都一樣:一次提交,就能把你釘在資料的某一個精確版本上。

# version the code in git as usual
git add clean_orders.py revenue.py Makefile
git commit -m "revenue pipeline v1"

# version the data by content: the bytes go to storage, a tiny pointer goes to git
dvc add data/raw/orders.csv
git add data/raw/orders.csv.dvc
git commit -m "snapshot raw orders 2026-03-01"
程式碼與資料指紋一起提交。日後簽出這次提交,就能同時還原出當時確切的腳本,以及指向它當時所用確切資料的指標。

第三條腿是環境——你所用的語言與函式庫的特定版本。用 pandas 1.5 算出的結果,可能和用 pandas 2.0 算出的不同;「在我電腦上明明可以跑」正是環境沒釘住的典型症狀。解方是把相依套件釘在一個鎖定檔裡(一個 requirements 檔,或一個記錄確切版本的鎖定檔),或者把整個環境裝進一個容器(container)出貨,讓同事在一模一樣的盒子裡跑你的程式碼。

  1. 釘住程式碼:每個腳本都提交到 git,並附上有意義的訊息。
  2. 釘住資料:記下這個結果所用的確切快照/版本。
  3. 釘住環境:一個鎖定檔或容器,內含確切的函式庫版本。
  4. 固定亂數種子,讓任何抽樣或洗牌都能重複。
  5. 使用相對路徑,而非 /Users/你/桌面——路徑必須在任何機器上都能運作。

可重現性檢查清單

我們把一切收攏成一份檢查清單,讓你在宣布一份分析「完成」之前,真的可以逐項核對。這些項目沒有一個是高深的;但合在一起,就是「一個你能捍衛的數字」與「一個你只是記得自己做過的數字」之間的差別。把這份清單當成標準,未來的你會為此感謝你。

  1. 原始資料是唯讀的,且記錄了它確切的快照/版本。
  2. 每一次轉換都是版本控制裡的程式碼——沒有任何手動的試算表編輯。
  3. 一道指令就能從原始資料一路重建到最終輸出。
  4. 管線是一張由冪等步驟構成的 DAG;重跑永遠安全。
  5. 綱要與資料合約的檢查,在每次執行時都在邊界跑一遍。
  6. 環境已釘住(鎖定檔或容器),亂數種子也已固定。
  7. 一份 README 讓新人不靠任何口耳相傳的知識就能跑起來。
  8. 整套東西在另一台機器上,從頭到尾都能重現。

這就為「資料整理」這條主線收尾了。你現在已經能把雜亂、有缺漏、形狀不對的資料變成整齊資料,小心地清理與合併(見清理遺漏值去重複合併),重塑與彙總(見樞紐分組彙總),並且——本篇的主題——把這一整套包進一條有腳本、有版本、可重現的管線裡。那麼這些管線究竟住在哪裡、又在哪裡執行呢?那就是下一條主線:現代資料技術棧,在那裡,倉儲、SQLdbt、排程編排,以及通往生產環境機器學習的那條路,會匯聚在一起。