,

XGBoost pitfalls: the mistakes that quietly wreck a boosted model

LEARN · GRADIENT BOOSTING WITH XGBOOST

A boosted-tree model can look healthy while being wrong in ways ordinary metrics do not expose. It may overfit after the useful trees were built, leak the test set into tuning, scramble columns at inference, or flatten a real upward trend.

The examples use XGBoost 3.3.0, released June 17, 2026, with synthetic retail, telemetry, and energy data.

Reproducible setup

python -m pip install -U "xgboost==3.3.0" pandas scikit-learn matplotlib

Run this shared setup first:

from __future__ import annotations

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xgboost as xgb
from sklearn.linear_model import LinearRegression
from sklearn.metrics import log_loss, mean_absolute_error
from sklearn.model_selection import train_test_split

rng = np.random.default_rng(42)
n = 4_000

X = pd.DataFrame(
    {
        "sessions_7d": rng.poisson(4, n),
        "avg_basket": rng.lognormal(3.6, 0.45, n),
        "discount_pct": rng.uniform(0, 0.35, n),
        "latency_ms": rng.gamma(4, 35, n),
        **{f"noise_{i}": rng.normal(size=n) for i in range(8)},
    }
)

logit = (
    -1.0
    + 0.18 * X["sessions_7d"]
    + 0.012 * (X["avg_basket"] - 40)
    + 2.4 * X["discount_pct"]
    - 0.0035 * X["latency_ms"]
)
probability = 1 / (1 + np.exp(-logit))
y = (rng.random(n) < probability).astype(int)

X_train, X_temp, y_train, y_temp = train_test_split(
    X,
    y,
    test_size=0.4,
    stratify=y,
    random_state=42,
)
X_valid, X_test, y_valid, y_test = train_test_split(
    X_temp,
    y_temp,
    test_size=0.5,
    stratify=y_temp,
    random_state=42,
)

Training fits trees. Validation chooses parameters and the stopping point. Test data gets one final evaluation.

1. Treating n_estimators as the desired tree count

A large n_estimators should be a ceiling, not a command to build every tree. XGBoost’s scikit-learn estimators require an eval_set for early stopping; prediction then uses best_iteration automatically.

common = dict(
    n_estimators=1_500,
    learning_rate=0.03,
    max_depth=7,
    tree_method="hist",
    eval_metric="logloss",
    random_state=42,
)

broken = xgb.XGBClassifier(**common)
broken.fit(X_train, y_train, verbose=False)

fixed = xgb.XGBClassifier(
    **common,
    early_stopping_rounds=60,
)
fixed.fit(
    X_train,
    y_train,
    eval_set=[(X_valid, y_valid)],
    verbose=False,
)

for name, model in {
    "all_trees": broken,
    "early_stopped": fixed,
}.items():
    test_loss = log_loss(
        y_test,
        model.predict_proba(X_test)[:, 1],
    )
    print(name, test_loss)

print("Best iteration:", fixed.best_iteration)

Do not guess the right tree count from training loss. Watch a genuine validation metric and stop when it stalls.

2. Tuning on the test set

The test set stops being an unbiased estimate as soon as its score influences a modeling choice. Test data should not be used to select models, preprocessing steps, thresholds, or hyperparameters.

# Broken: test loss chooses max_depth.
for depth in [2, 4, 6, 8]:
    params = {
        **common,
        "n_estimators": 600,
        "max_depth": depth,
    }
    model = xgb.XGBClassifier(**params)
    model.fit(X_train, y_train, verbose=False)

    test_loss = log_loss(
        y_test,
        model.predict_proba(X_test)[:, 1],
    )
    print(depth, test_loss)

Select on validation, then test once:

candidates = []

for depth in [2, 4, 6, 8]:
    params = {
        **common,
        "max_depth": depth,
    }
    model = xgb.XGBClassifier(
        **params,
        early_stopping_rounds=60,
    )
    model.fit(
        X_train,
        y_train,
        eval_set=[(X_valid, y_valid)],
        verbose=False,
    )

    validation_loss = log_loss(
        y_valid,
        model.predict_proba(X_valid)[:, 1],
    )
    candidates.append((validation_loss, depth, model))

validation_loss, best_depth, best_model = min(
    candidates,
    key=lambda row: row[0],
)
test_loss = log_loss(
    y_test,
    best_model.predict_proba(X_test)[:, 1],
)

print(
    {
        "best_depth": best_depth,
        "validation_loss": validation_loss,
        "test_loss": test_loss,
    }
)

The same rule covers feature selection, calibration, preprocessing, decision thresholds, and metric choice.

3. Trusting default feature importance

Booster.get_score() and xgb.plot_importance() default to weight: the number of splits using a feature. gain measures the average improvement produced by those splits. XGBoost can also return per-row SHAP contributions through pred_contribs. These methods answer different questions and can rank features differently.

booster = fixed.get_booster()[: fixed.best_iteration + 1]

weight = pd.Series(
    booster.get_score(importance_type="weight")
).reindex(X.columns, fill_value=0)

gain = pd.Series(
    booster.get_score(importance_type="gain")
).reindex(X.columns, fill_value=0)

contributions = booster.predict(
    xgb.DMatrix(X_valid),
    pred_contribs=True,
)
mean_abs_shap = pd.Series(
    np.abs(contributions[:, :-1]).mean(axis=0),
    index=X.columns,
)

importance = pd.concat(
    {
        "weight": weight,
        "gain": gain,
        "mean_abs_shap": mean_abs_shap,
    },
    axis=1,
)

print(
    importance
    .sort_values("mean_abs_shap", ascending=False)
    .head(8)
)

Use importance as a diagnostic:

  • Compare split count, gain, and SHAP magnitude.

  • Investigate suspiciously strong leakage candidates.

  • Expect correlated features to divide or exchange credit.

  • Recheck rankings across folds or time windows.

4. Ordinal-encoding a nominal category

Encoding direct, email, search, and social as 0–3 invents an order. A numerical split can only cut that line at a threshold. Native categorical splits can instead partition sets of categories. XGBoost 3.3 supports categorical DataFrame columns with enable_categorical=True; the hist and approx methods support categorical features.

rng_cat = np.random.default_rng(7)
levels = ["direct", "email", "search", "social"]
channel = rng_cat.choice(levels, 3_000)

effect = pd.Series(channel).map(
    {
        "direct": 0.0,
        "email": 4.0,
        "search": 0.0,
        "social": 4.0,
    }
)
y_cat = (
    10
    + effect.to_numpy()
    + rng_cat.normal(0, 0.5, len(channel))
)

X_cat = pd.DataFrame({"channel": channel})
Xc_train, Xc_test, yc_train, yc_test = train_test_split(
    X_cat,
    y_cat,
    test_size=0.3,
    random_state=7,
)

codes = {
    "direct": 0,
    "email": 1,
    "search": 2,
    "social": 3,
}
bad_train = Xc_train.assign(
    channel=Xc_train["channel"].map(codes)
)
bad_test = Xc_test.assign(
    channel=Xc_test["channel"].map(codes)
)

bad = xgb.XGBRegressor(
    n_estimators=1,
    max_depth=1,
    learning_rate=1,
    tree_method="hist",
    random_state=7,
)
bad.fit(bad_train, yc_train)

good_train = Xc_train.copy()
good_test = Xc_test.copy()
good_train["channel"] = pd.Categorical(
    good_train["channel"],
    categories=levels,
)
good_test["channel"] = pd.Categorical(
    good_test["channel"],
    categories=levels,
)

good = xgb.XGBRegressor(
    n_estimators=1,
    max_depth=1,
    learning_rate=1,
    tree_method="hist",
    enable_categorical=True,
    max_cat_to_onehot=1,
    random_state=7,
)
good.fit(good_train, yc_train)

print(
    {
        "ordinal_mae": mean_absolute_error(
            yc_test,
            bad.predict(bad_test),
        ),
        "categorical_mae": mean_absolute_error(
            yc_test,
            good.predict(good_test),
        ),
    }
)

Integer encoding is appropriate only when the numbers carry real ordered meaning or when a validated preprocessing strategy deliberately handles the representation.

5. Cargo-culting scale_pos_weight

scale_pos_weight is intended for imbalanced classes. The documentation suggests negative count ÷ positive count as a starting value—not a large constant copied from another project. For probability accuracy, the tuning guide warns against rebalancing by default.

for scale in [1, 10]:
    model = xgb.XGBClassifier(
        n_estimators=350,
        learning_rate=0.05,
        max_depth=4,
        tree_method="hist",
        eval_metric="logloss",
        scale_pos_weight=scale,
        random_state=42,
    )
    model.fit(X_train, y_train, verbose=False)

    predicted_probability = model.predict_proba(X_test)[:, 1]

    print(
        {
            "scale_pos_weight": scale,
            "logloss": log_loss(
                y_test,
                predicted_probability,
            ),
            "mean_prediction": predicted_probability.mean(),
        }
    )

An unjustified weight can push probabilities upward and damage calibration even when ranking changes little. Validate the setting against the actual class distribution and business objective.

6. Breaking the serving schema

Column order disappears

With named DataFrames, XGBoost validates matching feature names by default. Convert a reordered frame to an unnamed NumPy array and that protection disappears.

expected = X_train.columns.tolist()
reordered = X_test[expected[::-1]]

try:
    fixed.predict_proba(reordered)
except ValueError as error:
    print("Caught:", error)

wrong = fixed.predict_proba(
    reordered.to_numpy()
)[:, 1]

safe_frame = reordered.loc[:, expected]
right = fixed.predict_proba(safe_frame)[:, 1]

print(
    "Mean prediction shift:",
    np.mean(np.abs(wrong - right)),
)

The array has the correct width, so inference succeeds. Its values are attached to the wrong features.

Categorical dtype disappears

Since XGBoost 3.1, the Python interface can remember and recode categorical values for supported DataFrame inputs. It still relies on categorical metadata and consistent category-name types; a plain object column is not equivalent to a categorical column.

serving = pd.DataFrame(
    {
        "channel": ["email", "direct"],
    }
)

try:
    good.predict(serving)
except ValueError as error:
    print("Caught:", error)

serving["channel"] = pd.Categorical(
    serving["channel"],
    categories=levels,
)

print(good.predict(serving))

good.save_model("retail-channel.ubj")

JSON and UBJSON are the supported model formats, with UBJSON used by default. These formats preserve auxiliary model information such as feature names and categorical metadata.

Persist a schema beside the model:

  • Feature names and order

  • Data types and units

  • Allowed category values

  • Missing-value rules

  • Schema and model versions

7. Cherry on the cake: trees cannot extrapolate a trend

A tree routes each row to a leaf with a fixed score; an ensemble sums those scores. Beyond the largest training split, increasing the feature again can keep sending rows to the same outer leaves. The result is a flatline rather than a continued trend.

rng_trend = np.random.default_rng(123)

t_train = np.arange(120, dtype=float)
y_train_trend = (
    40
    + 0.65 * t_train
    + 6 * np.sin(t_train / 8)
    + rng_trend.normal(0, 1.2, len(t_train))
)

t_all = np.arange(170, dtype=float)
X_train_trend = t_train.reshape(-1, 1)
X_all_trend = t_all.reshape(-1, 1)

tree = xgb.XGBRegressor(
    n_estimators=500,
    learning_rate=0.03,
    max_depth=3,
    tree_method="hist",
    random_state=123,
)
tree.fit(X_train_trend, y_train_trend)
tree_prediction = tree.predict(X_all_trend)

trend = LinearRegression().fit(
    X_train_trend,
    y_train_trend,
)

residual = xgb.XGBRegressor(
    n_estimators=300,
    learning_rate=0.03,
    max_depth=3,
    tree_method="hist",
    random_state=123,
)
residual.fit(
    X_train_trend,
    y_train_trend - trend.predict(X_train_trend),
)

hybrid_prediction = (
    trend.predict(X_all_trend)
    + residual.predict(X_all_trend)
)

plt.figure(figsize=(9, 5))
plt.scatter(
    t_train,
    y_train_trend,
    s=12,
    alpha=0.5,
    label="training data",
)
plt.plot(
    t_all,
    tree_prediction,
    label="XGBoost only",
)
plt.plot(
    t_all,
    hybrid_prediction,
    label="linear trend + boosted residuals",
)
plt.axvline(
    t_train.max(),
    linestyle="--",
    label="training-range edge",
)
plt.xlabel("time index")
plt.ylabel("energy demand")
plt.legend()
plt.tight_layout()
plt.show()

The plot is the vulnerability report: the pure tree model follows the observed range, then flattens. The hybrid retains an extrapolating linear component while boosting models the nonlinear residual structure.

Production responses include:

  • Predict changes instead of absolute levels.

  • Add an explicit trend or forecasting component.

  • Reject or flag rows outside validated feature ranges.

  • Monitor training-range exceedance in production.

Production checklist

Before shipping, verify that:

  • Early stopping uses a genuine validation set.

  • Test data influenced no development decision.

  • Importance was checked beyond split counts.

  • Nominal categories were not treated as ordered numbers.

  • Class weighting matches the actual imbalance and objective.

  • Serving enforces feature names, order, dtypes, and category metadata.

  • Inference ranges resemble training ranges.

  • Extrapolation behavior was tested explicitly.

Take one existing XGBoost pipeline today. Reverse its columns, strip a categorical dtype, apply an unjustified class weight, and predict beyond its training range. Every failure your pipeline does not catch is a production bug waiting for traffic.