Danh sách bài viết

Bài 40: Grid Search — quét hyperparameter có hệ thống

GridSearchCV quét toàn bộ tổ hợp hyperparameter, đánh giá bằng cross-validation: param_grid, best_params_, best_score_, cv_results_, refit, Pipeline với __, design search space, cost và pitfall thường gặp.

24/05/2026
13 phút đọc
0 lượt xem
1

Mục tiêu bài học

Sau bài này, bạn sẽ:

  • Phân biệt rõ hyperparameterparameter.
  • Dùng được GridSearchCV để quét toàn bộ tổ hợp hyperparameter, đánh giá bằng cross-validation.
  • Đọc best_params_, best_score_, best_estimator_, cv_results_.
  • Kết hợp GridSearchCV với Pipeline để tránh data leak ở scaler/encoder.
  • Thiết kế search space hợp lý theo log-scale, biết khi nào cần coarse-to-fine.
  • Ước lượng cost theo công thức K × prod(grid) và biết khi nào nên chuyển sang Random Search (Bài 41).
2

Hyperparameter vs parameter

Hai khái niệm dễ nhầm:

  • Parameter: giá trị model học từ data trong quá trình fit. Vd: weight w và bias b của Linear Regression, coefficient của Logistic Regression, split point của Decision Tree.
  • Hyperparameter: giá trị set trước khi train, không học từ data. Vd: C trong SVM/LogReg, max_depth của Tree, n_neighbors của KNN, n_estimators của Random Forest, learning_rate của XGBoost.

Hyperparameter quyết định capacitycách học của model. Cùng dataset, cùng thuật toán, đổi hyperparameter có thể đẩy accuracy từ 70% lên 90% — hoặc ngược lại. Vì vậy việc chọn hyperparameter là bước bắt buộc trong workflow, không phải tuỳ chọn.

Không có hyperparameter nào "tốt mặc định cho mọi dataset". Default của sklearn được chọn để chạy được, không phải để đạt tối ưu. Tune là việc của người làm ML.

3

Manual tuning — vì sao không đủ

Cách thủ công: đổi C tay, fit, in score, lặp lại. Tạm chấp nhận khi có 1-2 hyperparameter và bạn quen model:

for C in [0.01, 0.1, 1, 10, 100]:
    model = SVC(C=C, kernel="rbf")
    model.fit(X_train, y_train)
    print(C, model.score(X_val, y_val))

Vấn đề của manual tuning:

  • Không systematic: dễ bỏ sót tổ hợp; thiên về giá trị "quen tay".
  • Đánh giá trên 1 split duy nhất: kết quả phụ thuộc may rủi của train_test_split; không phải ước lượng ổn định (xem Bài 39 về cross-validation).
  • Khó scale: 3 hyperparameter, mỗi cái 5 giá trị → 125 tổ hợp. Quét tay là không thực tế.
  • Khó tái lập: kết quả phụ thuộc thứ tự thử, không có log đầy đủ.

Grid Search giải quyết cả bốn vấn đề: quét đủ tổ hợp, dùng CV ổn định, có log đầy đủ trong cv_results_, reproducible với random_state của CV splitter.

4

Grid Search — quét hệ thống

Ý tưởng Grid Search rất thẳng:

  1. Định nghĩa grid — với mỗi hyperparameter, liệt kê tập giá trị muốn thử.
  2. Liệt kê mọi tổ hợp (tích Descartes các tập giá trị).
  3. Với mỗi tổ hợp, train + đánh giá bằng cross-validation (vd K-Fold).
  4. Chọn tổ hợp có CV score cao nhất.

Ví dụ với SVM: C ∈ {0.01, 0.1, 1, 10}, kernel ∈ {linear, rbf}, gamma ∈ {scale, 0.01, 0.1} → 4 × 2 × 3 = 24 tổ hợp. Với CV 5-fold, mỗi tổ hợp train 5 lần → tổng 120 lần fit. Sklearn lo phần lặp này; ta chỉ khai báo grid.

Grid Search là exhaustive search: nó đảm bảo tìm ra tổ hợp tốt nhất trong grid, nhưng không đảm bảo tổ hợp đó là tối ưu toàn cục — nếu grid không chứa giá trị tốt thật sự, Grid Search sẽ miss.

5

GridSearchCV — API sklearn

Class chuẩn ở sklearn.model_selection.GridSearchCV:

from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC

param_grid = {
    "C": [0.01, 0.1, 1, 10, 100],
    "kernel": ["linear", "rbf"],
    "gamma": ["scale", 0.01, 0.1],
}

grid = GridSearchCV(
    estimator=SVC(),
    param_grid=param_grid,
    cv=5,
    scoring="accuracy",
    n_jobs=-1,
)
grid.fit(X_train, y_train)

Tham số chính:

  • estimator: model (hoặc Pipeline) cần tune.
  • param_grid: dict tên hyperparameter → list giá trị. Có thể là list các dict nếu muốn grid điều kiện (vd kernel="linear" không cần gamma).
  • cv: số fold hoặc CV splitter (KFold, StratifiedKFold...). Mặc định 5-fold stratified cho classifier, 5-fold thường cho regressor.
  • scoring: tên metric (chuỗi) hoặc callable. Xem Bài 24-26 và mục 12 dưới.
  • n_jobs: số process song song. -1 = dùng hết core CPU.
  • refit: sau khi tìm best params, có refit lại trên toàn bộ X_train hay không (mặc định True).
  • verbose: in tiến trình.

Ví dụ grid điều kiện (conditional):

param_grid = [
    {"kernel": ["linear"], "C": [0.1, 1, 10]},
    {"kernel": ["rbf"], "C": [0.1, 1, 10], "gamma": ["scale", 0.01, 0.1]},
]

Sklearn quét từng dict riêng, tổng số tổ hợp = sum tích các dict — tránh quét những tổ hợp vô nghĩa (linear không dùng gamma).

6

Inspect kết quả sau search

Sau khi grid.fit, các attribute hữu ích:

print(grid.best_params_)
# {'C': 10, 'gamma': 0.01, 'kernel': 'rbf'}

print(grid.best_score_)
# 0.967  -- CV score (mean across folds) của best params

best_model = grid.best_estimator_
# model đã refit trên toàn bộ X_train với best params

print(grid.best_index_)
# index của best params trong cv_results_

cv_results_dict đầy đủ thông tin từng tổ hợp — convert sang DataFrame để phân tích sâu:

import pandas as pd

results = pd.DataFrame(grid.cv_results_)
print(results.columns.tolist())
# ['mean_fit_time', 'std_fit_time', 'mean_score_time', ...
#  'param_C', 'param_kernel', 'param_gamma', 'params',
#  'split0_test_score', ..., 'split4_test_score',
#  'mean_test_score', 'std_test_score', 'rank_test_score']

# Top 5 tổ hợp theo mean CV score
print(results.sort_values("rank_test_score").head()[
    ["param_C", "param_kernel", "param_gamma",
     "mean_test_score", "std_test_score"]
])

std_test_score đáng để nhìn cùng mean_test_score: nếu best params có mean cao nhưng std cũng cao, có khi tổ hợp xếp thứ 2 với std thấp hơn lại đáng tin hơn — đặc biệt khi chênh lệch mean rất nhỏ.

7

refit — train lại với best params

Trong quá trình CV, mỗi fold model chỉ train trên K-1 phần data. Sau khi tìm được best params, sklearn (mặc định refit=True) train lại 1 lần nữa trên toàn bộ X_train để tận dụng hết dữ liệu — đây là best_estimator_.

Khi đó grid chính nó hành xử như 1 estimator:

grid.predict(X_test)
grid.score(X_test, y_test)
grid.predict_proba(X_test)   # nếu best_estimator_ hỗ trợ

Các giá trị có thể của refit:

  • True (mặc định): refit trên toàn bộ X_train với best params.
  • False: chỉ search, không refit. best_estimator_ không tồn tại, không gọi predict trực tiếp từ grid được.
  • String: chỉ dùng khi scoring là list nhiều metric — chỉ định metric nào quyết định best (vd refit="f1_macro").

Lưu ý: best_score_ là CV score chứ không phải score trên test. Đánh giá honest cần evaluate grid.score(X_test, y_test) trên test set tách riêng từ trước.

8

GridSearchCV với Pipeline

Đây là pattern quan trọng nhất của bài này: luôn đặt GridSearchCV bên ngoài Pipeline, không phải tune scaler/encoder thủ công trước rồi mới tune model. Lý do: nếu fit scaler trên toàn bộ X_train trước CV, thông tin từ validation fold rỉ vào train fold qua mean/std — đó là data leak (Bài 7-8, 14).

Cú pháp đặt tham số step bằng double underscore step_name__param_name:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.model_selection import GridSearchCV

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC()),
])

param_grid = {
    "svm__C": [0.1, 1, 10],
    "svm__kernel": ["rbf"],
    "svm__gamma": ["scale", 0.01, 0.1],
}

grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy", n_jobs=-1)
grid.fit(X_train, y_train)
print(grid.best_params_)
# {'svm__C': 10, 'svm__gamma': 0.01, 'svm__kernel': 'rbf'}

Mỗi fold trong CV: scaler fit trên train fold → transform train + val fold → SVM fit trên train fold đã scale → score trên val fold. Không có chỗ nào val fold tham gia vào việc tính mean/std. Pipeline lo phần đó tự động — đó là lý do bài 14 nhấn mạnh dùng Pipeline.

Có thể tune luôn cả tham số preprocessing:

param_grid = {
    "scaler__with_mean": [True, False],
    "svm__C": [0.1, 1, 10],
}

Pipeline lồng nhiều tầng: "preprocessor__num__scaler__with_mean" — mỗi tầng cách bằng __.

9

Cost của Grid Search

Công thức cơ bản:

\[ \text{total\_fits} = K_{\text{fold}} \times \prod_{i} |\text{grid}_i| \]

Với grid C (5 giá trị) × kernel (2) × gamma (3) × CV 5-fold:

total_fits = 5 * (5 * 2 * 3)
print(total_fits)  # 150

Mỗi fit có chi phí riêng, phụ thuộc model và dataset:

  • SVM với kernel rbf: O(n²) ~ O(n³) theo số sample — đắt với n > 10k.
  • Random Forest với n_estimators=500: tuyến tính theo số cây × số sample.
  • XGBoost: phụ thuộc n_estimators, max_depth, kích thước data.

Hai cờ giúp giảm wall-clock time:

  • n_jobs=-1 — chạy song song các fit độc lập trên tất cả core. Tăng tốc gần tuyến tính nếu RAM đủ.
  • pre_dispatch — kiểm soát số job dispatch trước, tránh nuốt RAM khi dataset to.

Nếu tổng fits quá 1000-2000, cân nhắc:

  • Giảm grid (loại bỏ giá trị xa best đã thấy ở vòng trước).
  • Giảm cv tạm thời (3-fold cho vòng coarse, 5-fold cho vòng fine).
  • Subsample X_train cho vòng coarse, full data cho vòng fine.
  • Chuyển sang Random Search (Bài 41).
10

Thiết kế search space

Vài nguyên tắc thực dụng:

  • Log-scale cho hyperparameter continuous: C, gamma, alpha, learning_rate trải nhiều bậc độ lớn. Quét [0.001, 0.01, 0.1, 1, 10, 100] ý nghĩa hơn nhiều so với [0.1, 0.2, 0.3, 0.4].
  • Coarse trước, fine sau: vòng 1 quét log-scale rộng để khoanh vùng. Vòng 2 quét hẹp quanh giá trị tốt nhất với bước nhỏ hơn. Hiệu quả gấp nhiều lần so với quét fine ngay từ đầu.
  • Đừng quét quá fine: chênh lệch C=1.0 vs C=1.1 thường nhỏ hơn noise của CV — quét không đem lại tín hiệu.
  • Quan sát boundary: nếu best params nằm ở mép grid (vd C=100 ở grid [0.01, 0.1, 1, 10, 100]), khả năng cao tối ưu nằm ngoài — mở rộng grid.
  • Cố định hyperparameter rõ ràng không cần tune: vd random_state, n_jobs, max_iter đủ lớn — đưa vào constructor model, không vào grid.
import numpy as np

# Log-scale từ 10^-3 đến 10^3, 7 điểm
C_grid = np.logspace(-3, 3, 7)
# array([1.e-03, 1.e-02, 1.e-01, 1.e+00, 1.e+01, 1.e+02, 1.e+03])
11

Hyperparameter thường dùng theo model

Bảng tham khảo khi dựng grid lần đầu — bắt đầu từ vài giá trị log-scale rồi mở rộng:

  • Linear Regression / Logistic Regression: C (hoặc alpha), penalty (l1, l2, elasticnet), solver phù hợp penalty.
  • Ridge / Lasso: alpha (log-scale).
  • Decision Tree: max_depth, min_samples_leaf, min_samples_split, ccp_alpha (cost-complexity pruning).
  • Random Forest: n_estimators (100-500), max_features (sqrt, log2, float), max_depth, min_samples_leaf.
  • Gradient Boosting / XGBoost / LightGBM: n_estimators, learning_rate, max_depth, subsample, colsample_bytree, reg_alpha, reg_lambda.
  • SVM: C, kernel (linear, rbf, poly), gamma, degree (nếu poly).
  • KNN: n_neighbors, weights (uniform, distance), metric (euclidean, manhattan...).

Một số hyperparameter có quan hệ trade-off: n_estimators cao + learning_rate thấp thường tốt hơn ngược lại với boosting. Khi quét, để cả hai trong grid để model học ra cặp tốt nhất, không chọn 1 cái cố định.

12

scoring — chọn metric tune

Metric tune phải khớp với bài toán, không tự động là accuracy:

  • Classification: "accuracy", "f1", "f1_macro", "f1_weighted", "precision", "recall", "roc_auc", "average_precision", "neg_log_loss".
  • Regression: "r2", "neg_root_mean_squared_error", "neg_mean_absolute_error", "neg_mean_absolute_percentage_error".
  • Clustering: "adjusted_rand_score", "silhouette".

Sklearn quy ước higher is better, nên loss/error có tiền tố neg_ — best params là tổ hợp có mean_test_score cao nhất, vẫn có nghĩa "RMSE nhỏ nhất" với neg_root_mean_squared_error.

Multi-metric: truyền list/dict, đồng thời chỉ refit theo 1 metric:

grid = GridSearchCV(
    pipe,
    param_grid,
    cv=5,
    scoring=["accuracy", "f1_macro", "roc_auc_ovr"],
    refit="f1_macro",   # phải chỉ rõ metric refit
)

Khi đó cv_results_ chứa mean_test_accuracy, mean_test_f1_macro, mean_test_roc_auc_ovr — phân tích trade-off giữa các metric.

Với class imbalance, dùng accuracy dễ đánh lừa — chọn f1_macro, roc_auc, hoặc average_precision (xem Bài 24-26).

13

Pitfall thường gặp

  • Fit scaler trên toàn bộ X trước khi GridSearchCV: data leak qua mean/std. Sửa: bọc trong Pipeline (mục 8).
  • Dùng train_test_split 1 lần thay cho CV: best params phụ thuộc may rủi của split duy nhất. Dùng cv=5 trở lên.
  • Overfit grid trên test set: nếu mỗi vòng tune đều "kiểm tra trên test" rồi quay lại sửa grid, test set không còn honest. Test set chỉ chạm 1 lần ở cuối.
  • Best params nằm ở mép grid: tối ưu thật có thể ngoài grid. Mở rộng grid theo hướng đó và chạy lại.
  • Grid quá lớn ngay từ đầu: tốn thời gian quét nhiều giá trị không có tín hiệu. Coarse trước, fine sau.
  • Đánh giá best_score_ như metric production: best_score_ là CV score trên train; có optimistic bias vì chính grid được chọn theo CV. Báo cáo metric production từ grid.score(X_test, y_test).
  • Cần ước lượng honest sau khi tune: dùng nested CV — vòng ngoài chia train/val, vòng trong là GridSearchCV trên mỗi fold ngoài. Tốn hơn nhưng tránh optimistic bias khi báo cáo metric.
  • Forget random_state: CV splitter mặc định có shuffle phụ thuộc seed. Set random_state cho CV splitter để reproducible.
  • Quên rằng n_jobs=-1 tốn RAM: copy data sang mỗi process. Với dataset to, dùng n_jobs=2-4 hoặc kết hợp pre_dispatch="2*n_jobs".
14

Workflow chuẩn end-to-end

  1. Split: train_test_split(X, y, test_size=0.2, stratify=y, random_state=...). Đặt test sang 1 bên, không chạm cho tới bước cuối.
  2. Pipeline: gom preprocessing (impute, scale, encode) + model thành 1 object.
  3. GridSearchCV trên train: GridSearchCV(pipe, grid, cv=5, scoring="...", n_jobs=-1), grid.fit(X_train, y_train).
  4. Phân tích: best_params_, best_score_, pd.DataFrame(cv_results_).
  5. Evaluate trên test 1 lần: grid.score(X_test, y_test) — đây là metric báo cáo. Nếu kết quả không hài lòng, không quay lại tune trên cùng test set (đó là leak).
  6. Save best estimator: joblib.dump(grid.best_estimator_, "model.pkl") cho serving.

Nếu cần ước lượng tổng quát hơn cho báo cáo paper/thesis, thêm 1 lớp ngoài:

from sklearn.model_selection import cross_val_score, StratifiedKFold

outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
nested_scores = cross_val_score(grid, X, y, cv=outer, scoring="f1_macro")
print(nested_scores.mean(), nested_scores.std())

Nested CV: mỗi fold ngoài, GridSearchCV chạy CV trong để tìm best params, rồi đánh giá best_estimator_ trên fold ngoài. Kết quả là ước lượng honest về performance của quy trình tuning.

15

Code — SVM trên Iris + heatmap

Pipeline + GridSearchCV cho SVM trên Iris, visualize cv_results_ bằng heatmap C × gamma:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

# 1. Load + split
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42,
)

# 2. Pipeline scaler + SVM
pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC(kernel="rbf")),
])

# 3. Grid log-scale
C_values = np.logspace(-2, 2, 5)        # 0.01, 0.1, 1, 10, 100
gamma_values = np.logspace(-3, 1, 5)    # 0.001, 0.01, 0.1, 1, 10

param_grid = {
    "svm__C": C_values,
    "svm__gamma": gamma_values,
}

# 4. Grid search với CV 5-fold
grid = GridSearchCV(pipe, param_grid, cv=5,
                    scoring="accuracy", n_jobs=-1)
grid.fit(X_train, y_train)

print("Best params:", grid.best_params_)
print(f"Best CV score: {grid.best_score_:.4f}")
print(f"Test accuracy: {grid.score(X_test, y_test):.4f}")

# 5. Heatmap mean_test_score
scores = grid.cv_results_["mean_test_score"].reshape(
    len(C_values), len(gamma_values)
)

fig, ax = plt.subplots(figsize=(7, 5))
im = ax.imshow(scores, cmap="viridis", aspect="auto")
ax.set_xticks(range(len(gamma_values)))
ax.set_xticklabels([f"{g:.3g}" for g in gamma_values])
ax.set_yticks(range(len(C_values)))
ax.set_yticklabels([f"{c:.3g}" for c in C_values])
ax.set_xlabel("gamma")
ax.set_ylabel("C")
ax.set_title("Mean CV accuracy")
plt.colorbar(im, ax=ax)
plt.tight_layout()
plt.show()

Hai điểm thực dụng:

  • StandardScaler đứng trong Pipeline → mỗi fold scaler fit lại trên train fold, val fold chỉ transform. Không có leak.
  • Heatmap cho thấy "vùng" hyperparameter cho điểm cao — không chỉ 1 cặp tốt nhất. Nếu vùng tốt nằm dọc một dải, có thể coarse-to-fine ở vòng sau quanh dải đó.

Tóm tắt cv_results_ nhanh:

results = pd.DataFrame(grid.cv_results_)
top5 = (results
        .sort_values("rank_test_score")
        .head(5)
        [["param_svm__C", "param_svm__gamma",
          "mean_test_score", "std_test_score"]])
print(top5)
16

Khi Grid Search không scale

Grid Search là cách tiếp cận "đúng giáo trình" và dễ hiểu, nhưng có giới hạn:

  • Curse of dimensionality: số tổ hợp tăng theo tích. Mỗi hyperparameter thêm vào nhân thêm 1 chiều. Với 6-7 hyperparameter, grid bùng nổ — không thể quét đầy đủ trong thời gian thực tế.
  • Lãng phí ở hyperparameter ít ảnh hưởng: Grid đối xử mọi hyperparameter như nhau, dù trong thực tế learning_rate ảnh hưởng nhiều hơn min_samples_leaf.
  • Không thông tin từ kết quả trước: tổ hợp i không "học" gì từ tổ hợp i-1. Mỗi tổ hợp chạy độc lập.

Các phương pháp thay thế (Bài 41):

  • Random Search (RandomizedSearchCV): sample ngẫu nhiên từ distribution của mỗi hyperparameter. Với cùng budget, thường tìm ra tổ hợp tốt nhanh hơn Grid khi search space lớn — vì nó không lãng phí ở hyperparameter ít ảnh hưởng (Bergstra & Bengio, 2012).
  • Bayesian Optimization (Optuna, Hyperopt, scikit-optimize): xây surrogate model để "đoán" tổ hợp tiếp theo có khả năng tốt nhất, dựa trên các tổ hợp đã thử. Hiệu quả nhất khi mỗi fit đắt.
  • Successive Halving (HalvingGridSearchCV, HalvingRandomSearchCV): loại nhanh tổ hợp tệ ở vòng đầu với ít resource, dồn resource cho tổ hợp hứa hẹn ở vòng sau.

Quy tắc thực dụng: dùng GridSearchCV khi grid < vài trăm tổ hợp; chuyển sang Random Search / Bayesian khi nhiều hơn.

17

Bài tập thực hành

Bài 1. Trên Iris, build Pipeline StandardScalerRandomForestClassifier. GridSearchCV với:

param_grid = {
    "rf__n_estimators": [50, 100, 200],
    "rf__max_depth": [None, 3, 5, 10],
}

In best_params_, best_score_, và test accuracy. Tổng số fit = ?

Bài 2. Dataset Breast Cancer (sklearn.datasets.load_breast_cancer). Pipeline StandardScalerLogisticRegression(max_iter=5000). Grid:

param_grid = [
    {"lr__penalty": ["l2"], "lr__C": np.logspace(-3, 3, 7),
     "lr__solver": ["lbfgs"]},
    {"lr__penalty": ["l1"], "lr__C": np.logspace(-3, 3, 7),
     "lr__solver": ["liblinear"]},
]

Tune theo scoring="f1". Báo cáo best penalty + C. So sánh với scoring="accuracy" — kết quả có khác không?

Bài 3. Trên kết quả Bài 1, convert grid.cv_results_ thành DataFrame. Lọc các tổ hợp có mean_test_score >= best_score_ - 0.01 (gần với best). Tổ hợp đơn giản nhất (max_depth thấp nhất, n_estimators thấp nhất) trong số đó là gì? Quy tắc Occam's razor: chọn model đơn giản trong số các model tương đương về performance.

Bài 4. Trên Iris, so sánh wall-clock time của:

  1. GridSearchCV (5 × 5 grid, cv=5).
  2. Nested CV: vòng ngoài 5-fold, mỗi fold ngoài chạy lại GridSearchCV như trên.

Lý giải vì sao nested CV tốn gấp ~5 lần. Khi nào cần nested CV trong dự án thực?

Gợi ý đáp án Bài 1: số combo = 3 × 4 = 12; tổng fit = 12 × 5 (cv) = 60, cộng 1 refit cuối = 61 lần fit pipeline.

18

Bài tiếp theo

Bài 41: Random Search và Bayesian Optimization — khi search space lớn, Grid Search trở thành tốn kém. Random Search sample ngẫu nhiên từ distribution và thường tìm ra điểm tốt nhanh hơn; Bayesian Optimization dùng surrogate model để "thông minh hơn" trong việc chọn tổ hợp tiếp theo.