Cross-validation, Pipeline, GridSearchCV: đánh giá mô hình trung thực
Vì sao một lần chia train/val không đủ, cách dùng StratifiedKFold, Pipeline chống rò rỉ dữ liệu và GridSearchCV chọn tham số với scikit-learn, có code chạy được.
Nội dung bài viết
Bạn huấn luyện một mô hình phân loại OK/NG, chia dữ liệu một lần, được accuracy 82,5%. Đồng nghiệp chạy lại với cách chia khác, được 79,2%. Con số nào đúng? Và khi thử 10 bộ tham số rồi chọn bộ tốt nhất trên cùng tập validation, con số đó có còn đáng tin? Bài này giải quyết ba câu hỏi đó bằng ba công cụ của scikit-learn: cross-validation, Pipeline và GridSearchCV.
Bài tiếp nối overfitting và cách chia train/val/test và phân loại OK/NG bằng scikit-learn. Code chạy với scikit-learn 1.9.
Dữ liệu giả lập
Để ai cũng chạy được, ta tạo dữ liệu giống bài toán thật: 600 sản phẩm, mỗi sản phẩm 8 đặc trưng hình học (diện tích, chu vi, độ tròn...), khoảng 20% là NG.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# Dữ liệu giả lập: 600 sản phẩm, 8 đặc trưng hình học, 20% là NG
X, y = make_classification(n_samples=600, n_features=8, n_informative=4,
weights=[0.8, 0.2], random_state=0)
print(X.shape, "tỉ lệ NG:", y.mean().round(3))
# Giữ riêng tập test, chỉ dùng ở bước cuối
X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
# Một lần chia: kết quả phụ thuộc may rủi
for seed in range(3):
X_tr, X_val, y_tr, y_val = train_test_split(X_tv, y_tv, test_size=0.25, stratify=y_tv, random_state=seed)
mh = make_pipeline(StandardScaler(), LogisticRegression())
mh.fit(X_tr, y_tr)
print(f"seed {seed}: accuracy = {mh.score(X_val, y_val):.3f}")
(600, 8) tỉ lệ NG: 0.202
seed 0: accuracy = 0.825
seed 1: accuracy = 0.792
seed 2: accuracy = 0.817
Cùng một mô hình, chỉ đổi cách chia, kết quả dao động hơn 3 điểm phần trăm. Với tập dữ liệu nhỏ (rất thường gặp trong nhà máy), một lần chia không đủ để kết luận.
scoring="f1", tính cho lớp NG (nhãn 1).1. Cross-validation: chia nhiều lần, lấy trung bình
K-fold cross-validation chia dữ liệu thành K phần. Lần lượt mỗi phần làm validation, K−1 phần còn lại để huấn luyện. Kết quả là K điểm số; trung bình cho biết mô hình tốt cỡ nào, độ lệch chuẩn cho biết kết quả ổn định ra sao.
StratifiedKFold giữ tỉ lệ OK/NG giống nhau ở mọi fold, rất quan trọng khi lớp NG hiếm.
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
mh = make_pipeline(StandardScaler(), LogisticRegression())
diem = cross_val_score(mh, X_tv, y_tv, cv=cv, scoring="f1")
print("F1 từng fold:", diem.round(3))
print(f"F1 = {diem.mean():.3f} ± {diem.std():.3f}")
F1 từng fold: [0.48 0.296 0.417 0.25 0.462]
F1 = 0.381 ± 0.092
Bức tranh thật hiện ra: mô hình có accuracy "82%" nhưng chỉ bắt được NG rất kém (F1 khoảng 0,38), và dao động mạnh giữa các fold. Đây là thông tin mà một lần chia không cho bạn thấy.
2. Pipeline: gói tiền xử lý và mô hình làm một
Bạn đã thấy make_pipeline(StandardScaler(), LogisticRegression()). Tại sao không chuẩn hóa toàn bộ X một lần ở đầu cho gọn? Vì như thế, thông tin từ fold validation (trung bình, độ lệch chuẩn) đã "rò" vào lúc huấn luyện. Pipeline đảm bảo mỗi bước fit chỉ trên phần huấn luyện của từng fold.
Với StandardScaler, rò rỉ thường nhỏ. Nhưng với bước chọn đặc trưng, hậu quả rất lớn. Thí nghiệm sau dùng dữ liệu hoàn toàn ngẫu nhiên, không có quy luật nào để học, nên accuracy đúng phải quanh 50%:
from sklearn.feature_selection import SelectKBest, f_classif
rng = np.random.default_rng(0)
X_nhieu = rng.normal(size=(100, 5000)) # 5000 đặc trưng ngẫu nhiên
y_nhieu = rng.integers(0, 2, size=100) # nhãn ngẫu nhiên: không có gì để học
# SAI: chọn đặc trưng trên toàn bộ dữ liệu rồi mới cross-validation
X_chon = SelectKBest(f_classif, k=20).fit_transform(X_nhieu, y_nhieu)
sai = cross_val_score(LogisticRegression(), X_chon, y_nhieu, cv=5)
print(f"Sai (rò rỉ): accuracy = {sai.mean():.2f}")
# ĐÚNG: đưa bước chọn đặc trưng vào pipeline
dung = cross_val_score(make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression()),
X_nhieu, y_nhieu, cv=5)
print(f"Đúng (pipeline): accuracy = {dung.mean():.2f}")
Sai (rò rỉ): accuracy = 0.85
Đúng (pipeline): accuracy = 0.48
Cách sai báo 85% trên dữ liệu ngẫu nhiên! Bước chọn đặc trưng đã "nhìn thấy" nhãn của cả tập validation, nên chọn ra đúng 20 cột tình cờ khớp với nhãn. Mô hình như vậy mang ra dây chuyền sẽ thất bại hoàn toàn. Quy tắc: mọi bước học từ dữ liệu (chuẩn hóa, chọn đặc trưng, PCA, điền giá trị thiếu) đều phải nằm trong pipeline.
3. GridSearchCV: thử tham số một cách trung thực
GridSearchCV thử mọi tổ hợp tham số, mỗi tổ hợp được đánh giá bằng cross-validation, rồi huấn luyện lại mô hình tốt nhất trên toàn bộ dữ liệu train. Tên tham số trong pipeline có dạng tênbước__thamsố (hai dấu gạch dưới); tên bước do make_pipeline tự đặt bằng tên lớp viết thường.
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
luoi = {
"logisticregression__C": [0.01, 0.1, 1, 10],
"logisticregression__class_weight": [None, "balanced"],
}
tim = GridSearchCV(pipe, luoi, cv=cv, scoring="f1")
tim.fit(X_tv, y_tv)
print("Tốt nhất:", tim.best_params_)
print(f"F1 (CV) = {tim.best_score_:.3f}")
rf = GridSearchCV(RandomForestClassifier(random_state=0),
{"n_estimators": [100, 300], "max_depth": [None, 5]},
cv=cv, scoring="f1")
rf.fit(X_tv, y_tv)
print("Random forest tốt nhất:", rf.best_params_, f"F1 (CV) = {rf.best_score_:.3f}")
Tốt nhất: {'logisticregression__C': 0.1, 'logisticregression__class_weight': 'balanced'}
F1 (CV) = 0.435
Random forest tốt nhất: {'max_depth': None, 'n_estimators': 100} F1 (CV) = 0.837
Hai bài học rút ra:
class_weight="balanced"giúp mô hình tuyến tính quan tâm hơn tới lớp NG hiếm, F1 tăng từ 0,38 lên 0,435.- Nhưng mô hình tuyến tính vẫn không đủ sức: ranh giới OK/NG trong dữ liệu này phi tuyến. Random forest đạt F1 khoảng 0,84. Cross-validation cho phép so sánh hai họ mô hình một cách công bằng.
Random forest không cần StandardScaler vì cây quyết định không bị ảnh hưởng bởi thang đo của đặc trưng.
4. Đánh giá cuối cùng trên tập test
Điểm best_score_ hơi lạc quan, vì ta đã chọn tổ hợp tốt nhất trong nhiều lần thử. Tập test được giữ riêng từ đầu chính là "kỳ thi cuối" chưa ai nhìn thấy:
tot_nhat = rf if rf.best_score_ > tim.best_score_ else tim
print(f"F1 trên tập test: {tot_nhat.score(X_test, y_test):.3f}")
F1 trên tập test: 0.844
GridSearchCV sau khi fit đã tự huấn luyện lại mô hình tốt nhất trên toàn bộ X_tv (mặc định refit=True), và score dùng đúng thước đo scoring="f1". F1 trên test (0,844) khớp với ước lượng cross-validation (0,837), dấu hiệu quy trình đánh giá trung thực. Chỉ đánh giá trên test một lần; nếu quay lại chỉnh tham số theo kết quả test, tập test cũng bị "dùng mòn".
Lưu ý khi áp dụng với dữ liệu nhà máy
| Tình huống | Cách chia phù hợp |
|---|---|
| Nhiều ảnh của cùng một sản phẩm, cùng một lô | GroupKFold hoặc StratifiedGroupKFold với nhóm = mã sản phẩm/lô, để ảnh của cùng sản phẩm không vừa ở train vừa ở validation |
| Dữ liệu theo thời gian (cảm biến, xu hướng) | TimeSeriesSplit: luôn huấn luyện trên quá khứ, kiểm tra trên tương lai |
| NG rất hiếm (vài chục mẫu) | StratifiedKFold, có thể lặp lại nhiều lần với RepeatedStratifiedKFold để ước lượng ổn định hơn |
| Lưới tham số quá lớn | RandomizedSearchCV: thử ngẫu nhiên N tổ hợp thay vì tất cả |
Bài tập
Thêm GradientBoostingClassifier vào so sánh với lưới {"learning_rate": [0.05, 0.1], "n_estimators": [100, 200]}. Sau đó thay scoring="f1" bằng scoring="recall": mô hình nào được chọn? Vì sao trong kiểm tra chất lượng, recall của lớp NG (không để lọt hàng lỗi) thường được ưu tiên?
Nguồn tham khảo: scikit-learn: Cross-validation, scikit-learn: Tuning hyper-parameters, scikit-learn: Common pitfalls (data leakage).


