ML 7 — Mất cân bằng dữ liệu & tinh chỉnh mô hình

13 thg 7, 2026 6 lượt xem
#machine-learning
#ai
#overfitting
#smote
#hyperparameter-tuning
#imbalanced

ML 7 — Mất cân bằng dữ liệu & tinh chỉnh mô hình

Đến bài này, bạn đã có một mô hình chạy được: biết dựng quy trình chuẩn, biết dùng cây và ensemble, và biết đánh giá đúng cách. Nhưng khi mang mô hình ra bài toán thật, hai câu hỏi gần như luôn xuất hiện và làm không ít đội mất nhiều tuần:

  1. "Lớp tôi cần dự đoán chỉ chiếm 1% dữ liệu — sao mô hình cứ đoán toàn lớp còn lại?"
  2. "Tôi để mặc định siêu tham số, làm sao biết cấu hình nào tốt hơn mà không tự lừa mình?"

Bài này giải quyết trọn hai vấn đề đó: mất cân bằng dữ liệu (imbalanced data)tinh chỉnh siêu tham số (hyperparameter tuning), kèm cách nhận biết và chống overfitting. Đây là những kỹ năng phân biệt một mô hình "chạy trên notebook" với một mô hình "dùng được trong sản xuất".

Phần 1 — Mất cân bằng dữ liệu

Vấn đề trông như thế nào

Mất cân bằng xảy ra khi các lớp có tỉ lệ chênh lệch lớn. Ví dụ điển hình:

  • Gian lận thẻ (fraud): 1 giao dịch gian lận trên 1.000–10.000 giao dịch.
  • Vỡ nợ (default): 2–5% khách hàng vay không trả được.
  • Rời bỏ (churn): 5–15% khách hàng rời đi mỗi kỳ.

Điểm chung: lớp dương (positive) — thứ ta thực sự quan tâm — lại là lớp hiếm.

Vì sao accuracy vô nghĩa

Giả sử 1% giao dịch là gian lận. Một mô hình "ngốc" chỉ luôn trả lời "không gian lận" sẽ đạt 99% accuracy — nghe rất đẹp, nhưng nó bắt được 0 vụ gian lận. Accuracy bị lớp đa số nuốt chửng nên hoàn toàn vô dụng ở đây.

Như đã bàn kỹ trong bài đánh giá mô hình, với bài toán mất cân bằng bạn phải nhìn vào:

  • Precision & Recall của lớp dương, và F1 khi muốn cân bằng hai chỉ số này.
  • PR-AUC (diện tích dưới đường Precision–Recall) — nhạy với lớp hiếm hơn nhiều so với ROC-AUC.
  • Recall theo mục tiêu: ví dụ "bắt được ≥ 80% vụ gian lận", rồi tối ưu precision tại mức recall đó.

Nguyên tắc: chọn metric trước, rồi mới xử lý mất cân bằng — vì cách xử lý sẽ được đánh giá bằng chính metric đó.

Ba nhóm giải pháp

Có ba hướng xử lý mất cân bằng, và chúng không loại trừ nhau:

1. Resampling — thay đổi dữ liệu

Oversampling làm lớp hiếm nhiều lên. Cách ngây thơ là nhân bản (duplicate) mẫu dương, nhưng dễ khiến mô hình học vẹt. SMOTE (Synthetic Minority Over-sampling Technique) thay vì nhân bản thì tạo mẫu tổng hợp mới: với mỗi mẫu dương, nó tìm các láng giềng gần nhất cùng lớp và nội suy một điểm mới nằm giữa chúng. Nhờ đó vùng của lớp hiếm được "phủ" dày hơn thay vì chỉ lặp lại vài điểm cũ. Có nhiều biến thể như BorderlineSMOTE (tập trung vào mẫu gần ranh giới), ADASYN, và SMOTENC (dữ liệu có cả cột phân loại).

Undersampling làm lớp đa số ít đi (bỏ bớt mẫu âm). Nhanh, nhẹ, nhưng vứt bỏ thông tin — chỉ nên dùng khi dữ liệu rất lớn.

Kết hợp như SMOTE + Tomek links hoặc SMOTE + ENN: oversample lớp hiếm rồi dọn các mẫu nhiễu ở vùng chồng lấn.

Cảnh báo cực kỳ quan trọng: CHỈ resample trên tập train, và phải nằm trong vòng cross-validation. Nếu bạn SMOTE toàn bộ dữ liệu trước khi chia, các mẫu tổng hợp sinh từ tập train có thể "rò rỉ" (leakage) sang tập validation/test, khiến điểm số bị thổi phồng giả tạo. Cách đúng là dùng Pipeline của imbalanced-learn để việc resampling chỉ diễn ra bên trong mỗi fold. Đây là một dạng leakage rất phổ biến và tinh vi — nối lại với nguyên tắc chống rò rỉ ở quy trình chuẩn.

2. Class weight — thay đổi hàm mất mát

Thay vì đổi dữ liệu, ta phạt nặng hơn khi sai ở lớp hiếm. Trong scikit-learn, hầu hết bộ phân loại (LogisticRegression, SVM, RandomForest, ...) có tham số class_weight='balanced': nó tự động gán trọng số tỉ lệ nghịch với tần suất lớp — lớp càng hiếm, sai một mẫu càng "đắt". Với boosting, XGBoost/LightGBM dùng scale_pos_weight (thường đặt xấp xỉ tỉ lệ số mẫu âm / số mẫu dương), LightGBM còn có is_unbalance.

Đây là cost-sensitive learning: ta đưa chi phí thực tế của lỗi vào quá trình học. Ưu điểm lớn là không tạo dữ liệu giả nên tránh được nhiều rủi ro của SMOTE.

3. Điều chỉnh ngưỡng — thay đổi quyết định

Đây thường là cách đơn giản và hiệu quả nhất, nhưng hay bị bỏ quên. Mô hình phân loại trả về xác suất; ngưỡng mặc định 0.5 chỉ là quy ước. Với dữ liệu mất cân bằng, bạn hoàn toàn có thể hạ ngưỡng (ví dụ 0.15) để bắt được nhiều ca dương hơn (tăng recall), đổi lại nhiều báo động giả hơn (giảm precision).

Điểm hay: bạn không cần đụng đến dữ liệu hay train lại — chỉ cần huấn luyện một mô hình cho ra xác suất tốt (well-calibrated), rồi quét ngưỡng để chọn điểm khớp mục tiêu kinh doanh. Cách chọn ngưỡng theo đường PR đã trình bày ở bài đánh giá.

Một cảnh báo về calibration

Resampling làm sai lệch calibration: sau khi SMOTE hay đổi tỉ lệ lớp, xác suất mô hình xuất ra không còn phản ánh xác suất thật (nó bị "thổi" theo tỉ lệ mới). Nếu hệ thống của bạn cần xác suất đúng nghĩa (ví dụ tính tổn thất kỳ vọng = xác suất vỡ nợ × dư nợ), hãy ưu tiên class_weight + điều chỉnh ngưỡng, hoặc calibrate lại (Platt scaling / isotonic) sau khi resample.

Nên chọn cách nào?

Kinh nghiệm thực tế:

  1. Bắt đầu bằng class_weight='balanced' + điều chỉnh ngưỡng — rẻ, không leakage, không phá calibration.
  2. Nếu vẫn chưa đủ, thử SMOTE trong pipeline CV.
  3. So sánh cả ba bằng cùng một metric (PR-AUC hoặc recall tại precision mục tiêu). Đừng tin cảm giác.
# (minh hoạ) SMOTE đặt ĐÚNG trong pipeline để tránh leakage
from imblearn.pipeline import Pipeline          # pipeline của imbalanced-learn
from imblearn.over_sampling import SMOTE
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("smote",  SMOTE(random_state=42)),   # CHỈ chạy trên phần train của mỗi fold
    ("clf",    LogisticRegression(max_iter=1000)),
])

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# Đánh giá bằng PR-AUC (average_precision) thay vì accuracy
scores = cross_val_score(pipe, X, y, cv=cv, scoring="average_precision")
print("PR-AUC trung bình:", scores.mean())
# (minh hoạ) Cách nhẹ hơn: không tạo dữ liệu giả, chỉ chỉnh trọng số + ngưỡng
from sklearn.ensemble import RandomForestClassifier
import numpy as np

clf = RandomForestClassifier(class_weight="balanced", random_state=42)
clf.fit(X_train, y_train)

proba = clf.predict_proba(X_valid)[:, 1]   # xác suất lớp dương
# Quét ngưỡng để đạt mục tiêu recall >= 0.80
for t in np.arange(0.5, 0.05, -0.05):
    pred = (proba >= t).astype(int)
    # ... tính precision/recall tại ngưỡng t, chọn t phù hợp mục tiêu ...

Phần 2 — Tinh chỉnh siêu tham số

Siêu tham số (hyperparameters) là các thiết lập bạn chọn trước khi huấn luyện (độ sâu cây, learning rate, hệ số regularization), khác với tham số (trọng số) mà mô hình tự học. Chọn siêu tham số tốt có thể là khác biệt giữa mô hình tầm thường và mô hình xuất sắc.

Ba chiến lược tìm kiếm

GridSearchCV — thử mọi tổ hợp trong một lưới bạn định nghĩa. Đầy đủ, dễ hiểu, nhưng bùng nổ tổ hợp: 4 siêu tham số × 5 giá trị = 625 lần train × số fold. Chỉ hợp khi ít siêu tham số.

RandomizedSearchCV — lấy mẫu ngẫu nhiên từ không gian tham số với số lần cố định (n_iter). Thường tìm được cấu hình tốt nhanh hơn grid nhiều, vì đa số siêu tham số không quan trọng như nhau — random giúp khám phá rộng các siêu tham số thực sự có ảnh hưởng.

Bayesian optimization (Optuna, Hyperopt)học từ các lần thử trước để chọn tổ hợp tiếp theo có khả năng tốt hơn, thay vì mò mù. Optuna dùng thuật toán TPE (Tree-structured Parzen Estimator), hỗ trợ pruning (dừng sớm các lần thử kém), và cho phép định nghĩa không gian tìm kiếm linh hoạt hơn (điều kiện, thang log). Với mô hình đắt và nhiều siêu tham số (boosting, mạng nơ-ron), Bayesian thường hội tụ tới cấu hình tốt bằng ít lần thử hơn hẳn grid/random.

Những nguyên tắc không được quên

  • Luôn dùng cross-validation khi tuning để ước lượng không thiên vị.
  • Tối ưu theo metric đúng (scoring): với mất cân bằng, đặt scoring="average_precision" hoặc "f1", tuyệt đối không để mặc định accuracy.
  • Nested CV khi bạn muốn ước lượng thành thật cả hiệu năng lẫn ảnh hưởng của việc tuning: vòng trong chọn siêu tham số, vòng ngoài đánh giá. Tốn kém nhưng chống được thiên vị lạc quan do "vừa chọn vừa chấm trên cùng dữ liệu".
  • Không bao giờ tune trên tập test. Tập test chỉ được chạm một lần duy nhất ở cuối. Mỗi lần bạn nhìn test rồi chỉnh, bạn đang rò rỉ thông tin và tự lừa mình.
# (minh hoạ) RandomizedSearchCV — nhanh hơn grid, tối ưu đúng metric
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint

param_dist = {
    "n_estimators": randint(200, 800),
    "max_depth": randint(3, 20),
    "min_samples_leaf": randint(1, 20),
}
search = RandomizedSearchCV(
    RandomForestClassifier(class_weight="balanced", random_state=42),
    param_distributions=param_dist,
    n_iter=40, cv=5,
    scoring="average_precision",   # PR-AUC, KHÔNG dùng accuracy
    random_state=42, n_jobs=-1,
)
search.fit(X_train, y_train)
print(search.best_params_, search.best_score_)
# (minh hoạ) Optuna — Bayesian tuning với pruning
import optuna
from sklearn.model_selection import cross_val_score
from xgboost import XGBClassifier

def objective(trial):
    params = {
        "n_estimators": trial.suggest_int("n_estimators", 200, 1000),
        "max_depth": trial.suggest_int("max_depth", 3, 10),
        "learning_rate": trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
        "subsample": trial.suggest_float("subsample", 0.6, 1.0),
        "scale_pos_weight": trial.suggest_float("scale_pos_weight", 1.0, 50.0),
    }
    model = XGBClassifier(**params, eval_metric="aucpr")
    score = cross_val_score(model, X_train, y_train,
                            cv=5, scoring="average_precision").mean()
    return score

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=50)
print(study.best_params)

Phần 3 — Overfitting & regularization

Nhận biết overfitting

Overfitting là khi mô hình học thuộc lòng tập train (kể cả nhiễu) nên train rất tốt nhưng validation/test kém. Dấu hiệu kinh điển: khoảng cách lớn giữa điểm train và điểm validation.

Learning curve (điểm theo lượng dữ liệu) và validation curve (điểm theo một siêu tham số) là hai công cụ chẩn đoán:

  • Train cao, val thấp, khoảng cách rộng → overfitting (variance cao): giảm độ phức tạp, thêm regularization, thêm dữ liệu.
  • Cả train lẫn val đều thấp và sát nhau → underfitting (bias cao): tăng độ phức tạp, thêm đặc trưng.

Đây chính là bias–variance tradeoff: mô hình quá đơn giản thì bias cao (bỏ sót quy luật), quá phức tạp thì variance cao (bám nhiễu). Mục tiêu là điểm cân bằng — nơi tổng lỗi trên dữ liệu mới nhỏ nhất.

Bộ công cụ chống overfitting

  • Regularization: phạt trọng số lớn. Với hồi quy tuyến tính là L1/L2 (đã bàn ở bài mô hình tuyến tính trong series), điều chỉnh qua tham số C (logistic) hoặc alpha.
  • Giảm độ phức tạp cây: hạ max_depth, tăng min_samples_leaf, cắt tỉa (pruning).
  • Early stopping (boosting, mạng nơ-ron): theo dõi điểm trên tập validation, dừng ngay khi nó bắt đầu xấu đi. Trong XGBoost/LightGBM, truyền eval_set và đặt early_stopping_rounds — mô hình tự chọn số cây tối ưu thay vì train quá đà. (Lưu ý: khi kết hợp với CV, cách sạch là chọn siêu tham số bằng CV rồi train lại mô hình cuối với early stopping trên một tập validation riêng.)
  • Dropout (mạng nơ-ron): ngẫu nhiên "tắt" một phần nơ-ron mỗi bước để mạng không phụ thuộc vào vài đường dẫn.
  • Thêm dữ liệu: cách chống overfitting bền vững nhất khi khả thi.
  • Feature selection: bớt đặc trưng nhiễu/thừa để giảm variance.
# (minh hoạ) Early stopping trong XGBoost để chống overfitting
from xgboost import XGBClassifier

model = XGBClassifier(
    n_estimators=2000,          # đặt lớn, để early stopping tự cắt
    learning_rate=0.05,
    max_depth=5,
    eval_metric="aucpr",
    early_stopping_rounds=50,   # dừng nếu 50 vòng liên tiếp không cải thiện
)
model.fit(
    X_train, y_train,
    eval_set=[(X_valid, y_valid)],   # theo dõi trên tập validation
    verbose=False,
)
print("Số cây thực dùng:", model.best_iteration)

Quy trình tuning thực chiến

Gộp mọi thứ lại thành một quy trình bạn có thể lặp lại:

  1. Baseline trước. Một mô hình đơn giản với siêu tham số mặc định, class_weight='balanced', đánh giá bằng đúng metric. Đây là mốc để so sánh mọi cải tiến.
  2. Xử lý mất cân bằng nếu baseline có recall/PR-AUC quá thấp: thử class_weight + ngưỡng trước, SMOTE sau, so sánh công bằng.
  3. Tune vài siêu tham số quan trọng (không phải tất cả): với boosting là learning_rate, max_depth, n_estimators (qua early stopping), regularization. Dùng random hoặc Optuna, tối ưu đúng scoring.
  4. Chốt ngưỡng theo chi phí kinh doanh trên tập validation.
  5. Chạy test một lần duy nhất để báo cáo hiệu năng thật. Không quay lại chỉnh sau khi đã nhìn test.

Cạm bẫy lớn nhất xuyên suốt: mọi bước xử lý phụ thuộc dữ liệu (scale, SMOTE, chọn đặc trưng, chọn ngưỡng) phải học từ train và áp lên valid/test — không bao giờ ngược lại.

Use case thực tế — Mô hình phát hiện gian lận

Bài toán: 0.3% giao dịch là gian lận (mất cân bằng ~1:300). Chi phí bỏ sót một vụ gian lận cao hơn nhiều chi phí một báo động giả.

Cách một đội xử lý:

  1. Metric: chọn recall tại precision ≥ 20% làm mục tiêu chính (đội xử lý báo động chịu được tỉ lệ đó), theo dõi thêm PR-AUC.
  2. Baseline: XGBoost + scale_pos_weight ≈ 300, đánh giá bằng average_precision qua Stratified 5-fold. Đã tốt hơn hẳn accuracy-mù.
  3. Thử SMOTE trong pipeline CV: cải thiện nhẹ recall nhưng làm sai calibration — đội quyết định giữ scale_pos_weight vì cần xác suất để xếp hạng ưu tiên điều tra.
  4. Tune bằng Optuna (50 trials): learning_rate, max_depth, subsample, scale_pos_weight, tối ưu aucpr, có early stopping.
  5. Điều chỉnh ngưỡng: hạ từ 0.5 xuống ~0.12 để đạt recall mục tiêu, kiểm precision đi kèm chấp nhận được.
  6. Test một lần: xác nhận recall/precision giữ vững trên dữ liệu chưa từng thấy, rồi đưa lên production kèm giám sát drift.

Logic này áp dụng gần như nguyên vẹn cho mô hình vỡ nợ tín dụng: lớp vỡ nợ hiếm, dùng class_weight/scale_pos_weight để không bỏ sót, rồi chọn ngưỡng theo chi phí — cân giữa tổn thất khi cho vay nhầm người xấu và doanh thu mất đi khi từ chối nhầm người tốt. Ngưỡng ở đây là quyết định kinh doanh, không phải mặc định 0.5.

Ghi nhớ

  • Accuracy vô nghĩa với dữ liệu mất cân bằng. Dùng Precision/Recall, F1, PR-AUC; chọn metric theo mục tiêu kinh doanh.
  • Ba cách xử lý mất cân bằng: resampling (SMOTE), class weight (cost-sensitive), và điều chỉnh ngưỡng — nên bắt đầu bằng class weight + ngưỡng vì rẻ và không phá calibration.
  • SMOTE chỉ trên train, trong vòng CV (dùng pipeline của imbalanced-learn) để tránh leakage; nó cũng làm sai calibration.
  • Ba chiến lược tuning: Grid (đầy đủ, đắt), Random (nhanh, hiệu quả), Bayesian/Optuna (thông minh, ít trial hơn cho không gian lớn).
  • Luôn tune bằng CV, theo đúng metric, không bao giờ trên test. Test chỉ chạm một lần.
  • Overfitting = train tốt, val kém. Chống bằng regularization, giảm độ phức tạp, early stopping, dropout, thêm dữ liệu, feature selection.
  • Quy trình: baseline → xử lý mất cân bằng → tune vài siêu tham số quan trọng → chốt ngưỡng theo chi phí → test một lần.

Bài tiếp theo trong series: ML 8 — Triển khai & MLOps. Xem lại ML 5 — Đánh giá mô hình để hiểu sâu về metric và ngưỡng.

Bài viết liên quan

Phân biệt AI/ML/DL, các kiểu học máy, quy trình ML end-to-end và thuật ngữ nền tảng.

13 thg 7, 2026 15

Chất lượng dữ liệu, xử lý thiếu/ngoại lai, mã hoá, chuẩn hoá, tạo đặc trưng và tránh data leakage.

13 thg 7, 2026 13

Mô hình ngôn ngữ lớn hoạt động ra sao, token, context window, tham số sinh, và kỹ thuật viết prompt.

13 thg 7, 2026 13

Retrieval-Augmented Generation, vector database, tool calling, agent và MCP để xây ứng dụng LLM thực tế.

13 thg 7, 2026 11