DVC 資料版本控制:
資料與模型也要有 git
你的程式碼有 git,每一行改動都查得到。那資料呢?300 MB 的訓練資料丟進 git,clone 一次十分鐘、 git diff 給你看兩百萬行亂碼;500 MB 的模型檔每訓練一次多一份,半年後 repo 變 40 GB。 於是大家的資料夾裡開始出現 raw_final_v2_修正版.csv。 DVC 的解法只有一句話:git 只存一個小小的指標檔,真正的檔案放在別的地方。 這台時光機是 notebook 跑出來的四個 commit——點一個版本,看 git 裡到底存了什麼:
md5、檔案大小、AUC、每一行終端機輸出都是 notebook 的實測結果(DVC 3.67.1); 你自己跑會拿到一樣的 md5——這正是「內容定址」的意思。
git 不是設計來裝資料的
git 之所以快,是因為它假設你放的是文字:它會存差異、會壓縮、會讓你看得懂每一次改了什麼。 二進位大檔完全踩在這些假設的反面——存不了差異(每一版都是完整一份)、壓不動、 diff 出來沒有意義,而且 git 的歷史是不會縮小的:你今天刪掉那個 800 MB 的檔案, 它還是躺在每一個人的 .git 裡,永遠。
DVC 的做法是把「檔案」跟「這是哪一版檔案」拆成兩件事。git 只負責後者—— 存一個記著 md5 的小文字檔;檔案本身交給 DVC 放進 cache 與遠端儲存。本課的實測:
| 大小 | 誰在管 | |
|---|---|---|
| 訓練資料 data/raw.csv | 470,591 bytes(460 KB) | DVC(cache + 遠端) |
| 指標檔 data/raw.csv.dvc | 89 bytes | git |
| 跑完四個版本後的 .git | 348 KB | git |
| 同時期的 .dvc/cache | 2,092 KB | DVC |
把 460 KB 換成 460 GB,.git 還是那個大小——指標檔的大小跟資料完全無關。 這就是全部的魔法,剩下的都是這件事的推論。
那 Git LFS 呢?LFS 解決的是同一個容量問題,但它到此為止。DVC 多做了兩件事: ① 遠端可以是你自己的 S3/GCS/NAS,不必買 LFS 的儲存配額; ② 它還管管線——哪一版資料配哪一版程式產出哪一個模型、以及「這一步到底要不要重跑」。 後面五節講的全是這一半,而那一半 LFS 沒有。
到 notebook 的 0️⃣ 節:開一個真的 git repo,跑 dvc init一個指令,三件事
dvc add 做完這三件事:算出檔案的 md5 並把檔案本身複製進 .dvc/cache、產生指標檔 data/raw.csv.dvc、 在 data/.gitignore 加一行 /raw.csv 叫 git 不要碰這個大檔。指標檔整個就這 5 行:
然後看 cache 裡那個檔名:.dvc/cache/files/md5/84/45065497d9a2aa59d1ee84e100dc5d—— 前兩碼當資料夾、剩下的當檔名,合起來就是指標檔裡那串 md5。這叫內容定址,兩個直接的好處: 同樣的內容只會存一份(十個人各自 add 同一份資料,cache 裡還是一份); 檔案在不在、有沒有被動過,比對 md5 就知道,不需要相信檔名或修改時間。
資料改了會怎樣?notebook 裡把 2000 列中的一格標籤翻掉,重新 dvc add, 然後看 git diff:
整個 diff 就是一行。資料的改動在 code review 裡是一行,不是兩百萬行亂碼—— 但那一行足以精確指出「是哪一份資料」。同時 cache 裡變成兩份內容: 舊的沒有被覆蓋掉,所以下一節回得去。
到 notebook 的 1️⃣ 節:指標檔、.gitignore、cache 一次看完切版本永遠是兩步
這是新手最容易掉進去的一個洞,先把規則寫死: git 管指標檔,DVC 管檔案本身,所以切版本要兩個動作。
中間那個狀態很危險:指標檔已經是 v1 的,但 data/raw.csv 還是新的那一份, 而 git 一句話都不會說——它根本不知道有這個檔案存在。你會拿舊程式配新資料訓練, 然後對著一個「重現不出來」的數字抓半天。
唯一會告訴你的是 dvc status。所以請把它變成肌肉記憶: 每次 git checkout 之後,接一句 dvc status; 看到 modified 就 dvc checkout。 還原是從 cache 直接複製的,不重算、不連網,大檔案也是一瞬間。
到 notebook 的 2️⃣ 節:把「中間狀態」實際演一次dvc.yaml:把「模型是怎麼來的」寫下來
版本控制只解決了一半的問題。另一半是:這個模型是怎麼來的? dvc.yaml 用很像 Makefile 的方式描述一條管線。一個 stage 要講四件事:
| 欄位 | 意思 | 為什麼重要 |
|---|---|---|
| cmd | 要跑的指令 | 就是你平常在終端機打的那行,程式完全不用知道 DVC 存在 |
| deps | 這一步吃什麼(資料、程式) | 任何一個變了才需要重跑 |
| params | 從 params.yaml 讀哪些設定 | 參數變了也要重跑;沒宣告的參數 DVC 看不見 |
| outs / metrics | 這一步吐出什麼 | 產物交給 DVC 管:自動進 cache、自動 gitignore |
dvc repro 會走過每個 stage,比對 deps 與 params 的 md5:對得上就跳過,對不上才跑 cmd。 實測第一次 2.2–2.6 秒(真的訓練),第二次 0.5–0.6 秒、什麼都沒跑:
跑完會多一個 dvc.lock——這次執行的收據:每一個依賴、每一個參數、 每一個產物的 md5 全部記在裡面。它很小,要進 git; model.pkl 與 metrics.json 則被自動寫進 .gitignore。於是任何一個 commit 都完整回答了 「這個模型是用哪一版資料、哪些參數、哪一版程式跑出來的」。
到 notebook 的 3️⃣ 節:寫 dvc.yaml、跑 repro、讀 dvc.lock管線的價值不是「一鍵重跑」,是「不跑」
真實管線不會只有一步。notebook 把它拆成兩個 stage:prepare 讀原始資料切成訓練/測試集(2000 列 → train 1500 / test 500),train 讀那兩個檔訓練。 你不用宣告執行順序——DVC 從「誰吃誰的產物」自己推出來, dvc dag 畫出來的圖因此永遠跟真正跑的東西一致,不會像投影片上的架構圖那樣過期。
拆開之後最有感的一件事:只改參數,切分資料那一步不會重做。
實測:只重跑 train 是 1.9 秒;改到資料、兩個 stage 都要跑是 4.9 秒。 這裡差的是三秒,在真實專案裡是三十秒與三小時的差別—— 而且管線越長、前面的步驟越重(資料清洗、特徵工程、embedding),省下來的越多。
還有一個更狠的:把 max_depth 改回剛剛跑過的值再 repro——
is cached:這組依賴+參數的組合 DVC 以前算過, 它直接把當時的產物從 cache 撈回來,連跑都不用跑。這叫 run cache—— 來回比較兩組參數時,第二次之後都是瞬間完成。
到 notebook 的 5️⃣ 節:兩個 stage、dag、skip 與 run cache這次改動換到了什麼?
改完參數重跑之後,兩個指令直接告訴你差在哪——比的是工作區與 HEAD(上一個 commit):
Change 那一欄是 DVC 幫你算的,可以直接貼進 PR 描述裡。 注意這一輪沒有任何一份資料被複製——資料的 md5 沒變,DVC 認得出來是同一份。 參數的版本與資料的版本,是分開追蹤的兩件事。
但如果你想試十二組參數呢?照上面那套「改 params.yaml → repro → commit」, 你的 git 歷史會多出十二個沒人想看的 commit。dvc exp run 就是為這件事存在的:
它暫時換掉參數、跑一次管線、把結果存成一個實驗(掛在 git 上但不是 commit), dvc exp show 再把它們排成一張表(--only-changed 讓表只留有變動的欄位,不然沒動的參數會塞滿畫面)。 試錯的過程不會弄髒 git 歷史;覺得某一組值得留下來, dvc exp branch <實驗名> 把它變成真正的分支。
到 notebook 的 4️⃣ 與 6️⃣ 節:diff 與 dvc exp同事 clone 完之後,資料從哪來
到這裡為止,資料都只在你這台機器的 .dvc/cache 裡。 同事把 repo clone 下來只會拿到指標檔——這就是 remote 的工作:一個放「內容」的地方。 它可以是 S3、GCS、Azure、SSH、HTTP,也可以只是一個資料夾(NAS、共用磁碟機都算)。 對 DVC 來說差別只有那一行網址:
-d 是 default 的意思,設定寫進 .dvc/config—— 這個檔會進 git,所以團隊裡每個人 clone 完自動吃到同一個遠端。 打開遠端資料夾看,裡面是跟 cache 一模一樣的 files/md5/46/4179a42e1b28… 結構:遠端就是 cache 的另一份拷貝, 沒有資料庫、沒有中繼服務。換成 S3 的話,這些就是 bucket 裡的 object key。
notebook 接著把 .dvc/cache 與所有資料檔整個刪掉, 模擬「同事剛 clone 完」的狀態,然後:
資料與模型都回來了。所以新同事的完整流程就是三行: git clone → cd → dvc pull。 沒有「請找 Alice 要那份 CSV」,也沒有 raw_final_v2_真的最終版.csv。 但這也代表一件事:git push 之後別忘了 dvc push—— 只推指標檔不推內容,對方 pull 下來只會拿到一句「檔案不在本機也不在遠端」。
到 notebook 的 7️⃣ 節:push、刪光、pull 回來那 MLflow 呢?兩個一起用,接點只有一行
這個主題的第 1、2 課你用 MLflow 記過訓練。DVC 跟 MLflow 不是二選一——它們管的是不同的東西:
| DVC | MLflow | |
|---|---|---|
| 管什麼 | 檔案的版本、管線的可重現 | 實驗的紀錄、模型的註冊 |
| 綁在哪 | git commit | run id |
| 典型問題 | 「上個月那版資料在哪?」「這步要不要重跑?」 | 「哪一次跑的 AUC 最高?當時參數是什麼?」 |
| 存什麼 | 資料、模型檔、中間產物(內容定址) | params、metrics、artifacts、模型版本與 alias |
| 誰在用 | 整個團隊共用一份資料 | 每個人的每一次訓練 |
就這一行。半年後看到某個 run,你可以 git checkout <git_commit> 拿回當時的程式與指標檔、 dvc checkout 拿回當時的資料、dvc repro 重跑一次—— 而且因為 md5 對得上,你會拿到一模一樣的模型。 這就是「可重現」的完整定義:程式、資料、參數、產物四樣都能指名道姓,少一樣都做不到。
到 notebook 的 8️⃣ 節:把 md5 記進 MLflow run換你動手
notebook 的 9️⃣ 節有一個小工具:選一種改動、按下去,它會先把工作區還原、套用改動、真的執行一次 dvc repro。按之前先自己猜:哪些 stage 會重跑、哪些會 skip? 猜完再看三個挑戰:
把 n_estimators 從 50 改成 200,用 dvc params diff 與 dvc metrics diff 看多花的時間換到多少 AUC。進階:加一個全新的參數 min_samples_leaf——想清楚要改幾個地方,少改一個會怎樣。
加第三個 stage evaluate:讀 model.pkl 與 data/test.csv,把 ROC 曲線寫成 plots/roc.csv,並在 dvc.yaml 用 plots: 宣告它。跑完看 dvc dag 多了什麼、dvc plots show 產出什麼。
不用自己 clone,直接從別人的 repo 拿一份 DVC 管的資料:研究 dvc get 與 dvc import 的差別,說明什麼情況該用哪一個,並找出 import 產生的檔案裡「記住來源版本」的是哪一個欄位。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
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 擋下來是有道理的:同一個檔不能同時被 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 拿到這個。原因與修法是?
訊息直接說了:「這些 cache 檔在本機和遠端都不存在」,然後指名缺的那個 md5。git 與 DVC 是兩條各自獨立的推送管道——git push 送走的是指標檔與 dvc.lock,460 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 次。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;整份跑完約 40–60 秒,所有檔案都寫在暫存資料夾裡
不想用 molab?下載 dvc-basics_ext.py 後在自己電腦
uvx marimo edit --sandbox dvc-basics_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。