Machine Learningscikit-learn 1.9.1 · xgboost 3.4.1 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
52 small wins to finish your pathNext lesson →

Decision tree regression and pruning

A decision tree regressor is a decision tree that predicts a number: each leaf outputs the mean of its training targets, and the splits are chosen to lower the mean squared error.

Last updated: 04 Oct, 2026 · scikit-learn 1.9.1

The trees in Information gain predicted a class. When the output is continuous, such as a price or a score, the same tree works with two changes: a leaf gives an average, and the purity test becomes the squared error.

Decision tree regressor: mean output and MSE · from the Complete Machine Learning in 6 Hours video · 237:00 to 240:46

The video calls the criteria mean squared error and mean absolute error. scikit-learn names them "squared_error" and "absolute_error"; the older names "mse" and "mae" were removed in version 1.2.

Predicting the mean in each node

Say feature f1 is chosen for the root. The root's prediction is the mean of all the outputs. Instead of entropy or Gini, the tree measures how far the outputs are from that mean with the mean squared error (MSE), or the mean absolute error (MAE):

The board borrows 1/2m from the linear regression cost function; the MSE of a node divides by n, the number of records in it.

Then the tree splits. Some records go to one child, the rest to the other, and each child predicts the mean of its own records. A good split makes the MSE drop. Following a path down to a leaf, the output is the mean of the records in that leaf.

The video's example has five outputs: 20, 24, 26, 28 and 30. Their mean is 25.6, so the root predicts 25.6, and its MSE is

The clip calls the MSE "some 37, 47" as a stand-in number; for these five values it is 11.84. If the first two records go left and the other three right, the left leaf predicts 22 with MSE 4, the right leaf predicts 28 with MSE 2.67, and the weighted MSE falls to 3.2.

A regression tree on the targets 20, 24, 26, 28 and 30: the root predicts the mean 25.6 with MSE 11.84, and the split f1 <= 2.5 gives leaves with means 22 and 28, lowering the weighted MSE to 3.2.

Scoring a split with variance reduction

The MSE of a node around its own mean is the variance of its outputs. So the drop in MSE that a split buys has a name: variance reduction. A regression tree tries every candidate split and keeps the one with the largest variance reduction, the way a classification tree keeps the largest information gain. For the five targets above it is 11.84 − 3.2 = 8.64.

A second example makes the search visible. Five people have years of experience, a career gap (Yes or No) and a salary, the output:

ExpGapSalary
2Yes40K
2.5Yes42K
3No52K
4No60K
4.5Yes56K

The mean salary is 50K, so the root predicts 50 and its variance is:

Now try two thresholds on Exp. Each child's variance is weighted by its share of the records wᵢ and subtracted from the root's:

  • Exp ≤ 2 leaves 40 alone (variance 0) and 42, 52, 60, 56 on the other side (mean 52.5, variance 44.75). The reduction is 60.8 − (1/5 × 0 + 4/5 × 44.75) = 25.0.
  • Exp ≤ 2.5 leaves 40, 42 (mean 41, variance 1) and 52, 60, 56 (mean 56, variance 10.67). The reduction is 60.8 − (2/5 × 1 + 3/5 × 10.67) = 54.0, the larger one, so the tree splits here.

A new person who lands in the left leaf gets the mean of 40 and 42, a salary of 41K; one in the right leaf gets (52 + 60 + 56) / 3 = 56K. A common slip is to measure each child's spread from the root's mean of 50, which gives reductions of 0 and 0.004; each child's variance is measured from its own mean, which gives 25.0 and 54.0. The winner, Exp ≤ 2.5, is the same either way.

Five records of experience, career gap and salary (40K, 42K, 52K, 60K, 56K) with root variance 60.8: the split Exp <= 2 leaves 40 and 42, 52, 60, 56 for a variance reduction of 25.0, and the split Exp <= 2.5 leaves 40, 42 and 52, 60, 56 for a reduction of 54.0, so it wins and its leaves output 41 and 56.
Overfitting, post-pruning and pre-pruning · from the Complete Machine Learning in 6 Hours video · 240:47 to 242:42

Pruning a tree that overfits

A decision tree can keep dividing nodes to any depth, so it tends to overfit: it learns the training data, noise included. Cutting the tree back is called pruning, and there are two kinds.

Post-pruning builds the full tree first, then looks for branches that add little. The video's example is a node with 7 Yes and 2 No. Most of its records, 7 of 9 or about 78%, are Yes, so splitting it further adds little: cut the branch and let the node answer Yes.

Pre-pruning stops the tree while it grows, with hyperparameters such as max_depth (how many questions deep) and max_leaf_nodes (how many leaves at most). Their values are tuned with GridSearchCV, as in Hyperparameter tuning with GridSearchCV.

Left: a tree grown until every leaf is pure, which overfits. Middle: post-pruning cuts the split below a 7 Yes / 2 No node. Right: pre-pruning stops the tree at max_depth 2.

The video describes post-pruning without code. In scikit-learn it is minimal cost-complexity pruning, set with ccp_alpha. Each subtree T is scored by its error R(T) plus a charge of α for every leaf, and the tree keeps the subtree with the lowest score. A bigger α means a smaller tree.

Fitting the five targets with DecisionTreeRegressor

The five records

python
import numpy as np
from sklearn.tree import DecisionTreeRegressor, export_text

# One feature f1 and the five continuous targets from the video
f1 = np.array([[1], [2], [3], [4], [5]])
y = np.array([20, 24, 26, 28, 30])

Using the old criterion name

Code written for older releases passes criterion="mse". On scikit-learn 1.9.1 it fails before fitting:

ExampleRun on scikit-learn 1.9.1
try:
    DecisionTreeRegressor(criterion="mse").fit(f1, y)
except ValueError as err:
    print(type(err).__name__)
    print(str(err).split(" among")[0])   # the message goes on to list the accepted names
ExampleThe video's values, run on scikit-learn 1.9.1
print("root: mean", y.mean(), "MSE", round(((y - y.mean()) ** 2).mean(), 2))

reg = DecisionTreeRegressor(criterion="squared_error", max_depth=1).fit(f1, y)
print(export_text(reg, feature_names=["f1"]))
print("node MSEs:", reg.tree_.impurity.round(2))
print("predictions for f1 = 2 and 4:", reg.predict([[2], [4]]))

What the regressor found

  • Root mean 25.6, MSE 11.84: the numbers worked out above.
  • The split is f1 ≤ 2.5: of all the places to cut, putting 20 and 24 on one side and 26, 28, 30 on the other lowers the MSE the most.
  • Node MSEs 11.84, 4.0 and 2.67: the root, then the two leaves. Each leaf predicts its mean, 22 or 28.
  • "mse" is rejected with an InvalidParameterError, a kind of ValueError. The full message goes on to list the names 1.9.1 accepts: "squared_error", "absolute_error" and "poisson".

Computing variance reduction for the salary data

The salary records

python
import numpy as np
from sklearn.tree import DecisionTreeRegressor

# Years of experience and salary in thousands
exp = np.array([[2], [2.5], [3], [4], [4.5]])
salary = np.array([40, 42, 52, 60, 56])
ExampleThe salary example, run on scikit-learn 1.9.1
for i, cut in enumerate(exp[:-1, 0]):
    left, right = salary[: i + 1], salary[i + 1:]
    weighted = len(left) / 5 * left.var() + len(right) / 5 * right.var()
    print(f"Exp <= {cut}: variance reduction {salary.var() - weighted:.1f}")

stump = DecisionTreeRegressor(max_depth=1).fit(exp, salary)
print("scikit-learn threshold:", round(stump.tree_.threshold[0], 2))
print("predictions for Exp = 2.2 and 4.2:", stump.predict([[2.2], [4.2]]))

var() on a NumPy array divides by n, the same variance as the formula. The loop finds 25.0 for Exp ≤ 2 and 54.0 for Exp ≤ 2.5, the two splits worked out above, and scikit-learn picks the same split. It places the threshold at 2.75, halfway between 2.5 and 3, and the two leaves predict 41 and 56.

Pre-pruning and post-pruning on the diabetes data

Pruning pays off on real data. scikit-learn's diabetes dataset has 442 patients with ten measurements each (age, sex, BMI, blood pressure and six blood serum values, already centred and scaled). The output is a number: how far the disease progressed one year later. The data is split 70/30, and three regression trees are compared: no limits, pre-pruned with GridSearchCV, and post-pruned with ccp_alpha.

Loading and splitting the diabetes data

python
import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score
from sklearn.metrics import r2_score, mean_squared_error

X, y = load_diabetes(return_X_y=True, as_frame=True)   # 442 patients, 10 features
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=10)

Pre-pruning with GridSearchCV

The grid tries the criterion, the splitter, the depth and how many features each split may look at. neg_mean_squared_error is the MSE with its sign flipped, because GridSearchCV keeps the highest score. Older code for this search also lists criterion="friedman_mse" and max_features="auto"; scikit-learn 1.9.1 rejects both for DecisionTreeRegressor ("auto" was removed in 1.3), so None, meaning all features, takes the place of "auto".

python
# Pre-pruning: search the limits with 5-fold cross-validation
param = {
    "criterion": ["squared_error", "absolute_error"],
    "splitter": ["best", "random"],
    "max_depth": [1, 2, 3, 4, 5, 10, 15, 20, 25],
    "max_features": [None, "sqrt", "log2"],
}
grid = GridSearchCV(DecisionTreeRegressor(random_state=42), param_grid=param,
                    cv=5, scoring="neg_mean_squared_error")
grid.fit(X_train, y_train)

Post-pruning with ccp_alpha

python
# Post-pruning: grow the full tree, list the alphas at which branches get cut,
# then keep the alpha with the best 5-fold cross-validation score
path = DecisionTreeRegressor(random_state=42).cost_complexity_pruning_path(X_train, y_train)
alphas = path.ccp_alphas[:-1]        # the last alpha cuts the tree down to its root
cv = [cross_val_score(DecisionTreeRegressor(ccp_alpha=a, random_state=42), X_train, y_train,
                      cv=5, scoring="neg_mean_squared_error").mean() for a in alphas]
best_alpha = alphas[int(np.argmax(cv))]
pruned = DecisionTreeRegressor(ccp_alpha=best_alpha, random_state=42).fit(X_train, y_train)
ExampleThe diabetes regressor, run on scikit-learn 1.9.1
print("best grid:", grid.best_params_)
print("best ccp_alpha:", round(best_alpha, 1))
full = DecisionTreeRegressor(random_state=42).fit(X_train, y_train)
for name, model in [("no limits", full), ("grid search", grid.best_estimator_), ("ccp_alpha", pruned)]:
    pred = model.predict(X_test)
    print(f"{name:12} depth {model.get_depth():2}  leaves {model.get_n_leaves():3}  "
          f"train R2 {model.score(X_train, y_train):.3f}  test R2 {r2_score(y_test, pred):.3f}  "
          f"test MSE {mean_squared_error(y_test, pred):.0f}")

What pruning changed

  • The tree with no limits has 302 leaves for 309 training patients and a training R² of 1.000, but a test R² of −0.191. A negative R² means it does worse than predicting the average progression for every patient: it memorised the training rows.
  • The grid search keeps depth 4 and 13 leaves (squared error, random splitter, all features) and lifts the test R² to 0.248, with the test MSE down from 7534 to 4758.
  • Cost-complexity pruning keeps 7 leaves with ccp_alpha 126.4 and gives the best test score here, R² 0.324 and MSE 4277, with the smallest tree.
  • The training R² drops for both pruned trees, to 0.408 and 0.506. That is the point: they stop memorising the training rows. An R² near 0.3 also shows that one tree is a weak model for this data; the ensembles in Bagging and boosting combine many trees to do better.

Pre-pruning vs post-pruning

Pre-pruningPost-pruning
WhenWhile the tree growsAfter the full tree is built
HowLimits on depth, leaves or samples per leafCut weak branches, like the 7Y/2N node
In scikit-learnmax_depth, max_leaf_nodes, min_samples_leafccp_alpha (minimal cost-complexity pruning)
TuningGridSearchCV over the limitscost_complexity_pruning_path, then cross-validation
On the diabetes datadepth 4, 13 leaves, test R² 0.2487 leaves, test R² 0.324
CostCheap: the tree stays smallGrows the full tree first

Where you use decision tree regression

  • Prices and amounts with thresholds: rent by area and number of rooms, where the effect jumps at certain values.
  • A quick non-linear baseline: no scaling, no assumption of a straight-line relationship.
  • Inside ensembles: random forest and gradient boosting regressors are many regression trees added together.
Watch out. A regression tree can only predict the leaf means it learned. For f1 = 10, far past the training data, the tree above still predicts 28, the mean of its right leaf. Trees cannot extrapolate a trend; the prediction is a flat step outside the training range.
Try it yourself
  • Predict reg.predict([[10]]) and confirm the answer is 28.
  • Set max_depth=2 on the five-record regressor. How many leaves are there, and what are their means?
  • Change the regressor to criterion="absolute_error" and print the tree again. Each leaf now predicts the median of its records. Does the split stay at f1 ≤ 2.5?

This is what real progress feels like.