💡 先搞懂問題
虛構的「青松物流」想用過去的出貨紀錄,預測哪些包裹會延誤。資料分析師小陳寫了十幾行 scikit-learn 程式:讀進資料、標準化、切成訓練集與測試集、訓練邏輯迴歸、算準確率,測試集拿到 0.93。上線一個月後回頭對帳,實際命中率只有七成左右(數字皆為示意)。程式從頭到尾沒有任何錯誤訊息,問題出在其中一行的位置:他先對整份資料呼叫 StandardScaler().fit_transform(X),之後才呼叫 train_test_split。
新手在這裡卡住,通常不是因為不認得函式,而是說不出三件事:fit、transform、predict 各自在做什麼?哪些步驟會「從資料學東西」?測試資料在整個流程裡可以出現在哪幾行?scikit-learn(簡稱 sklearn)的每個物件都用同一套介面:fit 從資料學出參數,transform 用學到的參數轉換資料,predict 用學到的參數做預測。規則只有一條:會學東西的 fit 只能看訓練資料,測試資料只能被 transform 與 predict。違反這條規則,測試資料的資訊就會悄悄流進訓練過程,這叫資料洩漏(data leakage),結果是評估分數比模型面對真正新資料時的表現好。
mean_ 與 scale_ 被拿去轉換測試集,測試集本身從來沒有被 fit 過。生活比喻:段考前的複習講義
晴空補習班的林老師要幫學生準備下週的數學段考。她手上有兩疊卷子:一疊是過去的練習卷,另一疊是學校寄來、規定考試當天才能拆封的段考卷。正常的做法是:林老師只從練習卷整理出常考題型和平均難度,編成一份複習講義與一套評分標準;學生用講義練習;考試當天拆開段考卷作答,批改時套用事先定好的同一套評分標準。這樣得到的分數,才代表學生面對沒看過的題目時的實力。
假設林老師為了讓講義「更貼近考試」,先偷偷拆開段考卷,把段考題也算進平均難度,再編講義。講義裡一題段考原題都沒有,但題型比例和難度分布已經被段考卷影響,學生當然考得比較好;換成別校的考卷,分數就掉下來。值得注意的是,這種偷看不需要整張考卷外流,光是「平均難度」這種統計數字就夠了。
這個比喻有兩個地方和實際不同。第一,學生會「背」講義,模型也會記住訓練資料,但資料洩漏指的不是模型記太熟(那是過擬合,overfitting),而是評估用的資料參與了學習,讓評估本身失真。第二,真實的洩漏通常比偷看考卷更不起眼:一個平均數、一份類別清單、一組被挑中的特徵,都只會讓分數偏高一點,程式也不會報錯,所以只看分數幾乎察覺不到,必須回頭讀程式流程,問每一個 fit「你看過哪些資料」。
🎮 互動實驗室一:建模流程排序器
下面有兩回合:第一回合是手動寫法的 8 個步驟,第二回合改用 Pipeline 的 7 個步驟。把左邊的步驟卡排進右邊的編號格:電腦上可以直接拖曳;手機或想用點的,先點一張卡(會被框起來),再點一個格子。點選格子裡的卡片後再點另一格可以互換,選取後點左邊卡片區可以把它退回。排滿後按「檢查順序」,排錯的格子會晃動,並說明那樣執行時程式實際會出什麼事。正確答案不只一種,只要依賴關係對就算對。
X, y = load_breast_cancer(return_X_y=True) # 1 載入資料
X_tr, X_te, y_tr, y_te = train_test_split( # 2 第一步就切分
X, y, test_size=0.2, stratify=y, random_state=42)
scaler = StandardScaler().fit(X_tr) # 3 只在訓練集學平均與標準差
X_tr_s = scaler.transform(X_tr) # 4 轉換訓練集
X_te_s = scaler.transform(X_te) # 5 測試集用同一把尺,只 transform
clf = LogisticRegression(max_iter=1000).fit(X_tr_s, y_tr) # 6 模型也只看訓練集
y_pred = clf.predict(X_te_s) # 7 預測測試集
print(accuracy_score(y_te, y_pred)) # 8 最後評分一次
X, y = load_breast_cancer(return_X_y=True) # 1 載入資料
X_tr, X_te, y_tr, y_te = train_test_split( # 2 切分
X, y, test_size=0.2, stratify=y, random_state=42)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)) # 3 只是組裝,還沒學
print(cross_val_score(pipe, X_tr, y_tr, cv=5).mean()) # 4 每一折都重新 fit 整條 Pipeline
pipe.fit(X_tr, y_tr) # 5 定案後用整個訓練集 fit
y_pred = pipe.predict(X_te) # 6 測試集只 transform 再 predict
print(accuracy_score(y_te, y_pred)) # 7 最後評分一次
🎮 互動實驗室二:標準化洩漏模擬
示意資料是晴空咖啡 10 位會員的月消費(單位:百元)。用 train_test_split(x, test_size=0.3, random_state=8) 切分後,7 筆是訓練集、3 筆是測試集,下表就是實跑切出來的結果。上方切換兩種寫法,看 StandardScaler 學到的 mean_、scale_ 與每筆資料轉換後的 z 分數;再拖動「測試集整體偏移」,模擬上線後的新會員消費普遍變高。假設下游有一條規則:z 分數的絕對值大於 3 就標成「異常消費」。
這次執行的程式
scaler = StandardScaler().fit(np.vstack([X_tr, X_te])) # 測試集也被拿來算平均
z_tr = scaler.transform(X_tr)
z_te = scaler.transform(X_te)
scaler = StandardScaler().fit(X_tr) # 只用 7 筆訓練集
z_tr = scaler.transform(X_tr)
z_te = scaler.transform(X_te) # 測試集套用訓練集的尺
數線與 z 分數
🎮 互動實驗室三:這段程式有沒有洩漏
每張卡是一小段原創的 sklearn 程式,每一段都能正常執行。請判斷它有沒有資料洩漏,有的話是哪一種。判斷時對每一個 fit、fit_transform、fit_resample 或「拿分數來挑」的動作問同一個問題:它看過的資料裡,有沒有之後要拿來評估的那一份?答完會說明理由,旁邊的分類表會亮起正解。
📘 原理補完
1. 一套介面,兩種角色
scikit-learn 官方文件把凡是有 fit 方法的物件都稱為估計器(estimator)。為了好記,可以把它們分成兩種角色:一種是轉換器(transformer),例如 StandardScaler、OneHotEncoder、SimpleImputer,負責把資料變形,有 fit、transform、fit_transform;另一種是模型,官方稱為預測器(predictor),例如 LogisticRegression、RandomForestClassifier,有 fit、predict,分類器多半還有 predict_proba。地圖節點裡說的「估計器」指的就是這一類會預測的模型。
不管哪一種,fit 學到的東西一律存在名稱結尾有底線的屬性:標準化器的 mean_、scale_,編碼器的 categories_,模型的 coef_、classes_。還沒 fit 就呼叫 transform 或 predict,會得到 NotFittedError。fit 會回傳物件本身,所以可以寫成 StandardScaler().fit(X_tr) 一行建好並學完。
| 方法 | 做什麼 | 誰有 | 回傳 | 測試集可以用嗎 |
|---|---|---|---|---|
| fit(X, y) | 從資料學參數,存進結尾底線的屬性 | 所有估計器 | 物件本身 | 不行 |
| transform(X) | 用已學到的參數轉換資料 | 轉換器 | 轉換後的 X | 可以 |
| fit_transform(X) | 先 fit 再 transform 同一份資料 | 轉換器 | 轉換後的 X | 不行 |
| predict(X) | 用已學到的參數預測 | 模型 | 預測標籤或數值 | 可以 |
| predict_proba(X) | 各類別機率,欄位順序依 classes_ | 多數分類器 | (樣本數, 類別數) 機率 | 可以 |
| score(X, y) | 先 predict 再算預設指標(分類 accuracy、迴歸 R2) | 模型 | 一個數字 | 可以,但只在最後用一次 |
2. fit_transform 為什麼只能給訓練集
fit_transform(X) 的結果和 fit(X).transform(X) 相同,只是寫在一起,有些轉換器還有更省計算的實作。問題不在它本身,而在你把什麼資料交給它。對測試集呼叫 fit_transform,標準化器會改用測試集自己的平均與標準差,等於訓練集和測試集各用一把不同的尺:同樣是消費 65 百元,在訓練集的尺上是 0.955,對測試集 fit_transform 卻會變成 −1.354(測試集 84、65、92 自己的平均是 80.33),連正負號都反了,模型學到的規則就對不上了。上線時一次只來一筆資料,標準差是 0,這種寫法甚至算不出來。
下面是不用 Pipeline 時的完整手動寫法。每一個會學東西的動作都只拿到 X_tr,測試集只出現在 transform、predict 與最後的評分;註解裡的輸出是 scikit-learn 1.9.1 實際執行的結果。
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
X, y = load_breast_cancer(return_X_y=True) # (569, 30),二元標籤
X_tr, X_te, y_tr, y_te = train_test_split( # 1. 第一步就切分
X, y, test_size=0.2, stratify=y, random_state=42) # 回傳順序固定
print(X_tr.shape, X_te.shape) # (455, 30) (114, 30)
scaler = StandardScaler() # 轉換器:還沒學任何東西
X_tr_s = scaler.fit_transform(X_tr) # 2. fit + transform 只給訓練集
X_te_s = scaler.transform(X_te) # 3. 測試集只 transform
print(scaler.mean_[:2].round(2)) # [14.07 19.25] 來自 455 筆訓練資料
clf = LogisticRegression(max_iter=1000) # 模型
clf.fit(X_tr_s, y_tr) # 4. 模型也只看訓練集
y_pred = clf.predict(X_te_s) # 5. 預測測試集
print(round(accuracy_score(y_te, y_pred), 3)) # 0.982,最後評分一次
print(round(clf.score(X_te_s, y_te), 3)) # 0.982,分類器的 score 就是 accuracy
3. 先切分:train_test_split 的參數
train_test_split(X, y) 回傳四個東西,順序固定是 X_train, X_test, y_train, y_test:先是兩份 X,再是兩份 y。變數名稱寫錯順序時程式不會報錯,只是 y_tr 裡裝的其實是測試集的 X,這是程式閱讀題很愛用的陷阱。test_size 和 train_size 都沒給時,測試集預設占 25%;10 筆資料會切成 7 筆訓練、3 筆測試,因為測試集的筆數是無條件進位。
shuffle 預設是 True,切分前會先打亂;random_state 固定打亂的亂數,讓每次執行切出同樣的結果,方便重現與比較,它不影響切分「好不好」。stratify=y 讓訓練集與測試集的類別比例和整體一致,類別不平衡時特別重要;它需要打亂才能運作,shuffle=False 時不能同時指定 stratify。資料有時間順序時則反過來,不能打亂,要依時間先後切,見第 6 節。
4. 資料洩漏從哪裡來
判斷洩漏只要問一個問題:評估用的資料,有沒有參與任何一個會學東西的步驟?「會學東西」不只模型本身,還包括所有從資料算出統計量或做出選擇的動作。下表整理最常見的六種來源,最後一欄是改法。
| 來源 | 典型寫法 | 為什麼分數會虛高 | 改法 |
|---|---|---|---|
| 前處理在切分前 fit | 先 StandardScaler().fit_transform(X)、fillna(df.mean())、TfidfVectorizer().fit(全部文件) 再切 | 平均、補值、詞彙表與 IDF 含測試資料的資訊 | 先切分;或放進 Pipeline |
| 特徵選擇看過全部資料 | 先 SelectKBest(k=10).fit_transform(X, y) 再交叉驗證 | 挑特徵時已經用到驗證折的標籤 | 把選擇器放進 Pipeline |
| 過採樣碰到評估資料 | 先 SMOTE().fit_resample(X, y) 再切分或交叉驗證 | 合成樣本由驗證列內插而來,驗證時等於看見親戚 | 用 imbalanced-learn 的 Pipeline,只在訓練折過採樣 |
| 用測試集挑模型或調參 | 換 10 組參數都看測試分數,挑最高的報告 | 測試集變成另一個訓練集,挑中的是「剛好適合這份測試集」的設定 | 用交叉驗證或驗證集挑,測試集只用一次 |
| 用到未來的資料 | 時間序列打亂切分、rolling(center=True)、shift(-1) | 訓練時看過評估時間點之後的資訊 | TimeSeriesSplit、只往過去看的特徵 |
| 特徵藏著答案 | 用「是否已退款」預測「是否詐騙」 | 特徵在預測當下根本還不存在,是結果的一部分 | 只保留預測當下拿得到的欄位 |
遇到一段陌生的程式,可以照下面四步檢查,也可以搭配下一張流程圖:
- 圈出所有會學東西的動作:
fit、fit_transform、fit_resample、fillna(用統計量),以及「看分數挑參數或挑模型」。 - 對每一個動作問:它拿到的資料是哪一份?裡面有沒有之後要評估的列(測試集或交叉驗證的驗證折)?
- 資料有時間順序時,再問:訓練用的資料是不是都早於評估的資料?特徵有沒有偷看未來?
- 最後檢查每個特徵在「做預測的那一刻」拿不拿得到;拿不到的就是目標洩漏。
5. Pipeline 與 cross_val_score 怎麼自動避免
手動寫法要靠人記得「每個轉換器只在訓練集 fit」,步驟一多就容易漏。Pipeline 把多個步驟串成一個物件,除了最後一步,前面都必須是轉換器。呼叫 pipe.fit(X_tr, y_tr) 時,每個轉換器依序 fit_transform,最後的模型 fit;呼叫 pipe.predict(X_te) 時,轉換器只做 transform,模型做 predict,不會重新學。make_pipeline 會自動用小寫類別名稱當步驟名,例如 standardscaler、logisticregression。
Pipeline 真正的價值出現在交叉驗證。K 折交叉驗證(K-fold cross-validation)把訓練集切成 K 份,每次用 K−1 份訓練、1 份驗證。cross_val_score(pipe, X_tr, y_tr, cv=5) 每一折都會複製一份全新的 Pipeline,只在該折的訓練部分 fit 標準化器與模型,再拿驗證那一份評分,回傳 5 個分數。如果你在迴圈外先把整個訓練集標準化,再丟一個單純的模型進 cross_val_score,每一折的驗證資料早就參與過平均與標準差的計算。cv 給整數且模型是分類器時,預設使用 StratifiedKFold,保持每折的類別比例,而且預設不打亂。
挑超參數時把同一條 Pipeline 交給 GridSearchCV,參數名稱寫成「步驟名__參數名」。它對每組參數各做一次 K 折,總 fit 次數是「組合數 × K」,refit=True(預設)時再用最佳組合對整個訓練集 fit 一次。best_score_ 是交叉驗證的平均分數,不是測試分數;測試集要在全部定案後才評分一次。
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
X, y = load_breast_cancer(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
print(pipe.steps[0][0], pipe.steps[1][0]) # standardscaler logisticregression
scores = cross_val_score(pipe, X_tr, y_tr, cv=5) # 每一折重新 fit scaler 與模型
print(scores.shape, round(scores.mean(), 3)) # (5,) 0.98
grid = {"logisticregression__C": [0.1, 1, 10]} # 步驟名__參數名
gs = GridSearchCV(pipe, grid, cv=5) # 3 組 x 5 折 = 15 次 fit,再 refit 1 次
gs.fit(X_tr, y_tr) # 只用訓練集調參
print(gs.best_params_, round(gs.best_score_, 3)) # {'logisticregression__C': 0.1} 0.98
print(round(gs.score(X_te, y_te), 3)) # 0.974,測試集只在最後用一次
過採樣是例外:scikit-learn 本身的 Pipeline 不接受 SMOTE 這類會改變樣本數的步驟,要改用 imbalanced-learn 套件的 imblearn.pipeline.Pipeline(或它的 make_pipeline),它只在 fit 時過採樣,predict 與評分時不會碰驗證資料。
6. 時間序列:不能打亂
銷售量、感測器讀數、網站流量這類資料有先後順序。train_test_split 預設會打亂,切出來的訓練集可能包含 12 月的資料、測試集卻是 6 月,模型等於用未來預測過去。正確做法是依時間切:最簡單的是 shuffle=False 讓最後一段當測試集;交叉驗證則用 TimeSeriesSplit,每一折的驗證段都在訓練段之後。特徵工程也一樣,rolling(7, center=True) 的視窗會包含後面三天,shift(-1) 會拿到明天的值,都屬於偷看未來。
容易寫錯或考錯的地方
回傳順序:題目把 train_test_split 的左邊寫成 X_tr, y_tr, X_te, y_te,再問某個變數的 shape。程式不會報錯,答案要照「X_train, X_test, y_train, y_test」的真實順序推。
「分數比較高,所以比較好」:洩漏題的干擾選項幾乎都這樣寫。洩漏版本的分數本來就比較高,高分正是問題所在;正確選項通常描述的是流程,例如「每一折只用該折訓練資料 fit」。
「它不是模型,所以不會洩漏」:SelectKBest、SimpleImputer、TfidfVectorizer、SMOTE 都會從資料學東西。判斷依據是有沒有 fit,而不是它叫不叫模型。
「折數加大就沒事」:cv=5 改成 cv=10 不會修好洩漏,只要前處理在交叉驗證之外看過全部資料,每一折都一樣被污染。
對測試集 fit_transform:看起來兩邊都「標準化過」,其實是兩把不同的尺,屬於錯誤寫法;正確選項是測試集只呼叫 transform。測試集標準化後的平均不一定是 0,這是正常現象。
best_score_ 不是測試分數:它是交叉驗證的平均;題目若問「最後要報告哪個數字代表新資料表現」,答案是定案後在測試集算一次的分數。
stratify 與 random_state:前者讓類別比例一致,後者讓結果可以重現,兩者不能互相取代。時間序列兩個都不適用,要改依時間切分。
版本:本頁程式以 scikit-learn 1.9.1(並以 1.8 核對)與 imbalanced-learn 0.14 實際執行;fit、transform、predict、Pipeline、cross_val_score 這套介面在 1.x 版之間沒有改變。
✅ 自我檢測
以下 6 題都是原創的程式閱讀題,輸出都以 scikit-learn 1.9.1 實際執行核對。選完會立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6
🎯 重點整理
- fit 學參數(存在結尾底線的屬性),transform 套用,predict 預測;fit_transform 等於 fit 再 transform,只用在訓練集。
- 會學東西的不只模型:標準化、補值、編碼、TF-IDF、特徵選擇、過採樣,以及「看分數挑選」都算,全部只能看訓練資料。
- 先切分再做任何會學的步驟;train_test_split 回傳 X_train, X_test, y_train, y_test,測試集預設 25%。
- stratify=y 保持類別比例,random_state 讓結果可重現,兩者作用不同;時間序列不能打亂,改用 TimeSeriesSplit。
- 把前處理和模型綁成 Pipeline,交給 cross_val_score 或 GridSearchCV,每一折都只在該折訓練部分重新 fit;SMOTE 要用 imbalanced-learn 的 Pipeline。
- 測試集只在最後評分一次;best_score_ 是交叉驗證平均,不是測試分數。洩漏版本分數比較高,高分本身就是警訊。