Học AI & Lập trình

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.

Cross-validation, Pipeline, GridSearchCV: đánh giá mô hình trung thực
Nội dung bài viết
  1. Dữ liệu giả lập
  2. 1. Cross-validation: chia nhiều lần, lấy trung bình
  3. 2. Pipeline: gói tiền xử lý và mô hình làm một
  4. 3. GridSearchCV: thử tham số một cách trung thực
  5. 4. Đánh giá cuối cùng trên tập test
  6. Lưu ý khi áp dụng với dữ liệu nhà máy

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.

Accuracy 80% có thể là con số vô nghĩaDữ liệu có 80% OK. Một "mô hình" luôn trả lời OK cũng đạt accuracy 80%! Với bài toán mất cân bằng, hãy dùng F1, precision, recall (xem precision, recall, F1). Từ đây ta dùng 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ốngCá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ớnRandomizedSearchCV: thử ngẫu nhiên N tổ hợp thay vì tất cả
Báo cáo cả độ dao độngKhi trình bày kết quả cho trưởng nhóm, ghi "F1 = 0,84 ± 0,03 (5-fold)" thay vì chỉ một con số. Độ dao động lớn là tín hiệu cần thêm dữ liệu trước khi đưa mô hình vào sản xuất.

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).

Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.