Mục lục
- Mục tiêu bài học
- Hyperparameter vs parameter
- Manual tuning — vì sao không đủ
- Grid Search — quét hệ thống
- GridSearchCV — API sklearn
- Inspect kết quả sau search
- refit — train lại với best params
- GridSearchCV với Pipeline
- Cost của Grid Search
- Thiết kế search space
- Hyperparameter thường dùng theo model
- scoring — chọn metric tune
- Pitfall thường gặp
- Workflow chuẩn end-to-end
- Code — SVM trên Iris + heatmap
- Khi Grid Search không scale
- Bài tập thực hành
- Bài tiếp theo
Mục tiêu bài học
Sau bài này, bạn sẽ:
- Phân biệt rõ hyperparameter và parameter.
- 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).
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: weightwvà biasbcủ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:
Ctrong SVM/LogReg,max_depthcủa Tree,n_neighborscủa KNN,n_estimatorscủa Random Forest,learning_ratecủa XGBoost.
Hyperparameter quyết định capacity và cá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.
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.
Grid Search — quét hệ thống
Ý tưởng Grid Search rất thẳng:
- Định nghĩa grid — với mỗi hyperparameter, liệt kê tập giá trị muốn thử.
- Liệt kê mọi tổ hợp (tích Descartes các tập giá trị).
- Với mỗi tổ hợp, train + đánh giá bằng cross-validation (vd K-Fold).
- 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.
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:dicttên hyperparameter → list giá trị. Có thể là list các dict nếu muốn grid điều kiện (vdkernel="linear"không cầngamma).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_trainhay không (mặc địnhTrue).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).
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_ là 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ỏ.
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_trainvới best params.False: chỉ search, không refit.best_estimator_không tồn tại, không gọipredicttrực tiếp từgridđược.- String: chỉ dùng khi
scoringlà list nhiều metric — chỉ định metric nào quyết định best (vdrefit="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.
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 __.
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
cvtạm thời (3-fold cho vòng coarse, 5-fold cho vòng fine). - Subsample
X_traincho vòng coarse, full data cho vòng fine. - Chuyển sang Random Search (Bài 41).
Thiết kế search space
Vài nguyên tắc thực dụng:
- Log-scale cho hyperparameter continuous:
C,gamma,alpha,learning_ratetrả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.0vsC=1.1thườ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])
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ặcalpha),penalty(l1,l2,elasticnet),solverphù 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.
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).
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_split1 lần thay cho CV: best params phụ thuộc may rủi của split duy nhất. Dùngcv=5trở 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. Setrandom_statecho CV splitter để reproducible. - Quên rằng
n_jobs=-1tốn RAM: copy data sang mỗi process. Với dataset to, dùngn_jobs=2-4hoặc kết hợppre_dispatch="2*n_jobs".
Workflow chuẩn end-to-end
- 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. - Pipeline: gom preprocessing (impute, scale, encode) + model thành 1 object.
- GridSearchCV trên train:
GridSearchCV(pipe, grid, cv=5, scoring="...", n_jobs=-1),grid.fit(X_train, y_train). - Phân tích:
best_params_,best_score_,pd.DataFrame(cv_results_). - 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). - 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.
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)
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ơnmin_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.
Bài tập thực hành
Bài 1. Trên Iris, build Pipeline StandardScaler → RandomForestClassifier. 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 StandardScaler → LogisticRegression(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:
- GridSearchCV (5 × 5 grid, cv=5).
- 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.
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.
Tài liệu tham khảo
- scikit-learn — User Guide: Tuning the hyper-parameters of an estimator
- scikit-learn — GridSearchCV API reference
- scikit-learn — Example: Custom refit strategy of a grid search with cross-validation
- scikit-learn — Nested cross-validation
- scikit-learn — The scoring parameter: defining model evaluation rules
- Bergstra & Bengio (2012) — Random Search for Hyper-Parameter Optimization, JMLR
