DEV Community

shakti tiwari
shakti tiwari

Posted on

XGBoost max_depth: The Bias-Variance Knob That Decides Tree Complexity

XGBoost max_depth: The Bias-Variance Knob That Decides Tree Complexity

Quick Answer (40–100 words): max_depth caps how deep each boosted tree can grow (default 6 in XGBoost). Shallow trees (depth 2–4) underfit but generalize; deep trees (depth 10+) overfit fast. In our 5-fold test, regression RMSE was best at depth 4 (0.4194) and degraded to 0.7948 by depth 15, while classification accuracy peaked around depth 2 (0.9092) and fell past 10. Pair it with eta and min_child_weight — never tune alone.

Disclaimer: Shakti Tiwari is NISM-Series-XII certified; not a SEBI-registered Research Analyst. Content is educational only.

Why This Matters

After eta (learning_rate), max_depth is the second knob every XGBoost user meets. It is the primary bias-variance control of the tree booster. A deeper tree can approximate a more complex function (lower bias) but needs more data to fit reliably (higher variance). Get it wrong and you either waste capacity (underfit) or memorize noise (overfit).

The official doc (dmlc/xgboost, the canonical XGBoost GitHub repository, ~28.7k stars) defines max_depth under the gbtree booster with a default of 6. The in-repo Kaggle-style tuning guide (doc/tutorials/param_tuning.rst) explicitly states most XGBoost parameters are about the "bias variance tradeoff," and names max_depth first among the complexity controls you turn to when fighting overfitting. If you only ever read one page of the XGBoost docs, read that tuning guide — it frames the whole library as a set of levers on the same bias-variance dial, and depth is the biggest lever of the lot.

The math intuition

A single decision tree partitions the feature space into axis-aligned rectangles. max_depth=d allows at most 2^d leaf nodes. So depth 4 → 16 leaves, depth 10 → 1024 leaves, depth 15 → 32768 leaves. Each added leaf is one more degree of freedom the model can use to fit the training set — including its noise. Boosting stacks many such trees, so the effective capacity is far larger than one tree implies; that is why even moderate depth overfits if eta is not small and early stopping is absent.

Bias-variance, made concrete

The prediction error on a fresh sample decomposes as:

E[(y − ŷ)²] = Bias² + Var + σ²_noise

where Bias is the error from approximating the true function with a depth-d tree family, and Var is the sensitivity of the fitted model to the particular training sample. Deeper trees shrink Bias (they can represent x0², sin(x1), x2*x3 interactions) but inflate Var, because each of the ~2^d leaves can be fit to a small, noisy subset. In boosting, trees are added sequentially, so the total variance is not just one tree's — it compounds across rounds unless eta shrinks each contribution. Concretely, with n training rows and L ≈ 2^d leaves, the per-leaf sample count is ~n/L; when that drops below the noise scale, leaves start encoding noise instead of signal. That is the precise mechanism behind the RMSE blow-up from 0.4194 (depth 4) to 0.7948 (depth 15): leaf count went 16 → 32768, so the average leaf had fewer than one clean signal pattern to learn.

Why default 6 is a starting point, not a recommendation

The 6 default predates the modern "small eta + early stopping" convention. On a hard, noisy target, depth 6 is often already past the OOS optimum. Our data proves exactly that: depth 6 (RMSE 0.46) is worse than depth 4 (0.4194). The default is a safe middle, not a tuned value — treat it as the midpoint of your first sweep, never as the answer.

The two-layer engine note

Our live system is TWO-LAYER: Layer 1 = Dhan WebSocket live capture (shadow/predict-only, never trades without broker auth); Layer 2 = EOD-audited XGBoost/LightGBM training core (walk-forward, cost-and-slippage model). The max_depth parameter discussed here is tuned in Layer 2 — the training core — using walk-forward CV with the cost-and-slippage model applied. Layer 1 consumes the resulting predictions in shadow mode only; it never re-tunes depth on live streamed data. This separation matters: depth is a training-time complexity control, and applying it to live ticks would be both unsafe and statistically meaningless.

Research Question / Hypothesis

Hypothesis: There is an optimal depth band (~4–6) for our tabular signal; beyond it, out-of-sample error rises as trees memorize training noise. We test depth ∈ {2,3,4,6,8,10,15} under identical settings (eta=0.1, subsample=0.8, colsample=0.8, 500 rounds, early-stop 30, 5-fold CV).

Data & Methodology Box

  • Source: Synthetic tabular data, deterministic (seed=42). Regression: 20,000 rows × 24 features, nonlinear + interaction signal + Gaussian noise (σ=0.3). Classification: 20,000 rows × 24 features, logistic-style score threshold → binary label.
  • Sample size: 20,000 per task; 5-fold CV (shuffled, seed=0).
  • Model: gbtree, eta=0.1, subsample=0.8, colsample_bytree=0.8, num_boost_round=500, early_stopping_rounds=30.
  • Validation: OOS metric averaged across folds; early stopping on the held-out fold.
  • Baseline: default max_depth=6.
  • Costs: none (synthetic); real trading use must add transaction cost separately.
  • Reproducibility: xgb_articles/_utils/a2_a7_experiment.py (deterministic seeds).

Results

Primary Table — max_depth vs OOS (5-fold CV, eta=0.1)

max_depth Regression OOS RMSE ↓ Classification OOS ACC ↑
2 0.6811 0.9092
3 0.4351 0.9089
4 0.4194 0.9065
6 0.46 0.9045
8 0.5244 0.9038
10 0.612 0.9034
15 0.7948 0.9029

Findings

  1. Best regression RMSE at depth 4 (0.4194); error then climbs steadily — 0.46 at depth 6, 0.524 at depth 8, and a brutal 0.795 at depth 15. Textbook overfit signature: more capacity than the signal supports.
  2. Classification accuracy is nearly flat (0.9029–0.9092) across all depths. Why? The synthetic classification problem is "easy" (smooth score + modest noise), so even deep trees don't catastrophically overfit accuracy — though OOS still dips slightly past depth 10. RMSE on the regression target is the more sensitive microscope here.
  3. Depth 2 underfits on regression (0.6811) — too rigid to capture the x0² + sin(x1) + x2*x3 interaction. The model can't express the signal.
  4. Default 6 is reasonable but not optimal here; 4 is the sweet spot for RMSE.
  5. Depth acts with eta: small eta tolerates slightly deeper trees because each step is shrunk; large eta + deep trees overfit fastest.
  6. The overfit ascent is steeper than the descent. Going depth 2→4 (escaping underfit) cuts RMSE by 0.262 (0.6811→0.4194), but depth 4→15 (tipping into overfit) adds 0.375 (0.4194→0.7948). Overfitting accelerates — the cost of "one depth too many" grows nonlinearly, so err on the shallow side when uncertain.
  7. The classification optimum is also early (depth 2, 0.9092) and gently declines thereafter. Even on the easy task, the best depth is shallow — reinforcing "start low, sweep up."
  8. Depth 3→4 is marginal on regression (0.4351→0.4194, a 0.016 win). The signal needs a few interactions but not many; the big win is simply escaping the depth-2 underfit, not going deep.

Reproducibility (code)

def oos_reg_maxdepth(values):
    out = {}
    for d in values:
        p = dict(max_depth=d, eta=0.1, subsample=0.8, colsample_bytree=0.8,
                 objective='reg:squarederror', verbosity=0)
        oos = []
        for tr, te in kfold(Xr, yr):
            dtr = xgb.DMatrix(Xr[tr], label=yr[tr]); dte = xgb.DMatrix(Xr[te], label=yr[te])
            bst = xgb.train(p, dtr, 500, evals=[(dte,'te')],
                            early_stopping_rounds=30, verbose_eval=False)
            pred = bst.predict(dte, iteration_range=(0, bst.best_iteration+1))
            oos.append(np.sqrt(np.mean((yr[te]-pred)**2)))
        out[d] = np.mean(oos)
    return out
Enter fullscreen mode Exit fullscreen mode

Run: python3 xgb_articles/_utils/a2_a7_experiment.py. (Mac/Linux/Termux: python3; Windows CMD: py -3 a2_a7_experiment.py.)

Joint grid search: depth × eta

The single-parameter sweep above isolates the depth effect at fixed eta=0.1. In production you want the joint picture, because depth and eta trade off. Here is a compact grid searcher:

import numpy as np, xgboost as xgb
from itertools import product

def grid_depth_eta(X, y, depths, etas, folds, rounds=500):
    """Joint sweep: depth x eta. Returns { (d,eta): oos_rmse } and best config."""
    best, best_cfg, grid = 1e9, None, {}
    for d, eta in product(depths, etas):
        p = dict(max_depth=d, eta=eta, subsample=0.8, colsample_bytree=0.8,
                 objective='reg:squarederror', verbosity=0)
        oos = []
        for tr, te in folds:
            dtr = xgb.DMatrix(X[tr], label=y[tr]); dte = xgb.DMatrix(X[te], label=y[te])
            bst = xgb.train(p, dtr, rounds, evals=[(dte,'te')],
                            early_stopping_rounds=30, verbose_eval=False)
            pred = bst.predict(dte, iteration_range=(0, bst.best_iteration+1))
            oos.append(np.sqrt(np.mean((y[te]-pred)**2)))
        rmse = float(np.mean(oos))
        grid[(d, eta)] = rmse
        if rmse < best:
            best, best_cfg = rmse, (d, eta)
    return grid, best_cfg
Enter fullscreen mode Exit fullscreen mode

A useful mental model from this grid: capacity ≈ (leaves across all trees) × (step size²). Depth grows leaves exponentially; eta shrinks each step. So a depth-10 tree at eta=0.01 behaves more like a depth-6 tree at eta=0.1 in variance terms — you can "get away with" deeper trees on big data when you also drop eta, but you pay in compute and round count. Our A1 article showed the same interplay; fixing eta=0.1 here reveals the pure depth effect.

Leaf-count probe (a capacity check you can run in 3 lines)

Number of leaves is the concrete fingerprint of depth-driven capacity. After training, probe it:

def count_leaves(bst):
    """Total leaves across all boosting rounds — a capacity proxy."""
    trees = bst.get_dump(dump_format='text')
    return sum(t.count('leaf') for t in trees)
Enter fullscreen mode Exit fullscreen mode

Run count_leaves(bst) for each depth in your sweep. You will watch it jump from a few hundred (depth 4) to tens of thousands (depth 15). When leaves ≫ the number of distinct, repeatable signal patterns in your data, you are overfit before you even glance at the OOS number. This probe is the cheapest early-warning system for depth runaway — use it alongside the curve, not instead of it.

How to Read the Depth Curve (the skill that actually matters)

When you sweep max_depth, you are looking for a U-shaped OOS curve: error drops as the model gains capacity to fit the true signal, hits a minimum, then rises as it starts fitting noise. The minimum is your target depth. In production you rarely see a clean U on one run — variance between folds can mask it, so:

  • Plot OOS metric vs depth with error bars (std across folds), not just the mean.
  • If the curve is still dropping at your max depth, you may need more depth OR a stronger signal; if it's rising, you've overshot.
  • Never pick depth by training error — that only goes down. OOS is the only honest signal.

Three regimes of the curve

  1. Underfit plateau (left): RMSE is still descending as depth rises. The signal is richer than your trees can express. Raise depth (or add rounds). Depth 2→4 in our data lives here.
  2. Elbow / sweet spot (middle): the minimum. Lock it. Around depth 4 here for regression; depth 2 for classification.
  3. Overfit ascent (right): RMSE climbs, sometimes violently. Leaves now chase noise. Cut depth, raise min_child_weight or gamma, or drop eta. Depth 6→15 is squarely in this zone.

Because CV noise flattens the picture, always plot std error bars. If your best depth is within 1 std of its neighbors, treat depth as "good enough somewhere in 3–6" and don't over-optimize a noisy minimum — the marginal win of depth 3 vs 4 (0.4351 vs 0.4194) is smaller than the fold-to-fold noise you'd see on real data.

Regression is the sharper instrument. Accuracy saturates: a deep tree can overfit its probability calibration yet still rank most points correctly. That is exactly what we see — clf ACC barely moves (0.9092 → 0.9029) while reg RMSE nearly doubles (0.4194 → 0.7948). Lesson: if your production metric is a probability or a continuous target, trust it over accuracy when diagnosing depth.

What Failed / Counter-Evidence

  • We expected deeper to keep improving until a plateau. Instead we saw a clear increase in OOS error past depth 6 — classic overfit, not saturation. On real, noisy, non-stationary data (like NIFTY order flow), the curve drops then rises even sooner.
  • We also expected classification accuracy to fall harder. It didn't — the synthetic clf task is too easy to expose overfit via accuracy. This is a reminder that your choice of diagnostic metric can hide the very problem you're hunting. Use RMSE / log-loss / AUC, not raw accuracy, to read depth health.

Limitations (explicit non-claims)

  • Synthetic i.i.d. data; real NIFTY/options series are non-stationary and autocorrelated, shifting the optimal depth lower. The relative behavior (U-shaped OOS curve) transfers; the exact value does not.
  • 20k samples is modest; production with 200k+ rows may tolerate depth 6–8.
  • We did not joint-search depth with eta; the A1 article showed joint effects matter. The grid snippet above closes that gap for your own runs.
  • We did not apply gamma (min_split_loss) or lambda/alpha regularization here; those (covered in A4 and A7) let you run deeper trees more safely. Depth alone is the cleanest demonstration, not the final config.

Practical Takeaways

  1. Start max_depth in 3–6; rarely need >10 for tabular.
  2. Tune jointly with eta and min_child_weight (never in isolation).
  3. Use early stopping — it finds effective rounds per depth automatically.
  4. Watch the OOS curve, not training accuracy. If train RMSE << OOS RMSE, cut depth.
  5. Run the leaf-count probe as a cheap capacity sanity check before trusting a deep config.
  6. Production checklist: sweep depth ∈ {3,4,5,6}; read the OOS-U; lock the minimum; re-verify after any data-distribution change (markets shift!).
  7. Engine note: depth is set in the EOD-audited XGBoost/LightGBM training core (Layer 2); the live Dhan WebSocket layer (Layer 1) consumes predictions in shadow mode only — it never re-tunes depth on live data.

Production tuning checklist (step-by-step)

  • [ ] Fix eta first. With eta=0.1, sweep depth ∈ {3,4,5,6,8}. With eta=0.05, you may extend to {6,8,10}.
  • [ ] Run 5-fold CV with early stopping on the held-out fold, so round count adapts per depth.
  • [ ] Plot OOS metric vs depth with error bars; identify the U and its minimum.
  • [ ] Probe leaf count at each depth (count_leaves). If leaves explode past ~5000 on 20k rows, you're over capacity.
  • [ ] Joint-tune with min_child_weight (A3) and gamma (A4); never lock depth alone.
  • [ ] Re-verify after any distribution shift (new features, regime change). Schedule a quarterly re-sweep.
  • [ ] Two-layer discipline: set depth in Layer 2 (walk-forward CV + cost-and-slippage model); Layer 1 stays predict-only.

FAQ

Q: What is the default? A: 6 in XGBoost (sklearn GBM often defaults shallower).

Q: Depth vs number of trees? A: Depth = per-tree complexity; n_estimators = capacity via count. Both add capacity; depth does it per tree, which is more prone to overfit per unit.

Q: Can depth=0? A: No — depth≥1 required; depth=1 is a decision stump (single split).

Q: Does depth affect inference speed? A: Yes — deeper = more nodes traversed per prediction, scaling with leaf count.

Q: In the two-layer engine, where is depth set? A: Training core (Layer 2), walk-forward CV tuned with the cost-and-slippage model applied.

Q: How does depth interact with min_child_weight? A: A deep tree with a tiny min_child_weight can create leaves on single noisy rows. Raise min_child_weight as you raise depth to keep leaves meaningful.

Q: Should I use max_leaves instead? A: max_leaves (with grow_policy='lossguide') caps leaf count independent of depth — a finer control. Covered in cluster B.

Q: Why is classification accuracy so flat while regression RMSE explodes? A: Accuracy is a coarse, saturating metric — a tree can overfit calibration yet still rank most points correctly. Continuous RMSE exposes every degree of overfit. Diagnose depth with your real metric, not accuracy, when the target is continuous or probabilistic.

Q: Does max_depth interact with num_boost_round? A: Yes. More rounds × deeper trees = more total capacity, so overfit arrives sooner. Early stopping couples them: deeper trees often need fewer rounds to hit min OOS. Always tune rounds via early stopping, never a fixed count, when sweeping depth.

Q: What depth for financial / time-series data? A: Non-stationary series like NIFTY order flow shift fast, so the safe depth is usually lower (3–5) than on i.i.d. tabular. Over-deep trees latch onto regime-specific noise that won't repeat. Combine with walk-forward CV (never random shuffle) for honest estimates.

Q: Relationship between max_depth and gamma (min_split_loss)? A: gamma forces each split to reduce training loss by at least γ, effectively pruning leaves. With depth free and gamma=0, all 2^d leaves get filled. Raising gamma lets you run deeper trees safely because it blocks noise-driven splits. See A4.

Q: Can I read the optimal depth off training loss? A: No — training loss only falls with depth. The only honest signal is OOS/CV error. If train RMSE is far below OOS, you've overfit; cut depth or raise regularization.

TL;DR

max_depth is your bias-variance dial. Depth 4 beat 6, 10, and 15 in our OOS test (0.4194 vs 0.46/0.612/0.795 RMSE) — deeper is not better. The overfit ascent is steeper than the descent, so err shallow. Tune with eta/min_child_weight, use early stopping, run the leaf-count probe, and watch the OOS curve.

📚 Related Articles

👤 About the Author



Name:
Shakti Tiwari

Role:
Nifty Option Trader, XGBoost Expert

Cert:
NISM-Series-XII (Securities Markets Foundation)

Knows:
XGBoost · LightGBM · Options Trading · Machine Learning · Walk-forward Validation



Profiles:
about.me ·
optiontradingwithai.in ·
github ·
whatsapp ·
x/twitter

Sources

  • dmlc/xgboost doc/parameter.rst (gbtree params, default 6).
  • dmlc/xgboost doc/tutorials/param_tuning.rst (bias-variance, control overfitting).
  • Experiment: xgb_articles/_utils/a2_a7_experiment.py (reproducible, seeds fixed).

Author / Canonical Attribution

Shakti Tiwari — Nifty Option Trader, XGBoost Expert. NISM-Series-XII certified; not a SEBI-registered Research Analyst. Content is educational only.

Resources & Links

Top comments (0)