Một mô hình chỉ có giá trị nếu ta đo được nó tốt tới đâu trên dữ liệu chưa từng thấy. Bài này đi từ cách tách dữ liệu đúng (train/validation/test), cross-validation, hiểu bias–variance, tới bộ metrics đầy đủ cho bài toán phân loại và hồi quy, xử lý mất cân bằng lớp và cuối cùng là tinh chỉnh hyperparameter một cách trung thực.
Mục tiêu thật của machine learning là generalization — dự đoán tốt trên dữ liệu mới trong tương lai, chứ không phải học thuộc lòng tập huấn luyện. Một mô hình đạt 99% accuracy trên tập train nhưng 60% trên dữ liệu thực tế là vô dụng. Vì vậy toàn bộ quy trình đánh giá được thiết kế để ước lượng không thiên lệch hiệu năng trên dữ liệu chưa thấy.
Ba sai lầm phổ biến khiến con số đánh giá đẹp một cách giả tạo:
Cách chia dữ liệu chuẩn gồm ba phần, mỗi phần một vai trò khác nhau:
| Tập | Tỉ lệ điển hình | Vai trò |
|---|---|---|
| Train | 60–80% | Mô hình học tham số (weights) từ đây. |
| Validation | 10–20% | Chọn model / hyperparameter / dừng sớm. Được nhìn nhiều lần trong lúc phát triển. |
| Test | 10–20% | Chỉ đụng tới một lần duy nhất ở cuối để báo cáo hiệu năng thật. |
Vì sao phải tách test riêng biệt? Vì khi bạn dùng validation để tinh chỉnh hàng chục lần, bạn đang gián tiếp "học" theo validation — nó dần mất tính khách quan. Test set được giữ trong "két sắt", chỉ mở ra khi mọi quyết định đã chốt, để cho con số cuối cùng phản ánh trung thực dữ liệu tương lai.
from sklearn.model_selection import train_test_split # Tách test 20% trước, giữ nguyên tỉ lệ lớp với stratify X_temp, X_test, y_temp, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) # Từ phần còn lại tách validation X_train, X_val, y_train, y_val = train_test_split( X_temp, y_temp, test_size=0.25, random_state=42, stratify=y_temp) # 0.25 * 0.8 = 0.2 -> train 60% / val 20% / test 20%
Data leakage là kẻ thù âm thầm nhất. Ví dụ kinh điển: chuẩn hoá dữ liệu (StandardScaler) bằng mean/std tính trên toàn bộ dataset trước khi split — thống kê của test đã "rò" vào train. Cách đúng là fit scaler chỉ trên train rồi transform phần còn lại, tốt nhất là bọc trong một Pipeline.
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline([ ("scaler", StandardScaler()), # chỉ fit trên fold train ("clf", LogisticRegression()) ]) pipe.fit(X_train, y_train) # scaler học thống kê từ train mà thôi
Một lần split ngẫu nhiên có thể "may/rủi". Cross-validation (CV) chia dữ liệu thành nhiều phần, luân phiên dùng mỗi phần làm validation, rồi lấy trung bình — cho ước lượng ổn định hơn và tận dụng toàn bộ dữ liệu.
| Kỹ thuật | Cách hoạt động | Dùng khi |
|---|---|---|
| k-Fold | Chia thành k phần bằng nhau (thường k=5 hoặc 10); mỗi lượt 1 fold làm test, k−1 fold còn lại train. | Bài toán chung, dữ liệu đủ lớn. |
| Stratified k-Fold | Như k-Fold nhưng giữ nguyên tỉ lệ lớp trong mỗi fold. | Phân loại, nhất là khi lớp mất cân bằng. |
| Leave-One-Out (LOO) | k = n: mỗi lượt bỏ đúng 1 mẫu làm test. Rất tốn kém, phương sai cao. | Dataset rất nhỏ. |
| TimeSeriesSplit | Luôn train trên quá khứ, test trên tương lai; không xáo trộn thời gian. | Dữ liệu chuỗi thời gian. |
from sklearn.model_selection import (cross_val_score, StratifiedKFold, TimeSeriesSplit) # 5-fold phân tầng, đo bằng F1 cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipe, X, y, cv=cv, scoring="f1") print(scores.mean(), "+/-", scores.std()) # CV cho chuỗi thời gian: fold sau luôn nằm ở tương lai tscv = TimeSeriesSplit(n_splits=5) scores_ts = cross_val_score(model, X, y, cv=tscv, scoring="neg_root_mean_squared_error")
TimeSeriesSplit (hoặc walk-forward validation).Sai số kỳ vọng của mô hình phân rã thành ba phần: Error = Bias² + Variance + Noise.
Giảm bias thường làm tăng variance và ngược lại — đây là tradeoff. Mục tiêu là điểm cân bằng tối ưu.
| Tình huống | Train score | Val/Test score | Chẩn đoán |
|---|---|---|---|
| Underfit | Thấp | Thấp | Bias cao |
| Overfit | Rất cao | Thấp (gap lớn) | Variance cao |
| Tốt | Cao | Cao (gap nhỏ) | Cân bằng |
Learning curve vẽ điểm train & validation theo kích thước tập huấn luyện, giúp chẩn đoán: nếu hai đường hội tụ ở mức thấp → underfit; nếu có khoảng gap lớn dai dẳng → overfit (và thêm dữ liệu có thể giúp).
from sklearn.model_selection import learning_curve import numpy as np sizes, train_sc, val_sc = learning_curve( model, X, y, cv=5, scoring="accuracy", train_sizes=np.linspace(0.1, 1.0, 10)) # So sánh train_sc.mean(1) với val_sc.mean(1) theo sizes
| Vấn đề | Cách khắc phục |
|---|---|
| Underfit (bias cao) | Tăng độ phức tạp mô hình, thêm feature, giảm regularization, huấn luyện lâu hơn. |
| Overfit (variance cao) | Thêm dữ liệu, tăng regularization (L1/L2, dropout), giảm độ phức tạp, feature selection, early stopping, ensemble/bagging. |
Mọi metric phân loại nhị phân bắt nguồn từ confusion matrix — bảng đối chiếu dự đoán với thực tế:
| Dự đoán Positive | Dự đoán Negative | |
|---|---|---|
| Thực tế Positive | TP (đúng dương) | FN (bỏ sót) |
| Thực tế Negative | FP (báo động giả) | TN (đúng âm) |
Từ bốn ô này ta định nghĩa các metric (viết dạng công thức text):
Accuracy = (TP + TN) / (TP + TN + FP + FN) Precision = TP / (TP + FP) # trong số báo Positive, bao nhiêu đúng? Recall/TPR = TP / (TP + FN) # trong số Positive thật, bắt được bao nhiêu? Specificity = TN / (TN + FP) # tỉ lệ Negative thật được nhận đúng F1 = 2 * (P * R) / (P + R) # trung bình điều hoà của P và R
ROC curve vẽ TPR (recall) theo FPR = FP/(FP+TN) khi quét ngưỡng; AUC (diện tích dưới đường) đo khả năng phân tách của mô hình: 0.5 = đoán mò, 1.0 = hoàn hảo. Với dữ liệu rất lệch, Precision-Recall curve và PR-AUC phản ánh trung thực hơn ROC vì không bị số lượng TN khổng lồ làm đẹp con số.
from sklearn.metrics import (classification_report, confusion_matrix, roc_auc_score, average_precision_score, log_loss) y_pred = pipe.predict(X_test) y_proba = pipe.predict_proba(X_test)[:, 1] print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred)) # P, R, F1 mỗi lớp print("ROC-AUC:", roc_auc_score(y_test, y_proba)) print("PR-AUC :", average_precision_score(y_test, y_proba)) print("LogLoss:", log_loss(y_test, y_proba))
Chọn threshold: mặc định 0.5 hiếm khi tối ưu. Model xuất ra xác suất; ta chọn ngưỡng cắt dựa trên mục tiêu nghiệp vụ — hạ ngưỡng để tăng recall (bắt nhiều positive hơn, chấp nhận nhiều báo động giả), nâng ngưỡng để tăng precision. Có thể chọn ngưỡng tối đa hoá F1 hoặc theo ràng buộc "recall ≥ 90%".
Log-loss (cross-entropy) phạt nặng các dự đoán tự tin nhưng sai; nó đánh giá chất lượng xác suất, không chỉ nhãn cuối — quan trọng khi bạn dùng chính xác suất đó để ra quyết định.
| Ưu tiên | Khi nào | Ví dụ |
|---|---|---|
| Precision cao | Chi phí của FP (báo động giả) lớn — hành động sai gây thiệt hại. | Lọc spam (đừng chặn nhầm mail quan trọng), đề xuất phẫu thuật. |
| Recall cao | Chi phí của FN (bỏ sót) lớn — bỏ lọt case nguy hiểm. | Phát hiện ung thư, gian lận thẻ, phát hiện xâm nhập. |
| F1 | Cần cân bằng cả hai, dữ liệu lệch. | Đa số bài toán phân loại thực tế. |
MAE = mean(|y - ŷ|) # sai số tuyệt đối trung bình MSE = mean((y - ŷ)²) # phạt bình phương -> nhạy outlier RMSE = sqrt(MSE) # cùng đơn vị với y R² = 1 - SS_res / SS_tot # tỉ lệ phương sai được giải thích MAPE = mean(|y - ŷ| / |y|) * 100% # sai số phần trăm
| Metric | Đặc điểm | Nhạy outlier? |
|---|---|---|
| MAE | Trung bình sai số tuyệt đối, cùng đơn vị với y, dễ diễn giải. | Ít (bền vững) |
| MSE | Bình phương sai số; đơn vị bị bình phương nên khó diễn giải. | Rất nhạy |
| RMSE | Căn của MSE — cùng đơn vị y, vẫn phạt mạnh sai số lớn. | Nhạy |
| R² | 0→1 (có thể âm nếu tệ hơn dự đoán trung bình); so với baseline. | Nhạy (qua MSE) |
| MAPE | Sai số tương đối theo %, dễ so sánh giữa các thang đo. | Vô định khi y=0; lệch về giá trị nhỏ |
Khác biệt cốt lõi: MAE coi mọi sai số như nhau; MSE/RMSE phạt sai số lớn nặng hơn (do bình phương) nên rất nhạy với outlier. Nếu dữ liệu có ngoại lệ và bạn không muốn chúng chi phối, ưu tiên MAE. Nếu sai số lớn đặc biệt nguy hiểm, dùng RMSE. Dùng MAPE khi quan tâm sai số tương đối (dự báo doanh số theo %).
from sklearn.metrics import (mean_absolute_error, mean_squared_error, r2_score, mean_absolute_percentage_error) mae = mean_absolute_error(y_test, y_pred) rmse = mean_squared_error(y_test, y_pred, squared=False) r2 = r2_score(y_test, y_pred) mape = mean_absolute_percentage_error(y_test, y_pred)
Khi một lớp áp đảo (gian lận 0.1%, bệnh hiếm...), mô hình có xu hướng "phớt lờ" lớp thiểu số. Ba nhóm giải pháp:
class_weight="balanced" để phạt sai lớp thiểu số nặng hơn, không cần đổi dữ liệu — có sẵn trong LogisticRegression, SVM, RandomForest, XGBoost (scale_pos_weight).from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import SMOTE pipe = ImbPipeline([ ("scaler", StandardScaler()), ("smote", SMOTE(random_state=42)), # chỉ tác động fold train ("clf", LogisticRegression(class_weight="balanced")) ])
Hyperparameter (số cây, độ sâu, learning rate, C...) không được học từ dữ liệu mà phải dò tìm. Ba chiến lược:
| Phương pháp | Cách làm | Ưu / nhược |
|---|---|---|
| GridSearchCV | Duyệt vét cạn mọi tổ hợp trong lưới. | Đầy đủ nhưng bùng nổ tổ hợp; hợp khi ít tham số. |
| RandomizedSearchCV | Lấy mẫu ngẫu nhiên n_iter tổ hợp. | Hiệu quả hơn nhiều khi không gian lớn; thường tìm được điểm tốt nhanh. |
| Bayesian (Optuna) | Dùng kết quả trước để định hướng lần thử tiếp theo. | Ít lần thử nhất cho không gian lớn/đắt; cần thêm thư viện. |
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV param_grid = {"clf__C": [0.1, 1, 10], "clf__penalty": ["l1", "l2"]} grid = GridSearchCV(pipe, param_grid, cv=5, scoring="f1", n_jobs=-1) grid.fit(X_train, y_train) print(grid.best_params_, grid.best_score_)
# Optuna: tối ưu Bayesian import optuna from sklearn.model_selection import cross_val_score def objective(trial): C = trial.suggest_float("C", 1e-3, 1e2, log=True) model = LogisticRegression(C=C, max_iter=1000) return cross_val_score(model, X_train, y_train, cv=5, scoring="f1").mean() study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50)
Nested CV: nếu bạn vừa tune hyperparameter vừa muốn báo cáo hiệu năng trung thực, một vòng CV là chưa đủ (điểm best_score_ đã bị "lạc quan" vì đã chọn theo nó). Nested CV dùng hai vòng lồng nhau: vòng trong chọn hyperparameter, vòng ngoài đo hiệu năng trên fold mà vòng trong chưa thấy.
from sklearn.model_selection import cross_val_score, KFold inner = KFold(5, shuffle=True, random_state=1) outer = KFold(5, shuffle=True, random_state=2) search = GridSearchCV(pipe, param_grid, cv=inner, scoring="f1") nested = cross_val_score(search, X, y, cv=outer) # ước lượng không thiên lệch print(nested.mean(), nested.std())