Trên dữ liệu bảng, tức những bảng có hàng là khách hàng, đơn hàng, giao dịch và cột là thuộc tính, mô hình thắng nhiều cuộc thi và chạy trong nhiều hệ thống chấm điểm thực tế thường không phải mạng nơ-ron mà là gradient boosting trên cây quyết định. Bài này đi từ ý tưởng cốt lõi (mỗi cây mới học phần sai của các cây trước) tới việc tự cài đặt một bộ gradient boosting chừng 12 dòng, rồi so sánh XGBoost, LightGBM, scikit-learn và mạng nơ-ron trên cùng dữ liệu với số đo thật, kể cả những trường hợp mạng nơ-ron không thua.

Bài nối tiếp Feature engineering cho dữ liệu bảng: mã hoá, chuẩn hoá và rò rỉ dữ liệu; mô hình cây không cần chuẩn hoá nên phần lớn công đoạn ở bài đó nhẹ đi. Quy trình đánh giá dùng lại Machine learning với scikit-learn: Pipeline, cross-validation và tuning và các độ đo ở Đánh giá mô hình phân loại: precision, recall, ROC và dữ liệu lệch.

Môi trường chạy: Apple M4, 16 GB, không GPU. Các API dùng trong bài (early_stopping_rounds trong hàm tạo của XGBoost, lgb.early_stopping của LightGBM, HistGradientBoostingRegressor) theo tài liệu đã có từ XGBoost 1.6, LightGBM 3.3 và scikit-learn 1.2. Lưu ý trung thực: số đo trong bài lấy từ lần chạy bằng các bản thư viện mới hơn thời điểm đăng bài (xem requirements.txt), chúng tôi không chạy lại được trên các bản cũ, nên con số có thể lệch nhẹ nếu bạn dùng bản cũ.

Gradient boosting hoạt động thế nào

Ý tưởng được Jerome Friedman trình bày năm 2001 trong bài "Greedy Function Approximation: A Gradient Boosting Machine": xây mô hình dạng tổng F(x) = F0 + η·f1(x) + η·f2(x) + ..., trong đó mỗi f_k là một cây nhỏ được huấn luyện để giảm hàm mất mát (loss) của tổng hiện có. Cụ thể, ở mỗi vòng ta tính gradient âm của loss theo dự đoán hiện tại, rồi huấn luyện cây mới để xấp xỉ đại lượng đó. Với loss bình phương, gradient âm chính là phần dư y - F(x), nên mỗi cây học đúng phần mà mô hình đang dự đoán sai. Hệ số η (learning rate) thu nhỏ đóng góp của từng cây để tránh một cây đơn lẻ làm mô hình lệch đi.

Điểm khác với random forest: random forest huấn luyện nhiều cây độc lập trên các mẫu khác nhau rồi lấy trung bình (giảm phương sai), còn gradient boosting huấn luyện cây tuần tự, mỗi cây sửa lỗi cây trước (giảm độ chệch). Vì vậy gradient boosting thường chính xác hơn khi được điều chỉnh tốt, nhưng cũng dễ quá khớp hơn nếu cho chạy quá lâu.

Ta kiểm chứng bằng cách tự cài đặt. Dữ liệu là California Housing có sẵn trong scikit-learn (20.640 khu dân cư, 8 đặc trưng, nhãn là giá nhà trung vị tính bằng 100.000 USD, từ điều tra dân số Mỹ 1990), chia 80/20:

class MiniGB:
    def __init__(self, n=200, lr=0.1, depth=3): self.n, self.lr, self.depth = n, lr, depth
    def fit(self, X, y):
        self.f0 = y.mean(); pred = np.full(len(y), self.f0); self.trees = []
        for _ in range(self.n):
            resid = y - pred                         # với loss bình phương, -gradient chính là phần dư
            t = DecisionTreeRegressor(max_depth=self.depth, random_state=0).fit(X, resid)
            pred += self.lr * t.predict(X); self.trees.append(t)
        return self
    def predict(self, X, upto=None):
        p = np.full(len(X), self.f0)
        for t in self.trees[:upto]: p += self.lr * t.predict(X)
        return p
[1] California Housing: (20640, 8) | train 16512 | test 4128 | độ lệch chuẩn của y: 1.154

[2] Gradient boosting tự cài đặt bằng DecisionTreeRegressor (loss bình phương, learning_rate=0.1, max_depth=3)
    0 cây: RMSE test = 1.1421
    1 cây: RMSE test = 1.0841
   10 cây: RMSE test = 0.8075
   50 cây: RMSE test = 0.5769
  200 cây: RMSE test = 0.5053
  So với sklearn GradientBoostingRegressor cùng tham số: RMSE test = 0.5053 | MiniGB = 0.5053

Với gradient boosting, không cây nào (chỉ dự đoán trung bình) cho RMSE 1,1421, gần bằng độ lệch chuẩn của nhãn; mỗi cây thêm vào kéo sai số xuống, và sau 200 cây RMSE còn 0,5053. Bản tự viết cho kết quả trùng khớp tới 4 chữ số với GradientBoostingRegressor của scikit-learn cùng tham số, nghĩa là thư viện không làm gì bí ẩn hơn vòng lặp trên với loss bình phương.

XGBoost và LightGBM: hai bản cài đặt gradient boosting

Hai thư viện đều giữ khung gradient boosting trên nhưng cải tiến mạnh về kỹ thuật. Bài báo XGBoost: A Scalable Tree Boosting System (Chen và Guestrin, KDD 2016) nêu ba đóng góp chính: thuật toán nhận biết dữ liệu thưa (sparsity-aware), weighted quantile sketch cho việc tìm điểm chia xấp xỉ, và thiết kế tối ưu truy cập bộ nhớ đệm. Về mặt toán, tài liệu XGBoost mô tả mục tiêu gồm loss cộng regularization Ω(f) = γT + ½λΣw², phạt số lá T và độ lớn điểm số lá w. Loss được khai triển Taylor bậc hai nên mỗi lá có giá trị tối ưu w* = -G/(H+λ) và mỗi lần chia chỉ được giữ nếu độ lợi (gain) trừ γ còn dương, đó là cơ chế tỉa cây.

LightGBM theo tài liệu Features dựa trên ba thứ. Thuật toán histogram gom giá trị liên tục vào các bin rời rạc, nên chi phí tìm điểm chia phụ thuộc số bin thay vì số dòng. Cây mọc theo leaf-wise (best-first): luôn tách lá có độ giảm loss lớn nhất, thay vì tách mọi nút cùng độ sâu như level-wise; với cùng số lá thường cho loss thấp hơn, nhưng tài liệu cảnh báo có thể quá khớp trên dữ liệu nhỏ. Và biến phân loại được xử lý trực tiếp bằng cách sắp xếp các giá trị theo sum_gradient / sum_hessian rồi tìm điểm chia tốt nhất trên thứ tự đó, không cần one-hot.

Cũng cần biết: HistGradientBoostingRegressor của scikit-learn (cùng họ histogram với LightGBM) là lựa chọn không phụ thuộc thư viện ngoài, nên là điểm bắt đầu tốt khi môi trường triển khai hạn chế.

So sánh gradient boosting với các mô hình khác trên cùng dữ liệu

Ta huấn luyện gradient boosting cùng 6 mô hình khác trên 16.512 dòng huấn luyện, đo RMSE trên 4.128 dòng kiểm tra chưa từng thấy. Mạng nơ-ron (MLP của scikit-learn, hai lớp ẩn 64 đơn vị) được chuẩn hoá cả đặc trưng lẫn nhãn để so sánh công bằng.

  DecisionTree(max_depth=8)          RMSE test = 0.6679 | huấn luyện 0.05s | dự đoán 0.000s
  RandomForest(300 cây)              RMSE test = 0.5112 | huấn luyện 3.46s | dự đoán 0.080s
  sklearn GradientBoosting(300)      RMSE test = 0.4669 | huấn luyện 9.19s | dự đoán 0.013s
  sklearn HistGradientBoosting       RMSE test = 0.4406 | huấn luyện 9.09s | dự đoán 0.041s
  XGBoost(300, depth=6, lr=0.1)      RMSE test = 0.4430 | huấn luyện 0.95s | dự đoán 0.004s
  LightGBM(300, leaves=31, lr=0.1)   RMSE test = 0.4387 | huấn luyện 1.53s | dự đoán 0.013s
  MLP (64-64, chuẩn hoá X và y)      RMSE test = 0.5766 | huấn luyện 1.05s | dự đoán 0.002s

Ba kết luận về gradient boosting từ bảng. Thứ nhất, ba thư viện gradient boosting dựa histogram (HistGradientBoosting, XGBoost, LightGBM) cho RMSE trong khoảng 0,4387 đến 0,4430, khác nhau chưa tới 1%, nên chọn giữa chúng nên dựa vào hệ sinh thái, tốc độ và tính năng thay vì độ chính xác. Thứ hai, gradient boosting cổ điển của scikit-learn (không histogram) chậm hơn rõ: 9,19 giây so với 0,95 giây của XGBoost, và (ở cấu hình depth=4 này) kém chính xác hơn một chút. Thứ ba, random forest (0,5112) và cây đơn (0,6679) thua boosting rõ rệt trên bài toán này.

Early stopping và learning rate trong gradient boosting

Số cây tối ưu của gradient boosting không biết trước. Cách chuẩn là đặt n_estimators rất lớn, đặt learning rate nhỏ và để mô hình dừng sớm khi điểm trên tập validation không cải thiện sau một số vòng. Ta tách thêm 20% tập huấn luyện làm validation (tập kiểm tra vẫn để yên):

xg = XGBRegressor(n_estimators=3000, learning_rate=0.05, max_depth=6, tree_method="hist",
                  early_stopping_rounds=30, random_state=0)
xg.fit(X_fit, y_fit, eval_set=[(X_val, y_val)], verbose=False)

lg = LGBMRegressor(n_estimators=3000, learning_rate=0.05, num_leaves=31, random_state=0, verbose=-1)
lg.fit(X_fit, y_fit, eval_set=[(X_val, y_val)], eval_metric="rmse",
       callbacks=[lgb.early_stopping(30, verbose=False)])
  XGBoost : best_iteration = 894 / 3000 | RMSE val = 0.4536 | RMSE test = 0.4468
  LightGBM: best_iteration = 804 / 3000 | RMSE val = 0.4512 | RMSE test = 0.4467

Cả hai dừng ở khoảng 800 đến 900 cây trên 3.000 cây tối đa, và RMSE kiểm tra gần như giống nhau (0,4468 và 0,4467). Lưu ý đây không tốt hơn mô hình 300 cây ở learning rate 0,1 ở bảng trước (0,4387 đến 0,4430), vì lần này chỉ còn 80% dữ liệu huấn luyện cho mô hình: early stopping đổi một phần dữ liệu lấy sự an toàn trước quá khớp. Trong thực tế, người ta thường dùng early stopping để tìm số cây tốt nhất rồi huấn luyện lại trên toàn bộ dữ liệu với số cây đó.

Quá khớp của gradient boosting: num_leaves và cây leaf-wise

Tham số quyết định độ phức tạp của gradient boosting kiểu LightGBM là num_leaves. Ta tăng nó với 300 cây và min_child_samples=5 (cố tình lỏng để thấy hiệu ứng):

  num_leaves=4     RMSE train = 0.4857 | RMSE test = 0.5064
  num_leaves=15    RMSE train = 0.3657 | RMSE test = 0.4541
  num_leaves=63    RMSE train = 0.1989 | RMSE test = 0.4336
  num_leaves=255   RMSE train = 0.0453 | RMSE test = 0.4550
  num_leaves=1023  RMSE train = 0.0005 | RMSE test = 0.4981

Đây là đường cong quá khớp kinh điển của gradient boosting. RMSE huấn luyện giảm mãi tới 0,0005 (mô hình thuộc lòng dữ liệu), còn RMSE kiểm tra giảm đến num_leaves=63 (0,4336) rồi tăng trở lại: 0,4550 ở 255 lá và 0,4981 ở 1.023 lá. Tuning có giá trị nhưng khoảng cách nhỏ, trong khi chọn sai về hướng quá phức tạp làm mất hơn 0,06 RMSE.

Khi nào gradient boosting thắng deep learning, và khi nào không

Bài báo Why do tree-based models still outperform deep learning on tabular data? (Grinsztajn, Oyallon, Varoquaux, 2022) so sánh các phương pháp trên 45 bộ dữ liệu bảng và kết luận mô hình dựa trên cây vẫn dẫn đầu trên dữ liệu cỡ vừa (khoảng 10.000 mẫu), kể cả khi chưa tính ưu thế về tốc độ. Các tác giả gắn điều đó với khác biệt về inductive bias và nêu ba thách thức cho mạng nơ-ron chuyên cho dữ liệu bảng: chịu được đặc trưng vô ích, giữ hướng của dữ liệu, và học được hàm không trơn. Ta thử ba ý đó trên dữ liệu của mình, với MLP lớn hơn (128-128) và so với LightGBM:

     300 dòng train: LightGBM RMSE = 0.6768 | MLP RMSE = 0.6709
    1000 dòng train: LightGBM RMSE = 0.5642 | MLP RMSE = 0.6344
    5000 dòng train: LightGBM RMSE = 0.4935 | MLP RMSE = 0.5648
   16512 dòng train: LightGBM RMSE = 0.4680 | MLP RMSE = 0.5113
  MLP (128-128) quên chuẩn hoá y, 300 dòng: RMSE = 7.1466 (so với 0.6709 khi chuẩn hoá y)

    Thêm 20 cột nhiễu thuần tuý (đặc trưng vô ích) vào 8 cột gốc, train đủ 16.512 dòng:
  8 cột gốc          LightGBM RMSE = 0.4680 | MLP RMSE = 0.5181
  8 gốc + 20 nhiễu   LightGBM RMSE = 0.4768 | MLP RMSE = 0.7358

Phát hiện đáng chú ý nhất là đặc trưng vô ích: thêm 20 cột nhiễu chỉ làm LightGBM tệ đi 0,009 (0,4680 lên 0,4768) nhưng làm MLP tệ đi 0,218 (0,5181 lên 0,7358). Cây tự bỏ qua cột không giúp giảm loss, còn mạng nơ-ron phải học trọng số bằng 0 cho chúng. Về kích thước dữ liệu, ở 300 dòng hai mô hình ngang nhau (0,6768 và 0,6709, MLP thậm chí nhỉnh hơn một chút); từ 1.000 dòng trở lên LightGBM dẫn trước ổn định. Cần trung thực: gradient boosting không phải luôn thắng. Với dữ liệu rất nhỏ, kết quả phụ thuộc cách tuning cả hai bên, và một lần chạy với một hạt giống không đủ để khẳng định.

Các trường hợp mạng nơ-ron hợp lý hơn gradient boosting: dữ liệu có cấu trúc mà cây không bắt được (ảnh, văn bản, âm thanh, chuỗi dài); cần học biểu diễn dùng chung giữa nhiều tác vụ hoặc kết hợp với embedding; dữ liệu cực lớn có thể huấn luyện trên GPU trong thời gian dài. Với bảng thông thường, gradient boosting vẫn là baseline mạnh nhất cần thử trước.

Gradient boosting với biến phân loại và giá trị thiếu

Gradient boosting trên cây xử lý giá trị thiếu tự nhiên: XGBoost học một hướng mặc định cho dữ liệu thiếu ở mỗi nút chia, LightGBM tương tự. Biến phân loại cũng được hỗ trợ trực tiếp. Ta sinh một bảng giả lập có biến phân loại 200 giá trị, hai biến số và 20% giá trị thiếu ở một cột, nhãn có nhiễu độ lệch chuẩn 0,5:

  tỉ lệ thiếu ở num1: 20.2%
  LightGBM, cột category + NaN nguyên bản : RMSE test = 0.9672
  XGBoost enable_categorical + NaN       : RMSE test = 0.9963
  LightGBM, mã số 0..199 như số thường    : RMSE test = 1.0280
  (nhiễu của nhãn có độ lệch chuẩn 0.5, nên RMSE không thể thấp hơn khoảng 0.5; mỗi giá trị phân loại chỉ có ~30 dòng train)

Cả ba đều xa mức tối thiểu 0,5 vì mỗi giá trị phân loại chỉ có khoảng 30 dòng huấn luyện, và 20% dữ liệu của num1 bị mất, nên kết quả này chỉ nên đọc như một so sánh tương đối. Xử lý đúng kiểu category trong LightGBM (0,9672) tốt hơn coi mã 0..199 như số thường (1,0280), vì cây số thường chỉ chia theo ngưỡng thứ tự mà các mã này không có. XGBoost với enable_categorical=True nằm giữa (0,9963).

Độ quan trọng đặc trưng của mô hình gradient boosting

Mô hình gradient boosting của XGBoost cho sẵn độ quan trọng theo gain (tổng độ giảm loss do các lần chia dùng đặc trưng đó):

  MedInc     46.2%
  AveOccup   14.7%
  Longitude  11.9%
  Latitude   10.5%
  HouseAge   7.2%
  AveRooms   4.2%
  AveBedrms  2.8%
  Population 2.7%

Thu nhập trung vị của khu (MedInc) chiếm 46,2% tổng gain, hợp lý với kinh nghiệm bất động sản, và kinh độ cộng vĩ độ (22,4%) gộp lại cho thấy vị trí địa lý quan trọng. Nhưng độ quan trọng theo gain là thước đo thiên lệch về phía biến có nhiều cách chia (biến liên tục, biến cardinality cao) và không cho biết hướng tác động. Muốn giải thích đáng tin hơn, ta dùng SHAP, chủ đề của bài kế tiếp trong series.

Khi nào không nên dùng gradient boosting

  • Dữ liệu phi cấu trúc như ảnh, văn bản, âm thanh: mạng nơ-ron tích chập, Transformer phù hợp hơn nhiều.
  • Cần ngoại suy ngoài miền dữ liệu huấn luyện (ví dụ dự báo xu hướng tăng liên tục): cây dự đoán hằng số theo lá nên không ngoại suy.
  • Độ trễ và kích thước mô hình rất chặt: gradient boosting hàng nghìn cây có thể nặng hơn một mô hình tuyến tính nhỏ; hãy đo.
  • Cần giải thích đơn giản cho người dùng cuối: hồi quy logistic hoặc cây nông dễ trình bày hơn; gradient boosting cần công cụ như SHAP.

Lỗi thường gặp

  1. Không dùng early stopping cho gradient boosting hoặc dùng tập kiểm tra cuối làm validation, làm điểm số bị lạc quan.
  2. Đặt learning rate lớn và vài trăm cây rồi kết luận mô hình "đã tối ưu"; hãy thử learning rate nhỏ hơn kèm early stopping.
  3. Tăng num_leaves không kèm min_child_samples, dẫn tới cây thuộc lòng dữ liệu như ở bảng num_leaves=1023.
  4. Mã hoá biến phân loại bằng số thứ tự rồi coi như số thường, trong khi thư viện hỗ trợ kiểu category.
  5. So sánh với mạng nơ-ron mà quên chuẩn hoá nhãn; lần chạy đầu tiên của chúng tôi quên điều này và cho MLP (128-128, chỉ chuẩn hoá X, không chuẩn hoá y) RMSE 7,1466 trên 300 dòng train, cao gấp hơn mười lần so với 0,6709 khi chuẩn hoá y (dòng cuối mục [6] trong gb_demo.py tái lập số này), một kết luận sai do lỗi chuẩn bị chứ không do mô hình.

Checklist gradient boosting

  • Bắt đầu bằng gradient boosting (HistGradientBoosting, LightGBM hoặc XGBoost mặc định) làm baseline mạnh trước khi nghĩ tới mạng nơ-ron.
  • Với gradient boosting, dùng learning rate nhỏ (0,03 đến 0,1), n_estimators lớn và early stopping trên tập validation riêng.
  • Điều chỉnh gradient boosting qua num_leaves/max_depth và min_child_samples; theo dõi chênh lệch RMSE huấn luyện và kiểm tra.
  • Dùng trực tiếp kiểu category và giá trị thiếu, không one-hot biến cardinality cao.
  • So sánh với mô hình đơn giản và mạng nơ-ron trên cùng tập kiểm tra, cùng quy trình chuẩn bị.
  • Chạm tập kiểm tra một lần; ghim phiên bản thư viện (ví dụ bằng requirements.txt), vì giá trị mặc định và số đo có thể thay đổi giữa các bản.

Mã nguồn đầy đủ nằm trong code/s7-14 (gb_demo.py, output.txt, requirements.txt). Trên macOS, LightGBM cần thư viện OpenMP: nếu thiếu, cài libomp bằng Homebrew hoặc trỏ DYLD_FALLBACK_LIBRARY_PATH tới libomp.dylib đi kèm scikit-learn. Mọi số liệu là output thật từ Apple M4, 16 GB; thời gian là một lần chạy, không phải benchmark chặt chẽ.

Bài tiếp theo

Giải thích mô hình với SHAP: biết vì sao mô hình quyết định. Mô hình gradient boosting chính xác nhưng là hộp đen; bài sau dùng giá trị Shapley để giải thích từng dự đoán và so với độ quan trọng đặc trưng truyền thống.