昨天的成熟度診斷中,其中「訓練前自動檢查資料」與「重現三個月前的訓練」應是最多團隊答「否」的兩題。今天就處理這兩題。
Day 06 說過可重現需要五根柱子,今天說明資料版本與隨機種子這根支柱,也順便處理一個棘手的問題。
「我照著三個月前的 commit 重跑,結果不一樣。」
程式碼一樣、套件版本一樣、seed 也固定了,為什麼結果不同?因為 data/train.csv 這個檔案在這三個月內被人更新過兩次,而 Git 裡沒有它的任何紀錄——它被 .gitignore 擋掉了(Day 05 我們才剛這樣做)。
「模型離線 AUC 0.91,上線後只有 0.78。」
這個更麻煩,因為它不會報錯。等到有人發現時,通常已經跑了幾個月。
這兩個問題,就是今天的主題。
📊 職缺訊號
「訓練管線與 CI/CD」出現在 42.6% 的 MLOps 職缺中,而資料驗證是這條管線的第一道關卡。實務上的比例更誇張:Day 06 那張圖顯示資料相關工作佔專案總工作量近三成,是模型程式碼的六倍。

圖 9-1:資料問題的三種來源(示意流程)。問題 1、2 來自上游變動,問題 3 來自訓練與服務兩條路徑的實作差異。
NULL 改成空字串。訓練時沒發現,因為 pandas 很包容。第三個最陰險,因為它完全不會報錯。經典案例:訓練時「近 30 日消費總額」用完整月結資料算,線上取的卻是尚未結算的即時累計值——同一個特徵名,兩個意思。
DVC 的設計很聰明:Git 管小檔、遠端儲存管大檔,兩者用雜湊對上。

圖 9-2:DVC 的運作方式(示意流程)。Git 只存指標檔,實際資料存在遠端;git checkout 加 dvc pull 就能還原任一時間點的「程式碼+資料」組合。
關鍵在於:.dvc 檔案(只有幾百 bytes,內含資料的 md5 雜湊)進 Git。所以當你 git checkout 到三個月前的 commit,再 dvc pull,拿到的就是當時那份資料。
uv add dvc dvc-s3 # 或 dvc-gs / dvc-azure,依你的儲存而定
dvc init # 會建立 .dvc/ 並自動處理 .gitignore
# 把資料交給 DVC 管
dvc add data/train.csv # 產生 data/train.csv.dvc,並把原檔加進 .gitignore
git add data/train.csv.dvc data/.gitignore
git commit -m "資料集 v1:2026-01 快照"
# 設定遠端儲存並推送
dvc remote add -d storage s3://my-bucket/dvc-store
dvc push
# ── 三個月後要重現當時的結果 ──
git checkout <當時的 commit>
dvc pull # 資料自動還原成當時那一份
💡 搭配 Git tag 使用
每次資料集有意義的更新就打 tag(
git tag data-v2 && git push --tags),日後「用 data-v2 重跑」就是一句話的事。這比在檔名寫train_final_v2_真的最終版.csv可靠得多。
驗證的重點不是「檢查所有東西」,而是把你的假設寫成程式碼。用 pandera 示範:
"""validate.py —— 訓練前的資料契約檢查。放在管線的第一步。"""
import pandera.pandas as pa
from pandera import Column, Check
schema = pa.DataFrameSchema({
# 型別與值域:抓「上游改了型別」與「出現不可能的值」
"user_id": Column(int, nullable=False, unique=True),
"age": Column(int, Check.in_range(0, 120), nullable=True),
"amount_30d": Column(float, Check.ge(0), nullable=False),
"channel": Column(str, Check.isin(["web", "app", "store", "line"])),
"label": Column(int, Check.isin([0, 1])),
}, strict=True) # strict=True:出現預期外的欄位也要報錯
def validate(df, ref_stats=None):
df = schema.validate(df, lazy=True) # lazy:一次回報所有錯誤,而非第一個
# 契約之外,再加三個「統計層」的守門條件
null_rate = df["amount_30d"].isna().mean()
assert null_rate < 0.05, f"amount_30d 缺值率 {null_rate:.1%} 超過 5%"
pos_rate = df["label"].mean()
assert 0.01 < pos_rate < 0.5, f"正樣本比例 {pos_rate:.1%} 異常,疑似標籤產生錯誤"
if ref_stats: # 與上一版比較,抓「格式沒變但內容變了」
drift = abs(df["amount_30d"].mean() - ref_stats["amount_30d_mean"])
assert drift / ref_stats["amount_30d_mean"] < 0.3, "amount_30d 均值偏移超過 30%"
return df
這段程式碼的價值不在語法,在於那三個 assert。 它們是把「我以為的資料長這樣」變成可執行的契約。每次線上事故後,就往這裡加一條——這份檔案會慢慢變成團隊對資料理解的活文件。
根本解法是讓訓練與線上共用同一份特徵計算邏輯。最小可行做法不需要導入 Feature Store:
# features.py —— 訓練與線上服務都 import 這一個檔案,不准各寫一份
from datetime import timedelta
def amount_30d(txns, as_of):
"""近 30 日消費總額。
as_of 一定要由呼叫端傳入——訓練時傳「當時的時間點」,線上傳「現在」。
這個參數就是防止時間洩漏的關鍵:訓練時絕不能用 as_of 之後的交易。
"""
window = txns[(txns.ts <= as_of) & (txns.ts > as_of - timedelta(days=30))]
return float(window.amount.sum())
📌 重點在
as_of這個參數多數偏斜與時間洩漏,都源自「訓練時不小心用了未來的資料」。把時間點變成顯式參數,讓兩邊都必須明講「以什麼時間為準」,問題就消失一大半。這比導入任何工具都有效。
當團隊規模成長、特徵開始被多個模型共用時,才值得導入特徵存放區(Feature Store):

圖 9-3:特徵存放區的線上/離線雙路徑(示意架構)。核心價值是「一次定義、兩處供應」:離線路徑供訓練批次讀取、線上路徑供推論低延遲查詢,兩者由同一份定義產出。
| 需求 | 最小方案 | 完整方案 | 什麼時候升級 |
|---|---|---|---|
| 資料版本 | Git tag + 檔案雜湊記錄 | DVC + 遠端儲存 | 資料超過 100MB 或需要多人協作 |
| 資料驗證 | 幾行 assert | pandera / Great Expectations | 資料來源超過 3 個或曾出過事故 |
| 特徵一致性 | 共用 features.py |
Feature Store(Feast 等) | 特徵被 3 個以上模型共用 |
| 資料血緣 | README 寫清楚 | 資料目錄工具 | 稽核或法遵有要求 |
其一:資料落地與內網限制。 半導體、製造與金融業的資料經常不允許離開內網,這讓「把資料放雲端物件儲存」的標準做法行不通。可行的替代是把 DVC 的遠端指向內部的 MinIO 或 NAS——DVC 對後端是抽象的,換一個 remote 設定就好,這也是我建議用 DVC 而非綁定特定雲端服務的原因之一。
其二:個資與去識別化的時間點。 很多團隊在訓練前才做去識別化,但這代表原始個資已經被複製到分析環境了。正確的做法是在資料進入分析環境之前就完成遮罩或假名化,讓分析環境從頭到尾都不持有可識別資料。這件事在 Day 27 談治理時會再細講,但要在建資料管線的第一天就決定,因為事後補救等於重建整條管線。
技術上的版本控制解決「用了哪份資料」,但解決不了「為什麼當初決定這樣處理」。例如:為什麼把年齡超過 100 的樣本刪掉?為什麼某個通路的資料從三月起才納入?這些決策當下都有理由,三個月後全部忘記,於是下一個人要嘛不敢動,要嘛動了之後出事。
實務做法很簡單:在資料集的 .dvc 檔旁邊放一份 dataset-card.md,記錄資料來源、涵蓋期間、已知問題、處理決策與其理由。這份文件的維護成本一次不到十分鐘,但它是新人接手時唯一能救命的東西。
⚠️ 不要一開始就上 Feature Store
我看過團隊在只有一個模型時就導入完整的特徵平台,結果維護成本高於解決的問題。Feature Store 解決的是「多模型、多團隊共用特徵」的問題;只有一個模型時,一份共用的
features.py就夠了。反過來說,資料驗證從第一天就該有,因為它的成本只有幾行 assert,擋掉的卻是最常見的線上事故。
git checkout + dvc pull 就能還原任一時間點的程式碼與資料組合。as_of 時間點變成顯式參數。資料有版本了,明天輪到實驗與模型。我們會用 MLflow 把「這次訓練用了什麼參數、跑出什麼指標、產出哪個模型」全部記下來,並用模型註冊表的 champion/challenger 別名,做到換模型不用改程式碼、回滾只要一行指令。
dvc add 與 dvc pull 兩節即可上手