L1.2 老師用考古題教學又出題:為什麼要切資料
本篇重用M1.1從鞋子猜身高:Conditional Expectation·M5.0保險為什麼存在:Convex Function 與 Jensen 不等式
起點:老師用考古題教學,又用考古題出題
一位老師整個學期都用歷屆考題上課,每一題都講過。期末考,他從同一批歷屆考題裡挑了二十題。
全班平均 92 分。
這個 92 分代表什麼? 它不代表學生會這門課,也不代表他們不會——它幾乎不提供資訊,因為衡量用的題目和教學用的題目是同一批。
這件事在機器學習裡不只發生一次,而是至少發生在三個層次,一個比一個難察覺:
- 模型調參數時看過那些資料——所以訓練誤差不能當成績(台北的房仲去台中:風險與經驗風險 已經量過這個偏差)。
- 你調超參數時看過那些資料——所以驗證分數也不能當成績。這是這一篇的主題。
- 資料在切開之前就被前處理「混」過——這時三個集合全部同時失效,而且你不會有任何徵狀。這是本篇最貴的一節。
上一篇量 bias 與 variance 時,我們一直呼叫 truth()。真實專案裡沒有 truth()。 你手上只有資料,而要用資料估「在沒看過的資料上表現如何」,唯一的辦法就是真的留一部分不看。
上一篇量 bias 與 variance 時用了真實函數,
真實專案沒有它。
只有資料的時候,「在沒看過的資料上表現如何」要怎麼估?
課堂提問Q1
把三個集合翻成三個不同的問題。為什麼「用驗證集挑出來的模型,它的驗證分數」是有偏的?測試集為什麼只能看一次——看第二次會發生什麼?
先想一想,再展開看整理後的答案
三個問題,三份資料:
| 集合 | 回答的問題 | 誰在用它做決定 |
|---|---|---|
| 訓練 | 給定這個模型設定,參數該設多少? | 最佳化器 |
| 驗證 | 這些設定裡,哪一個比較好? | 你 |
| 測試 | 我最後交出去的這一個,實際上多好? | 沒有人做決定——只讀一次 |
驗證分數為什麼有偏。 這是 L0.2 的論證再用一次,只是換一層:你在候選 裡挑驗證分數最低的那個。每個 都是 的無偏估計,但取最小值不是無偏的——(Jensen 不等式的方向,見 保險為什麼存在:Convex Function 與 Jensen 不等式, 是凹函數)。你挑中的往往是「真的好」加上「這次驗證集剛好對它友善」的組合,而後者不會重現。
測試集看第二次會怎樣。 一旦你根據測試分數做了任何決定——換一個模型、多訓一輪、調一個閾值——測試集就變成了驗證集,它的分數就開始有同一種偏差。而人幾乎不可能看了不做決定,所以規則寫成「只看一次」。
「看」本身無害,根據它做決定才是。
每一次決定,花掉一點誠實度
把資料想成一種會被消耗的資源:每次你用某份資料做一個決定,那份資料對「未來的決定」的誠實度就下降一點。
- 用訓練集調參數 → 訓練誤差不再誠實。
- 用驗證集選 20 個候選 → 驗證分數不再誠實(偏一點點)。
- 用驗證集選 200 個候選、跑了三個月、每天看一次 → 驗證分數偏很多。
這解釋了為什麼要有第三份資料,也解釋了為什麼「第三份」不是萬靈丹:如果你把測試分數拿回來當回饋,它就退化成第二份。 唯一能保護它的是紀律,不是統計。
還有一種更徹底的失效:洩漏(leakage)。上面的偏差是「用得太多次」,洩漏是「一開始就不該給」——測試集的資訊在訓練時就已經以某種形式進到模型裡。這時三個集合同時失效,而且你看到的所有數字都很漂亮。它的三種常見形式:
- 前處理跨越切分:用全部資料算平均與標準差、做 PCA、挑特徵、填補缺失值,然後才切。本篇最後會示範這一種能造出多大的假象。
- 重複或近重複的樣本:同一位病人的多張影像、同一則新聞的轉載、資料增強之後才切分。模型在測試集看到的是它訓練過的東西的複本。
- 時間:用未來預測過去。隨機切分一份時間序列,訓練集裡就有測試時點之後的資訊。
樂觀量隨候選數怎麼長
設 個候選、驗證集大小 、損失有界。對每個固定的 , 集中在 附近,偏離超過 的機率隨 衰減(Hoeffding)。要同時對 個候選都成立,取 union bound:
兩個依賴要記住:分子是 (候選數的對數——所以候選從 5 個變 200 個,樂觀量只成長幾倍,不是四十倍),分母是 。 這給了兩個實務推論:驗證集要夠大(樂觀量以 縮小),而「多試幾個設定」的代價是溫和但真實的——它不是零。
裡的 是你實際比較過的候選數,包含你手動試過又丟掉的那些。一個跑了三個月、每天看驗證分數並據此改一點東西的專案, 是幾百,不是你最後寫在論文裡的三個。
洩漏不在這個框架裡。 上面的界假設驗證集與訓練過程獨立。洩漏違反的正是這個假設,所以沒有任何界適用——這也是為什麼洩漏造成的假象可以任意大,下面會看到 。
展開細節為什麼「最小值是有偏的」用 Jensen 就能看出來
令 ,每個都無偏:。函數 是凹的,所以由 Jensen(保險為什麼存在:Convex Function 與 Jensen 不等式)
不等號的方向是固定的:你回報的那個最小驗證分數,在期望上不高於候選中最好的真實風險——而你真正拿到的模型甚至不一定是最好的那個候選。兩層樂觀疊在一起。
差距有多大,取決於 之間的相關性與各自的散佈:候選彼此高度相關(例如 degree 8 與 degree 9 的模型幾乎一樣)時差距小;候選各自獨立地亂跳(例如比較 200 個隨機種子)時差距大。這也是為什麼「比較很多個幾乎相同的設定」比「比較很多個天差地遠的設定」安全一些。
回到情境:規則與它們的理由
規則一:先切分,再做任何事。 切分是流程的第一步,不是資料清理之後的第二步。所有從資料估出來的東西——平均、標準差、詞彙表、PCA 的基底、缺失值的填補量、特徵選擇的名單——都只能用訓練集擬合,再套用到驗證與測試。
規則二:切分要沿著「相依的單位」切,不是沿著列。 同一位病人、同一個使用者、同一份原始文件的所有列,必須整組落在同一邊。以列為單位隨機切,等於把近重複樣本同時放進兩邊。
規則三:有時間結構就用時間切。 訓練用較早的區間、驗證與測試用較晚的區間。這同時模擬了真實部署(你總是用過去預測未來),也避免了未來資訊回流。
規則四:測試集只讀一次,而且要有紀錄。 實務上的作法是把它加密封存、或交給不做建模的人保管,並且在讀之前先寫下「我預期會看到多少」。這一條看起來像儀式,但它是唯一能保護第三份資料的東西。
這一切依賴什麼。 一,三份資料同分佈;若測試集刻意取自更晚的時間或另一個地區,它量的是「偏移下的表現」,是另一個(通常更有用的)問題,但別把它當同分佈的估計。二,測試集夠大—— 的分數本身有很大的 variance,兩個模型差 1% 沒有意義。三,你誠實計算 。
回到情境:兩個實驗
第一段:候選越多,驗證分數越樂觀;測試集不動如山。 每次抽 120 筆資料,切成 60 訓練 / 30 驗證 / 30 測試,用驗證集從 個候選(不同次數與 ridge 強度)裡挑一個,然後比較三個數字。重複 600 次取中位數(分佈右尾很長,平均會被少數幾次災難拉走——這件事本身就值得知道):
候選 5 個:validation 0.0241 test 0.0250 真實風險 0.0251
候選 20 個:validation 0.0234 test 0.0257 真實風險 0.0261
候選 200 個:validation 0.0224 test 0.0251 真實風險 0.0255
測試集那一欄與真實風險那一欄始終貼合(、、)——它沒有參與挑選,所以它誠實。驗證那一欄則隨候選數變多而越來越低於真相:從低 到低 。候選數乘了四十倍,樂觀量只成長約三倍——正是 的形狀。
實務翻譯:如果你只有驗證分數,你回報的數字大約是「真實表現」再打一個折,而折扣的大小取決於你試過幾個設定。 這個折扣在 、 時大約是 ;驗證集越小、試得越多,折扣越大。
第二段:刻意違反「先切分再前處理」。 造一份標籤與特徵完全無關的資料:100 個樣本、1000 個純 Gaussian noise 特徵、標籤是公平的銅板。任何誠實的評估都必須給出 。
import numpy as np
rng = np.random.default_rng(0)
n, p, K, k_sel = 100, 1000, 5, 20
acc_wrong, acc_right = [], []
for rep in range(50):
X = rng.standard_normal((n, p)) # 1000 個純噪聲特徵
y = rng.integers(0, 2, n) # 標籤與 X 完全無關
folds = np.array_split(rng.permutation(n), K)
def cv(select_inside):
if not select_inside: # 錯誤:先用全部資料挑特徵
c = np.array([abs(np.corrcoef(X[:, j], y)[0, 1]) for j in range(p)])
sel_all = np.argsort(-c)[:k_sel]
out = []
for f in folds:
te, tr = f, np.setdiff1d(np.arange(n), f)
if select_inside: # 正確:只用訓練折挑特徵
c = np.array([abs(np.corrcoef(X[tr, j], y[tr])[0, 1]) for j in range(p)])
sel = np.argsort(-c)[:k_sel]
else:
sel = sel_all
A = np.c_[np.ones(len(tr)), X[np.ix_(tr, sel)]]
w = np.linalg.lstsq(A, y[tr]*2 - 1, rcond=None)[0]
B = np.c_[np.ones(len(te)), X[np.ix_(te, sel)]]
out.append((((B @ w) > 0).astype(int) == y[te]).mean())
return np.mean(out)
acc_wrong.append(cv(False)); acc_right.append(cv(True))
print(f"先挑特徵再做 CV(洩漏): {np.mean(acc_wrong):.3f}")
print(f"每一折內部才挑特徵(正確):{np.mean(acc_right):.3f}")
先挑特徵再做 CV(洩漏): 0.814
每一折內部才挑特徵(正確):0.491
的準確率,資料裡沒有任何訊號。
機制很簡單也很可怕:在 1000 個純噪聲特徵裡,一定有幾個碰巧和這 100 個標籤相關。用全部資料挑出這幾個,等於把每一筆測試樣本的標籤資訊寫進了「哪些特徵被選中」這件事裡。之後的交叉驗證再怎麼嚴謹,都是在一個已經偷看過答案的特徵子集上做的。
這個錯誤在真實專案裡長什麼樣。 它幾乎從不長成「我故意用了測試集」,而是長成一行放錯位置的程式:
scaler.fit(X)寫在train_test_split之前。- 先對整份資料做 PCA 降維、或用整份資料訓一個 embedding,再切。
- 先用整份資料選出「重要特徵」,再開始做嚴謹的交叉驗證。
- 先做 SMOTE / 資料增強、再切分(複本橫跨兩邊)。
沒有任何徵狀。 訓練與驗證曲線都很正常,四格診斷會告訴你「成功」。唯一的防線是流程本身。而唯一能事後抓到它的,是一份從來沒進過這條流程的資料——或是像這裡一樣,在一份你知道答案是 的假資料上跑一遍整條流程。這是一個值得對任何評估流程做一次的健康檢查:把標籤打亂,重跑,看看它是不是誠實地回報「沒有訊號」。
切分是第一步,不是資料清理之後的第二步。
切分順序與候選數:兩種偏差的來源
互動 demo:候選越多,勝出者的驗證分數越樂觀;把驗證集加大比少試幾組有效得多。
先消化一下
參考文獻
- Hastie, T., Tibshirani, R., Friedman, J. The Elements of Statistical Learning, 2nd ed. Springer 2009, Ch. 7.10.2.(「錯誤的交叉驗證方式」——本篇噪聲實驗的原型。)
- Kaufman, S., Rosset, S., Perlich, C. Leakage in Data Mining: Formulation, Detection, and Avoidance. KDD 2011.(洩漏的分類、在真實競賽資料上的案例,以及可操作的偵測方法。)
- Kapoor, S., Narayanan, A. Leakage and the Reproducibility Crisis in ML-based Science. Patterns 2023.(跨學科盤點:洩漏在多少已發表研究裡造成了無法重現的結論。)