GBM Regularization and Evaluation in C++
The first two implementation posts built working GBM models. This final post adds the controls that make them usable: options, row subsampling, validation curves, early stopping, and permutation importance. The goal is not a full XGBoost clone; it is the practical scaffolding that turns the core loop into a model that can be tuned and inspected.
Options Struct
Collecting hyperparameters in one struct makes experiments reproducible:
struct GBMOptions {
int rounds = 300;
int treeDepth = 3;
int minSamples = 8;
double learningRate = 0.05;
double rowSample = 0.8; // fraction of rows used per tree
int earlyStoppingRounds = 25;
unsigned seed = 42;
};
rounds is now a maximum. Early stopping will choose how many fitted trees to keep.
Row Subsampling
Row subsampling trains each tree on a random subset of rows while still updating predictions for all rows after the tree is fitted. This is stochastic gradient boosting.
vector<int> sample_rows(int n, double fraction, mt19937& rng) {
vector<int> idx(n);
iota(idx.begin(), idx.end(), 0);
shuffle(idx.begin(), idx.end(), rng);
int keep = max(1, (int)round(fraction * n));
idx.resize(keep);
return idx;
}
Matrix take_rows(const Matrix& X, const vector<int>& idx) {
Matrix out;
out.reserve(idx.size());
for (int i : idx) out.push_back(X[i]);
return out;
}
vector<double> take_values(const vector<double>& y, const vector<int>& idx) {
vector<double> out;
out.reserve(idx.size());
for (int i : idx) out.push_back(y[i]);
return out;
}
Validation Curve
For regression, the training loop can evaluate validation RMSE after every round:
struct FitHistory {
vector<double> trainRmse;
vector<double> validRmse;
int bestRound = 0;
double bestValid = numeric_limits<double>::infinity();
};
FitHistory fit_with_validation(RegressionGBM& model,
const Matrix& Xtr,
const vector<double>& ytr,
const Matrix& Xva,
const vector<double>& yva,
const GBMOptions& opt) {
mt19937 rng(opt.seed);
FitHistory hist;
model.F0 = accumulate(ytr.begin(), ytr.end(), 0.0) / ytr.size();
vector<double> predTr(Xtr.size(), model.F0);
model.trees.clear();
int stale = 0;
for (int m = 0; m < opt.rounds; ++m) {
vector<double> residual(Xtr.size());
for (size_t i = 0; i < Xtr.size(); ++i)
residual[i] = ytr[i] - predTr[i];
vector<int> rows = sample_rows((int)Xtr.size(), opt.rowSample, rng);
Matrix Xsub = take_rows(Xtr, rows);
vector<double> rsub = take_values(residual, rows);
RegTree tree(opt.treeDepth, opt.minSamples);
tree.fit(Xsub, rsub);
for (size_t i = 0; i < Xtr.size(); ++i)
predTr[i] += opt.learningRate * tree.predict(Xtr[i]);
model.trees.push_back(move(tree));
double trainScore = rmse(model, Xtr, ytr);
double validScore = rmse(model, Xva, yva);
hist.trainRmse.push_back(trainScore);
hist.validRmse.push_back(validScore);
if (validScore < hist.bestValid) {
hist.bestValid = validScore;
hist.bestRound = m + 1;
stale = 0;
} else if (++stale >= opt.earlyStoppingRounds) {
break;
}
}
model.trees.resize(hist.bestRound);
return hist;
}
The model keeps the best validation round, not the final attempted round. This mirrors the early-stopping workflow in the XGBoost walkthrough.
Example Curve
Permutation Importance
A simple model inspection tool measures how much validation error increases when one feature column is shuffled:
vector<double> permutation_importance(const RegressionGBM& model,
Matrix X,
const vector<double>& y,
mt19937& rng) {
int d = (int)X[0].size();
double base = rmse(model, X, y);
vector<double> imp(d);
for (int f = 0; f < d; ++f) {
vector<double> original;
for (auto& row : X) original.push_back(row[f]);
vector<double> shuffled = original;
shuffle(shuffled.begin(), shuffled.end(), rng);
for (size_t i = 0; i < X.size(); ++i) X[i][f] = shuffled[i];
imp[f] = rmse(model, X, y) - base;
for (size_t i = 0; i < X.size(); ++i) X[i][f] = original[i];
}
return imp;
}
The original column is restored before moving to the next feature. If shuffling a feature destroys validation performance, the model relied on that feature.
GBM Implementation Checklist
- Use a held-out validation set, or cross-validation for small datasets.
- Fit each preprocessing transform on training data only.
- Start with shallow trees and a small learning rate.
- Use early stopping to choose the number of trees.
- Report both the metric being optimized and the metric used by the application.
The resulting C++ GBM is still compact: one CART regression tree, one additive loop, and a small evaluation harness. That compactness is the purpose of implementing it from scratch; the core idea is visible enough to connect directly back to the conceptual GBM series.