🧭 Do not search for the first 15 minutes. When stuck: re-read the requirements → define I/O → choose the data structure → trace a small example by hand → write code.
Implement a gradient boosting regressor over depth-1 regression stumps, without sklearn. Where bagging (#19) is "a majority vote of independent learners", this is sequential: each learner fills in the residual the previous one left behind. The most common boosting bug is getting the ordering — or the moment you update the residual — wrong.
A depth-1 tree. Its best split is found by minimizing weighted SSE (sum of squared errors).
X[:, f] <= t, right = the restmodel = GradientBoostingRegressor(n_estimators=10, lr=0.1)
model.fit(X, y)
pred = model.predict(X) # a list of n floats
base = mean(y) — computed once and never changed afterwards (the model's
constant term).residual = y - base.n_estimators. Each round:
residual and store it.residual <- residual - lr * (the prediction of the stump you just fit).predict(X) = base + lr * (sum of every stump's prediction).⚠️ Each round's stump is fit on the residual as it stands at that moment — not on the original
y. And the residual update subtracts only the stump you just fit this round; it does not recompute the accumulated prediction. (The two are equivalent in the end, but confusing the update order makes the next round's stump see the wrong residual.)
If X and y are empty, set base = 0.0 and finish training with no stumps (no
exception). predict in that state returns 0.0 for every input row.
| Level | Added requirement |
|---|---|
| 1 | fit_reg_stump, predict_reg_stump — the base learner |
| 2 | GradientBoostingRegressor — fit and predict |
| 3 | X_val, y_val, patience — validation-based early stopping |
| 4 | feature_importance — split contributions |
Each level must preserve the behavior of the previous ones.
Build the base learner first.
fit_reg_stump(X, y) -> (dict, float)
It returns a (stump, gain) tuple. stump is one of these two shapes.
{"feature": int, "threshold": float, "left": float, "right": float}
{"feature": None, "threshold": None, "left": m, "right": m} # no split, m = overall mean
gain is the SSE the split removed: parent_sse - (left_sse + right_sse). When no
split was possible, gain is 0.0.
predict_reg_stump(stump, X) -> list
left when X[:, feature] <= threshold, otherwise right.feature is None, return the left value for every row.