AI 互動教室 ‹ MLOps 自動化技術
下載 .py 開啟實戰 notebook ↗ 留言回報
DATA VERSIONING · 補充 G · 12

DVC 資料版本控制:
資料與模型也要有 git

你的程式碼有 git,每一行改動都查得到。那資料呢?300 MB 的訓練資料丟進 git,clone 一次十分鐘、 git diff 給你看兩百萬行亂碼;500 MB 的模型檔每訓練一次多一份,半年後 repo 變 40 GB。 於是大家的資料夾裡開始出現 raw_final_v2_修正版.csv。 DVC 的解法只有一句話:git 只存一個小小的指標檔,真正的檔案放在別的地方。 這台時光機是 notebook 跑出來的四個 commit——點一個版本,看 git 裡到底存了什麼:

四個 COMMIT(點一個看內容)
GIT 裡存的
.DVC/CACHE 裡存的
DVC CHECKOUT 之後的工作區

那「要不要重跑」呢?改一樣東西,按下 DVC REPRO

md5、檔案大小、AUC、每一行終端機輸出都是 notebook 的實測結果(DVC 3.67.1); 你自己跑會拿到一樣的 md5——這正是「內容定址」的意思。

01 · 為什麼

git 不是設計來裝資料的

git 之所以快,是因為它假設你放的是文字:它會存差異、會壓縮、會讓你看得懂每一次改了什麼。 二進位大檔完全踩在這些假設的反面——存不了差異(每一版都是完整一份)、壓不動、 diff 出來沒有意義,而且 git 的歷史是不會縮小的:你今天刪掉那個 800 MB 的檔案, 它還是躺在每一個人的 .git 裡,永遠。

DVC 的做法是把「檔案」跟「這是哪一版檔案」拆成兩件事。git 只負責後者—— 存一個記著 md5 的小文字檔;檔案本身交給 DVC 放進 cache 與遠端儲存。本課的實測:

大小誰在管
訓練資料 data/raw.csv470,591 bytes(460 KB)DVC(cache + 遠端)
指標檔 data/raw.csv.dvc89 bytesgit
跑完四個版本後的 .git348 KBgit
同時期的 .dvc/cache2,092 KBDVC

把 460 KB 換成 460 GB,.git 還是那個大小——指標檔的大小跟資料完全無關。 這就是全部的魔法,剩下的都是這件事的推論。

那 Git LFS 呢?LFS 解決的是同一個容量問題,但它到此為止。DVC 多做了兩件事: 遠端可以是你自己的 S3/GCS/NAS,不必買 LFS 的儲存配額; 它還管管線——哪一版資料配哪一版程式產出哪一個模型、以及「這一步到底要不要重跑」。 後面五節講的全是這一半,而那一半 LFS 沒有。

到 notebook 的 0️⃣ 節:開一個真的 git repo,跑 dvc init
02 · DVC ADD

一個指令,三件事

$ dvc add data/raw.csv To track the changes with git, run: git add data/.gitignore data/raw.csv.dvc

dvc add 做完這三件事:算出檔案的 md5 並把檔案本身複製進 .dvc/cache、產生指標檔 data/raw.csv.dvc、 在 data/.gitignore 加一行 /raw.csv 叫 git 不要碰這個大檔。指標檔整個就這 5 行:

outs: - md5: 8445065497d9a2aa59d1ee84e100dc5d size: 470591 hash: md5 path: raw.csv

然後看 cache 裡那個檔名:.dvc/cache/files/md5/84/45065497d9a2aa59d1ee84e100dc5d—— 前兩碼當資料夾、剩下的當檔名,合起來就是指標檔裡那串 md5。這叫內容定址,兩個直接的好處: 同樣的內容只會存一份(十個人各自 add 同一份資料,cache 裡還是一份); 檔案在不在、有沒有被動過,比對 md5 就知道,不需要相信檔名或修改時間

資料改了會怎樣?notebook 裡把 2000 列中的一格標籤翻掉,重新 dvc add, 然後看 git diff

$ git diff data/raw.csv.dvc @@ -1,5 +1,5 @@ outs: -- md5: 8445065497d9a2aa59d1ee84e100dc5d +- md5: 464179a42e1b28ead96babdf5cdaa79e size: 470591 hash: md5 path: raw.csv

整個 diff 就是一行。資料的改動在 code review 裡是一行,不是兩百萬行亂碼—— 但那一行足以精確指出「是哪一份資料」。同時 cache 裡變成兩份內容: 舊的沒有被覆蓋掉,所以下一節回得去。

到 notebook 的 1️⃣ 節:指標檔、.gitignore、cache 一次看完
03 · 回溯

切版本永遠是兩步

這是新手最容易掉進去的一個洞,先把規則寫死: git 管指標檔,DVC 管檔案本身,所以切版本要兩個動作。

$ git checkout e713732 -- data/raw.csv.dvc # ← 只有指標檔回到 v1 $ dvc status data/raw.csv.dvc: changed outs: modified: data/raw.csv # ← DVC 在說:檔案跟指標檔對不上了 $ dvc checkout # ← 資料才真的變回去 M data/raw.csv

中間那個狀態很危險:指標檔已經是 v1 的,但 data/raw.csv 還是新的那一份, 而 git 一句話都不會說——它根本不知道有這個檔案存在。你會拿舊程式配新資料訓練, 然後對著一個「重現不出來」的數字抓半天。

唯一會告訴你的是 dvc status。所以請把它變成肌肉記憶: 每次 git checkout 之後,接一句 dvc status; 看到 modifieddvc checkout。 還原是從 cache 直接複製的,不重算、不連網,大檔案也是一瞬間。

到 notebook 的 2️⃣ 節:把「中間狀態」實際演一次
04 · 管線

dvc.yaml:把「模型是怎麼來的」寫下來

版本控制只解決了一半的問題。另一半是:這個模型是怎麼來的? dvc.yaml 用很像 Makefile 的方式描述一條管線。一個 stage 要講四件事:

欄位意思為什麼重要
cmd要跑的指令就是你平常在終端機打的那行,程式完全不用知道 DVC 存在
deps這一步吃什麼(資料、程式)任何一個變了才需要重跑
paramsparams.yaml 讀哪些設定參數變了也要重跑;沒宣告的參數 DVC 看不見
outs / metrics這一步吐出什麼產物交給 DVC 管:自動進 cache、自動 gitignore
stages: train: cmd: python train.py deps: - data/raw.csv - train.py params: - train.max_depth - train.n_estimators outs: - model.pkl # ← 模型檔也歸 DVC 管,這就是課名的「模型」那一半 metrics: - metrics.json

dvc repro 會走過每個 stage,比對 depsparams 的 md5:對得上就跳過,對不上才跑 cmd。 實測第一次 2.2–2.6 秒(真的訓練),第二次 0.5–0.6 秒、什麼都沒跑:

$ dvc repro 'data/raw.csv.dvc' didn't change, skipping Stage 'train' didn't change, skipping Data and pipelines are up to date.

跑完會多一個 dvc.lock——這次執行的收據:每一個依賴、每一個參數、 每一個產物的 md5 全部記在裡面。它很小,要進 gitmodel.pklmetrics.json 則被自動寫進 .gitignore。於是任何一個 commit 都完整回答了 「這個模型是用哪一版資料、哪些參數、哪一版程式跑出來的」。

到 notebook 的 3️⃣ 節:寫 dvc.yaml、跑 repro、讀 dvc.lock
05 · 只重跑該跑的

管線的價值不是「一鍵重跑」,是「不跑」

真實管線不會只有一步。notebook 把它拆成兩個 stage:prepare 讀原始資料切成訓練/測試集(2000 列 → train 1500 / test 500),train 讀那兩個檔訓練。 你不用宣告執行順序——DVC 從「誰吃誰的產物」自己推出來, dvc dag 畫出來的圖因此永遠跟真正跑的東西一致,不會像投影片上的架構圖那樣過期。

$ dvc dag +------------------+ | data/raw.csv.dvc | +------------------+ * +---------+ | prepare | +---------+ * +-------+ | train | +-------+

拆開之後最有感的一件事:只改參數,切分資料那一步不會重做

$ dvc repro # 只把 max_depth 改成 8 'data/raw.csv.dvc' didn't change, skipping Stage 'prepare' didn't change, skipping # ← 依賴一個字都沒變 Running stage 'train': > python train.py auc = 0.97016

實測:只重跑 train 是 1.9 秒;改到資料、兩個 stage 都要跑是 4.9 秒。 這裡差的是三秒,在真實專案裡是三十秒與三小時的差別—— 而且管線越長、前面的步驟越重(資料清洗、特徵工程、embedding),省下來的越多。

還有一個更狠的:把 max_depth 改回剛剛跑過的值再 repro——

$ dvc repro Stage 'prepare' didn't change, skipping Stage 'train' is cached - skipping run, checking out outputs

is cached:這組依賴+參數的組合 DVC 以前算過, 它直接把當時的產物從 cache 撈回來,連跑都不用跑。這叫 run cache—— 來回比較兩組參數時,第二次之後都是瞬間完成。

到 notebook 的 5️⃣ 節:兩個 stage、dag、skip 與 run cache
06 · 比較

這次改動換到了什麼?

改完參數重跑之後,兩個指令直接告訴你差在哪——比的是工作區HEAD(上一個 commit)

$ dvc params diff Path Param HEAD workspace params.yaml train.max_depth 4 12 $ dvc metrics diff Path Metric HEAD workspace Change metrics.json auc 0.95318 0.97287 0.01969

Change 那一欄是 DVC 幫你算的,可以直接貼進 PR 描述裡。 注意這一輪沒有任何一份資料被複製——資料的 md5 沒變,DVC 認得出來是同一份。 參數的版本與資料的版本,是分開追蹤的兩件事。

但如果你想試十二組參數呢?照上面那套「改 params.yaml → repro → commit」, 你的 git 歷史會多出十二個沒人想看的 commit。dvc exp run 就是為這件事存在的:

$ dvc exp run --set-param train.max_depth=6 $ dvc exp run --set-param train.max_depth=20 $ dvc exp show --only-changed Experiment Created auc train.max_depth workspace - 0.97063 20 master 05:15 AM 0.97287 12 ├── d4186d7 [blunt-torc] 05:15 AM 0.97063 20 └── 9285a95 [gaunt-debs] 05:15 AM 0.96376 6

它暫時換掉參數、跑一次管線、把結果存成一個實驗(掛在 git 上但不是 commit), dvc exp show 再把它們排成一張表(--only-changed 讓表只留有變動的欄位,不然沒動的參數會塞滿畫面)。 試錯的過程不會弄髒 git 歷史;覺得某一組值得留下來, dvc exp branch <實驗名> 把它變成真正的分支。

到 notebook 的 4️⃣ 與 6️⃣ 節:diff 與 dvc exp
07 · 遠端

同事 clone 完之後,資料從哪來

到這裡為止,資料都只在你這台機器的 .dvc/cache 裡。 同事把 repo clone 下來只會拿到指標檔——這就是 remote 的工作:一個放「內容」的地方。 它可以是 S3、GCS、Azure、SSH、HTTP,也可以只是一個資料夾(NAS、共用磁碟機都算)。 對 DVC 來說差別只有那一行網址:

dvc remote add -d storage s3://my-bucket/dvcstore dvc remote add -d storage gs://my-bucket/dvcstore dvc remote add -d storage ssh://user@host/path dvc remote add -d storage /mnt/nas/dvcstore # 本課用這種,看得見裡面 $ dvc push 5 files pushed

-d 是 default 的意思,設定寫進 .dvc/config—— 這個檔會進 git,所以團隊裡每個人 clone 完自動吃到同一個遠端。 打開遠端資料夾看,裡面是跟 cache 一模一樣的 files/md5/46/4179a42e1b28… 結構:遠端就是 cache 的另一份拷貝, 沒有資料庫、沒有中繼服務。換成 S3 的話,這些就是 bucket 裡的 object key。

notebook 接著把 .dvc/cache 與所有資料檔整個刪掉, 模擬「同事剛 clone 完」的狀態,然後:

$ dvc pull A data/raw.csv A data/test.csv A data/train.csv A model.pkl 5 files fetched and 4 files added

資料與模型都回來了。所以新同事的完整流程就是三行git clonecddvc pull。 沒有「請找 Alice 要那份 CSV」,也沒有 raw_final_v2_真的最終版.csv但這也代表一件事git push 之後別忘了 dvc push—— 只推指標檔不推內容,對方 pull 下來只會拿到一句「檔案不在本機也不在遠端」。

到 notebook 的 7️⃣ 節:push、刪光、pull 回來
08 · 分工

那 MLflow 呢?兩個一起用,接點只有一行

這個主題的第 1、2 課你用 MLflow 記過訓練。DVC 跟 MLflow 不是二選一——它們管的是不同的東西:

DVCMLflow
管什麼檔案的版本、管線的可重現實驗的紀錄、模型的註冊
綁在哪git commitrun id
典型問題「上個月那版資料在哪?」「這步要不要重跑?」「哪一次跑的 AUC 最高?當時參數是什麼?」
存什麼資料、模型檔、中間產物(內容定址)params、metrics、artifacts、模型版本與 alias
誰在用整個團隊共用一份資料每個人的每一次訓練
md5 = yaml.safe_load(open("data/raw.csv.dvc"))["outs"][0]["md5"] with mlflow.start_run(): mlflow.log_param("data_md5", md5) # 用了哪一份資料 mlflow.log_param("git_commit", git_short_sha()) # 用了哪一版程式 mlflow.log_metric("auc", metrics["auc"])

就這一行。半年後看到某個 run,你可以 git checkout <git_commit> 拿回當時的程式與指標檔、 dvc checkout 拿回當時的資料、dvc repro 重跑一次—— 而且因為 md5 對得上,你會拿到一模一樣的模型。 這就是「可重現」的完整定義:程式、資料、參數、產物四樣都能指名道姓,少一樣都做不到。

到 notebook 的 8️⃣ 節:把 md5 記進 MLflow run
09 · 實戰

換你動手

notebook 的 9️⃣ 節有一個小工具:選一種改動、按下去,它會先把工作區還原、套用改動、真的執行一次 dvc repro按之前先自己猜:哪些 stage 會重跑、哪些會 skip? 猜完再看三個挑戰:

LEVEL 1

n_estimators 從 50 改成 200,用 dvc params diffdvc metrics diff 看多花的時間換到多少 AUC。進階:加一個全新的參數 min_samples_leaf——想清楚要改幾個地方,少改一個會怎樣。

LEVEL 2

加第三個 stage evaluate:讀 model.pkldata/test.csv,把 ROC 曲線寫成 plots/roc.csv,並在 dvc.yamlplots: 宣告它。跑完看 dvc dag 多了什麼、dvc plots show 產出什麼。

LEVEL 3

不用自己 clone,直接從別人的 repo 拿一份 DVC 管的資料:研究 dvc getdvc import 的差別,說明什麼情況該用哪一個,並找出 import 產生的檔案裡「記住來源版本」的是哪一個欄位。

卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。

10 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

團隊的 repo 裡有一個 800 MB 的訓練資料檔,已經 commit 進 git 半年了,現在 clone 一次要十分鐘。你要怎麼收拾?

A 是最常見的誤解:git 的歷史不會縮小——刪掉只是新增一個「這個檔不見了」的 commit,那 800 MB 還是躺在每個人的 .git 裡(要真的移除得改寫歷史,全團隊重新 clone)。所以 C 的第一步 git rm -r --cached 只是「別再往前疊」,它解決的是未來;而把資料交給 DVC 之後,往後每一版資料在 git 裡都只有一行 md5 的差異。B 確實讓 repo 變小,但也把版本資訊整個丟掉了——半年後沒人答得出「當時那個模型是用哪一版資料訓的」,而這正是這堂課要解決的問題。D 壓縮救不了:CSV 壓完還是幾百 MB,而且壓縮後的二進位每改一格就整個變一份,git 反而更存不動。

Q2 錯誤診斷

你在一個既有專案裡第一次跑 dvc add,得到這個錯誤。最直接的修法是?

$ dvc add data/raw.csv ERROR: output 'data/raw.csv' is already tracked by SCM (e.g. Git). You can remove it from Git, then add to DVC. To stop tracking from Git: git rm -r --cached 'data/raw.csv' git commit -m "stop tracking data/raw.csv"

DVC 擋下來是有道理的:同一個檔不能同時被 git 和 DVC 追蹤,兩邊都想決定「這個檔該長什麼樣」,一 checkout 就打架。訊息本身已經把解法印出來了——git rm -r --cached 只把它從 git 的索引移除,檔案本身不會被刪(少了 --cached 才會真的刪檔,這是要看清楚的一個字)。C 是很多人的第一反應,但 .gitignore已經被追蹤的檔案無效——git 只用它來決定要不要追蹤「新」檔案,所以錯誤照樣出現。A 跟這個錯誤完全無關,DVC 的設定好好的。D 的 --force 在這裡繞不過去,因為衝突在 git 那邊不在 DVC 這邊。

Q3 情境題

同事照著 README 做「重現三個月前那次實驗」:git checkout v1.2 之後 python train.py,AUC 卻跟當時記錄的 0.953 差很多。過程中沒有任何錯誤訊息、沒有例外。第一件該做的事是?

「沒有錯誤訊息但數字對不上」是這個坑的招牌症狀。git checkout 只換了指標檔,data/raw.csv 仍然是最新那一份——而 git 不會警告你,因為它根本不知道有這個檔(它被寫在 data/.gitignore 裡)。唯一會講話的是 dvc status,它會印 modified: data/raw.csv;接著 dvc checkout 就從 cache 還原了。A 值得懷疑但排在後面:如果是套件升級,通常整組指標一起變,而且 dvc.lock 已經釘住了資料與參數,先排除便宜的可能性再說。B 是把問題掩蓋掉——本課的訓練有固定 random_state,同樣輸入就該有同樣輸出,「取平均」只會讓你永遠查不出根因。C 沒有根據,tag 指的 commit 本來就對,錯的是「工作區只回去了一半」。

Q4 錯誤診斷

你把新版資料 commit 完 git push,同事 clone 下來跑 dvc pull 拿到這個。原因與修法是?

$ dvc pull Everything is up to date. WARNING: Some of the cache files do not exist neither locally nor on remote. Missing cache files: md5: 65eea634c19580aa1c6fec509f4a181f ERROR: failed to pull data from the cloud - Checkout failed for following targets: data/raw.csv Is your cache up to date? <https://error.dvc.org/missing-files>

訊息直接說了:「這些 cache 檔在本機和遠端都不存在」,然後指名缺的那個 md5。git 與 DVC 是兩條各自獨立的推送管道——git push 送走的是指標檔與 dvc.lock460 MB 的內容要靠 dvc push 才會上遠端。把 dvc push 加進你的收工習慣(或做成 pre-push hook / CI 的一步)就不會再犯。B 症狀不符:真的沒設遠端時錯誤是 config file error: no remote specified,而且遠端設定寫在會進 git 的 .dvc/config 裡,同事 clone 完就有了。C 沒有根據——md5 是算出來的不會「算錯」,重新 add 只會產生一模一樣的指標檔。D 順序反了也沒用:dvc checkout 是從本機 cache 還原,同事的 cache 裡根本沒有這份內容。

Q5 情境題

你要試 12 組 max_depth,最後只留最好的那一組。前面 4 個 stage(下載、清洗、特徵、切分)跑一輪要 20 分鐘,訓練只要 2 秒。最有效率、又不會弄髒 git 歷史的做法是?

兩件事同時成立才是好答案。效率max_depth 只是 train 這一個 stage 的參數,前 4 個 stage 的依賴完全沒變,DVC 會一路印 didn't change, skipping——20 分鐘只付一次,之後每組只花 2 秒。乾淨dvc exp run 把每次結果存成掛在 git 上的實驗而不是 commit,dvc exp show 一張表比完,只有贏家用 dvc exp branch 變成真正的分支。A 能動但 12 個分支的管理成本遠高於 12 個實驗,而且 merge 一堆 dvc.lock 是自找麻煩。C 最快但把管線繞過去了:這 12 次跑用的是哪一版資料、哪一版特徵程式,沒有任何紀錄,最後你會有一堆對不上來源的數字。D 弄髒歷史,而且 metrics diff 一次只比兩個版本,12 組要比 11 次。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;整份跑完約 40–60 秒,所有檔案都寫在暫存資料夾裡

不想用 molab?下載 dvc-basics_ext.py 後在自己電腦 uvx marimo edit --sandbox dvc-basics_ext.py,依賴會自動安裝。

molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。