Section 2 · Lesson 6
Validate with cross-validation and macro F1
Use stratified folds, read the confusion matrix, and tune per-class weights for macro F1.
- How stratified five-fold cross-validation produces out-of-fold (OOF) predictions.
- How to read a confusion matrix for ordered classes.
- Why per-class weights can lift macro F1, and why the gain is small and easy to overstate.
- Why the project's cross-validation score understates the test score by about 0.004.
The validation scheme
The notebook creates one set of five stratified folds at the start and reuses it everywhere:
SEED, FOLDS, K = 42, 5, 4
y = train["price_tier"].to_numpy() - 1
folds = list(StratifiedKFold(FOLDS, shuffle=True, random_state=SEED).split(train, y))
Stratified means that each fold keeps the same tier mix as the full data. Reusing the same folds keeps the out-of-fold target features (Lesson 4) and the model validation consistent. Every row is predicted exactly once by a model that never saw it, which gives 279,746 OOF predictions to score.
K-fold cross-validation visualizer
Each square is one listing, colored by its tier. Choose the number of folds, then click a fold number to see which squares become the validation set. Switch off stratification and compare the tier mix per fold. The 240 squares follow the real class shares of the training data.
Fold scores and noise
The notebook prints the macro F1 of each fold and the pooled OOF score:
| Model | Fold 0 | Fold 1 | Fold 2 | Fold 3 | Fold 4 | All OOF |
|---|---|---|---|---|---|---|
| Multiclass LightGBM | 0.7523 | 0.7530 | 0.7515 | 0.7509 | 0.7522 | 0.7520 |
| Ordinal LightGBM | 0.7557 | 0.7551 | 0.7517 | 0.7541 | 0.7570 | 0.7547 |
The folds agree within about 0.005, which suggests that the model is stable. The development experiments also found that rerunning the same configuration with a different seed moves the holdout score by about 0.002, so a gain below that is indistinguishable from luck. Multiclass log loss changes more smoothly, so the experiments report it alongside macro F1.
Why does the project use the same five folds for target encoding and for model validation?
- To make training faster.
- So that every validation row's target features come from the other four folds, consistent with how the model is validated.
- Because LightGBM requires it.
If the folds differed, a validation row's encoding could include labels from rows in the model's own training set in ways the validation scheme doesn't model.
Read the confusion matrix
A confusion matrix counts how often each true tier gets each predicted tier. The following matrix comes from the fold-0 holdout predictions that the project saved (combo_*.npy): an equal blend of CatBoost, LightGBM, and ordinal LightGBM, with a macro F1 of 0.7604.

build/01_data_and_figures.py.- Errors are mostly adjacent. Only 891 of the 13,286 mistakes (6.7%) are off by two tiers or more. This is why the ordinal model helps.
- Tier 3 is the weak class. The per-class F1 scores are 0.853, 0.732, 0.629, and 0.828 for tiers 1 to 4. Tier 3 is confused with tiers 2 and 4, which sit on both sides.
- Unseen operators cost about 0.03. Listings from operators that don't appear in train score about 0.03 lower, because the operator-level encodings carry no information for them.
Tune per-class weights for macro F1
Argmax picks the most probable tier. Macro F1 may do better if you nudge borderline rows toward a tier the model under-predicts. The notebook multiplies each class probability by a weight and takes the argmax of the product. tune_class_weights() searches for the weights that maximize macro F1 on the OOF predictions, one class at a time, for three rounds:
def tune_class_weights(p, rounds=3):
w = np.ones(K)
for _ in range(rounds):
for k in range(1, K):
grid = w[k] * np.exp(np.linspace(-0.3, 0.3, 31))
scores = [macro_f1(p, np.r_[w[:k], g, w[k + 1:]]) for g in grid]
w[k] = grid[int(np.argmax(scores))]
return w
Tier 1's weight stays at 1, which fixes the scale. On the notebook's blend, the weights are [1, 0.98, 1.15, 0.852]. They raise tier 3, the weak class, and lower tier 4.
Class-weight tuner on real predictions
These are real holdout probabilities from the project (combo_*.npy), sampled to 15,000 rows so the page stays small. Move the weights, or click Optimize to run the notebook's search. Remember that tuning and scoring on the same rows flatters the result.
How much does tuning really help?
The notebook's weights raise the blend's OOF macro F1 from 0.7559 to 0.7577. But the weights are fit on the same predictions that report the score, so that gain is slightly optimistic. A cross-fitted check, which fits the weights on one stratified half and scores the other half, put the real gain at about +0.001. In the model experiments, class weights added +0.000 to +0.002, which is near noise. They helped single LightGBM models, where tier 2 was under-predicted, and added almost nothing to the CatBoost blend. If you use them, fit them on OOF predictions from full cross-validation.
You tune class weights on all OOF predictions and report macro F1 on those same predictions. What is the problem?
- The weights are fit to the data being scored, so the reported gain is optimistic.
- Class weights can never help macro F1.
- The weights change the model's training.
The search picks whatever weights work best for these rows, and some of that is chance. Cross-fitting (fit on one half, score the other) gives a fairer estimate.
Why the CV score is pessimistic
The cross-validated score understates the expected test score by about 0.004. The notebook builds the target features once, over all five folds. So each fold model's training rows carry encodings that include the validation fold's labels, a mismatch that test rows never have. A nested check on fold 0 rebuilt the training rows' encodings without the validation fold. It scored 0.7562 against 0.7523 for the multiclass model. The submission already follows the nested structure: training rows use OOF encodings, and test rows use encodings fit on all of train.
A nested check scores 0.7562 where the flat CV scores 0.7523 for the same model. Which statement is correct?
- The flat CV score is optimistic by 0.004.
- Both scores measure the same thing and differ by chance.
- The flat CV score is pessimistic by about 0.004, because training encodings include the validation labels in a way that test rows avoid.
The nested version imitates how test rows are encoded, so it's closer to the expected test performance.