shakti tiwari XGBoost colsample_bytree/bylevel/bynode: the three column-sampling knobs and their multiplicative trap.
colsample_bytree / colsample_bylevel / colsample_bynode: The Column-Sampling Trio
Quick Answer: colsample_bytree, colsample_bylevel, and colsample_bynode are XGBoost's three feature-sampling (column-sampling) knobs. All three default to 1.0 (use every feature). colsample_bytree samples a fraction of columns once per tree; colsample_bylevel re-samples per level (depth) inside the tree; colsample_bynode re-samples per split (node). They multiply together, so the columns actually seen at a split = bytree × bylevel × bynode × total_features. In our 5-fold test, dropping columns helped classification (peak ACC 0.9082 at 0.7) but hurt regression (best RMSE 0.4093 at the full 1.0).
Disclaimer: Shakti Tiwari is NISM-Series-XII certified; not a SEBI-registered Research Analyst. Content is educational only.
If subsample is XGBoost's row-bagging (see the previous article, A5), then the colsample_* trio is its feature-bagging — the exact idea that makes Random Forests work. Where subsample asks "which training rows do I look at this round?", the colsample_* family asks "which features am I even allowed to split on at this moment?"
Most beginners only ever touch colsample_bytree, because that is the one every tutorial mentions. But XGBoost actually gives you three distinct scopes of column sampling, and they behave very differently:
colsample_bytree — samples once when a brand-new tree begins. Every split in that tree draws from the same reduced feature pool.colsample_bylevel — samples again at every depth level. So level 0 (root) might see features {A, C, F}, level 1 sees {B, D, G}, level 2 sees {A, E, H}, and so on. The pool is refreshed as the tree gets deeper.colsample_bynode — the most aggressive. It samples fresh at every single node, i.e. at every individual split decision.This is the single most misunderstood corner of XGBoost's parameter docs, and I have seen production trading models silently mis-tuned because someone set all three to 0.8 and got an effective sampling of 0.8³ = 0.512 without realizing it. We will unpack that multiplicative trap below, because it is the headline lesson of this article.
Two-layer engine note (where this parameter lives in our stack): 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 parameter discussed here — colsample_bytree, and by extension its siblings — is tuned in Layer 2, the EOD-audited training core, never in the live shadow feed.
In a Random Forest, each tree is grown on a bootstrap of rows and a random subset of features at every split. That feature randomness is what decorrelates the trees and is the main reason RF generalizes well. XGBoost is a boosting algorithm (trees are sequential, not independent), so naive feature sampling is less central — but it still works as a regularizer. colsample_bytree is the closest analogue to RF's max_features: it gives each tree a different, limited view of the world. colsample_bylevel and colsample_bynode go further than classic RF by re-randomizing deeper inside each tree, which can squeeze out even more diversity (and, if overdone, more noise).
All three parameters are documented in the official dmlc/xgboost repository (github.com/dmlc/xgboost, ~28.7k stars) under doc/parameter.rst and the tuning guide doc/tutorials/param_tuning.rst. The key doc quote, paraphrased faithfully: subsampling of columns happens once per tree (colsample_bytree), once per level (colsample_bylevel), and once per node (colsample_bynode), and the three ratios compose multiplicatively. That is the canonical statement we build on.
Hypothesis: Reducing colsample_bytree below 1.0 decorrelates trees and should improve out-of-sample generalization — i.e. lower regression RMSE and higher classification accuracy — up to a point, after which too few features starve the model and performance collapses.
Test design: Sweep colsample_bytree ∈ {0.3, 0.5, 0.7, 0.8, 0.9, 1.0} holding colsample_bylevel = colsample_bynode = 1.0 (so we isolate the bytree effect first). We run both a regression (reg:squarederror) and a classification (binary:logistic) objective, because feature-sampling can affect the two tasks differently — and as you will see, it does.
The two sibling parameters are explained conceptually after the experiment, since the measured numbers come from colsample_bytree alone.
gbtree booster. eta=0.1, max_depth=4, subsample=1.0 (we are isolating column sampling, not row sampling), colsample_bylevel=1.0, colsample_bynode=1.0. 500 boosting rounds with early stopping (patience 30) on a validation fold.xgb_articles/_utils/a2_a7_experiment.py; the cached results are in xgb_articles/_utils/a2_a7_results.json (keys A6_col_reg and A6_col_clf).colsample_bytree vs OOS (bylevel = bynode = 1.0)
| colsample_bytree | Regression OOS RMSE ↓ | Classification OOS ACC ↑ |
|---|---|---|
| 0.3 | 0.6010 | 0.9057 |
| 0.5 | 0.4288 | 0.9066 |
| 0.7 | 0.4144 | 0.9082 (peak) |
| 0.8 | 0.4194 | 0.9065 |
| 0.9 | 0.4134 | 0.9072 |
| 1.0 (default) | 0.4093 (best) | 0.9071 |
Regression wants ALL the columns. RMSE falls monotonically (with tiny wiggles) as colsample_bytree rises: 0.6010 → 0.4288 → 0.4144 → 0.4194 → 0.4134 → 0.4093 at the default 1.0. Dropping features hurt the regression task at every step. The 0.3 setting is a disaster — 0.6010 is ~47% worse than 0.4093. With a dense signal spread across many features, hiding columns just discards useful information.
Classification has a genuine sweet spot at 0.7. Accuracy climbs from 0.9057 (0.3) to a peak of 0.9082 at 0.7, then dips at 0.8 (0.9065), recovers at 0.9 (0.9072), and sits at 0.9071 for the default 1.0. The gain from 1.0 → 0.7 is +0.0011 accuracy, modest but real and consistent with the "feature sampling reduces overfit" theory for a classification boundary.
The two tasks disagree — and that is the lesson. Same data family, same depth, same rows; only the objective differs. Regression improves with more features, classification with fewer. This is exactly why you must tune colsample_bytree per objective and per dataset rather than copying a forum's "0.8 works great" value.
The default 1.0 is not automatically best, but it is rarely catastrophic. For classification, 1.0 (0.9071) is within 0.0011 of the peak — so leaving it at default costs almost nothing here. For regression, 1.0 is the best. The danger zone is the low end (0.3), not the top.
The 0.8 "magic number" myth takes a hit. A lot of blog posts claim "set colsample_bytree=0.8" as a universal regularizer. In our regression run, 0.8 (0.4194) is actually worse than both 0.7 (0.4144) and 0.9 (0.4134), and far worse than 1.0. The number is dataset-specific, not sacred.
Starvation is asymmetric and sudden. Dropping from 0.5 to 0.3 in regression blows RMSE up from 0.4288 to 0.6010 — a 40% jump for a 0.2 change in the ratio. Feature starvation has a cliff, not a slope. In production you feel it as a model that suddenly "forgets" how to price.
Plot RMSE and ACC against the colsample_bytree value. You are looking for one of three shapes:
Do not just read the table row-by-row. Read the shape. The shape tells you whether feature sampling is helping at all. If the curve is flat across the whole sweep, colsample_bytree is not your lever — go tune max_depth or subsample instead.
import xgboost as xgb
from sklearn.model_selection import KFold
import numpy as np
## Load your X (n_samples, 24) and y
## dtrain = xgb.DMatrix(X, label=y)
def cv_colsample(colsample, objective, num_boost=500):
kf = KFold(n_splits=5, shuffle=True, random_state=0)
scores = []
for tr, te in kf.split(X):
p = dict(
max_depth=4,
eta=0.1,
subsample=1.0, # isolating COLUMN sampling
colsample_bytree=colsample,
colsample_bylevel=1.0, # siblings held at default
colsample_bynode=1.0,
objective=objective, # 'reg:squarederror' or 'binary:logistic'
verbosity=0,
)
# early-stop on a validation slice of tr
bst = xgb.train(p, dtrain_sub, num_boost)
pred = bst.predict(dtest_sub)
# RMSE for reg, ACC for clf
scores.append(score(pred, y_te))
return np.mean(scores)
for c in [0.3, 0.5, 0.7, 0.8, 0.9, 1.0]:
print(c, cv_colsample(c, 'reg:squarederror'),
cv_colsample(c, 'binary:logistic'))
To extend the experiment to the siblings, simply vary colsample_bylevel or colsample_bynode instead (or in addition) while holding the others at 1.0, and watch how the effective sampling compounds.
colsample_bytree < 1 to help regression too. Theory said "feature sampling = regularization = better OOS." Reality: regression RMSE got worse as we dropped columns, all the way to the default. The regularization benefit only showed up for classification. This is the most important counter-intuitive result and the reason we report both objectives rather than hiding the regression line.bytree=bylevel=bynode=0.8 gives an effective 0.512 — we did not measure this in the sweep, but the regression 0.3 result (effective starvation) is a strong warning that triple-sampling would likely crater both tasks on this dataset.max_depth=4, eta=0.1. At deeper trees, per-node sampling (colsample_bynode) becomes more impactful because there are more splits to randomize. Our sweep isolates bytree only; the siblings are characterized conceptually.colsample_bytree with bylevel = bynode = 1.0. Only after you understand the bytree curve should you touch the siblings.bytree × bylevel × bynode. If you set all three to 0.8 you are at 0.512, not 0.8. Log the product in your tuning tracker.colsample_* separately for each. One size fits none here.colsample_bytree alone cannot buy you enough generalization, escalate to colsample_bylevel (re-randomize per depth) before colsample_bynode (re-randomize per split). bynode is the strongest and noisiest; save it for last.subsample (A5). Row + column sampling compose into a powerful regularizer combo — but tune them on separate grids so you can attribute gains.Now that the measured effect of colsample_bytree is clear, here is how the other two fit:
colsample_bylevel (default 1.0): Re-samples the column pool at each depth level of the tree. Think of bytree as choosing which books are in the library for the whole tree; bylevel re-stocks the shelf every floor you walk down. It adds diversity between shallow and deep splits without starving any single tree as hard as a low bytree would.colsample_bynode (default 1.0): The finest grain — a fresh column sample at every split node. This is more random than classic Random Forest (which samples per split too, but RF has no deeper re-sampling). It maximizes tree diversity and is the strongest regularizer of the three, but also the most likely to inject noise, because a split that should have used feature X may never see it.colsample_bytree × colsample_bylevel × colsample_bynode of the original feature count. A tree built with (0.9, 0.9, 0.9) effectively sees 0.729 of features at each split — not 0.9. This is why aggressive triple-sampling can quietly starve a model the way our bytree=0.3 regression run did.A good mental model: bytree = "which features may this whole tree use?", bylevel = "which features may this depth use?", bynode = "which features may this exact split use?" Each question narrows the candidate set further, and the product is what the split algorithm actually optimizes over.
Q: What is the difference between subsample and colsample_bytree?
A: subsample samples rows (training examples) each boosting round — bagging over data. colsample_bytree samples columns (features) for each tree — bagging over inputs. They attack overfitting from two orthogonal directions and compose beautifully together. (See A5 for subsample.)
Q: What are the defaults?
A: All three colsample_* parameters default to 1.0 — i.e. no column sampling, every feature is always eligible. The regularization only kicks in when you drop them below 1.0.
Q: Should I use bytree, bylevel, or bynode?
A: Start with colsample_bytree — it is the coarsest and most interpretable. If you still overfit, try colsample_bylevel next. Reserve colsample_bynode for stubborn cases because it is the noisiest. And never forget they multiply.
Q: Why did regression get worse when I dropped columns?
A: With a dense signal spread across many features, hiding columns just throws away real information — there is no noise feature to "drown out." Classification, by contrast, benefits from the decorrelation because its decision boundary is more sensitive to any single dominant feature dominating every tree. Always check the curve shape for your own objective.
Q: Which layer of our system tunes this?
A: The EOD-audited training core (Layer 2). The Dhan WebSocket shadow feed (Layer 1) only runs inference on the already-trained model.
colsample_bytree, colsample_bylevel, and colsample_bynode are XGBoost's three feature-sampling knobs, all defaulting to 1.0 and multiplying together. In our 5-fold test, colsample_bytree hurt regression (best RMSE 0.4093 at 1.0) but helped classification (peak ACC 0.9082 at 0.7). Sweep it per-objective, watch the curve shape, and respect the multiplicative trap.
👤 About the Author
Profiles:
about.me ·
optiontradingwithai.in ·
github ·
whatsapp ·
x/twitter
dmlc/xgboost repository — doc/parameter.rst (colsample_bytree / colsample_bylevel / colsample_bynode definitions; multiplicative composition note). github.com/dmlc/xgboost (~28.7k stars).dmlc/xgboost — doc/tutorials/param_tuning.rst (column subsampling as a regularization strategy).xgb_articles/_utils/a2_a7_experiment.py; cached results xgb_articles/_utils/a2_a7_results.json (keys A6_col_reg, A6_col_clf).Shakti Tiwari — Nifty Option Trader, XGBoost Expert. NISM-Series-XII certified; not a SEBI-registered Research Analyst. Content is educational only.
subsample | Next: A7 lambda / alpha