Dictionary of Applied Machine Learning · random forest

random forest — Python demo

Numerical companion to the entry random forest: it recomputes what the entry states and prints one line per check

Numerical companion to the glossary entry 'randomforest' (random forest). The GeoSphere Austria weather station Krems (station id 3805) records the minimum and maximum air temperature and the sunshine hours of each day; this script downloads the records for 2020 from the GeoSphere data hub (dataset klima-v2-1d) and writes the temperatures to randomforest_weather.csv. Each day is a data point whose feature is the minimum temperature tmin and whose label is the maximum temperature tmax.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/randomforest.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download randomforest.py into a folder and run uv run randomforest.py there. With NumPy and Matplotlib already installed, python3 randomforest.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download randomforest.py · Notebook · Open in Colab

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""Random forest at Krems: three trees and their average, which features
the splits use, and what withholding features costs.

Purpose
-------
Numerical companion to the glossary entry 'randomforest' (random
forest).  The GeoSphere Austria weather station Krems (station id 3805)
records the minimum and maximum air temperature and the sunshine hours
of each day; this script downloads the records for 2020 from the
GeoSphere data hub (dataset klima-v2-1d) and writes the temperatures to
randomforest_weather.csv.  Each day is a data point whose feature is the
minimum temperature tmin and whose label is the maximum temperature tmax.

Three depth-2 regression trees are trained, each on its own random
selection (with replacement) of the 366 days, and the random forest
predicts by averaging the three tree predictions.  A second experiment
adds three more features of the day and grows forests whose base
learners are also offered only a random pair of the four features, as
the entry's random forest does.

The demo checks the entry's central claims: each tree is a piecewise
constant map with few pieces; the random selection makes the trees
differ; the mean squared prediction error of the averaged prediction on
the full dataset never exceeds the average of the trees' errors (the
algebraic identity behind variance reduction by averaging); the feature
importance scores are shares of the splits; and withholding features
lowers the correlation between the trees while raising the variance of
each of them, which is the trade the entry describes.

Deterministic: every random selection uses a fixed seed.  Self-contained:
numpy + matplotlib only (stdlib urllib for the download).

Blocks
------
[B-fetch]  Download the 366 daily records at Krems for 2020 and write the
           temperature pairs to randomforest_weather.csv; check the count
           and one pinned value against the archive.
[B-trees]  Train three depth-2 regression trees, each on a random
           selection of the days (fixed seeds); check that each tree is
           piecewise constant with at most four pieces, fits its
           selection better than a constant, and that the trees differ
           pairwise.
[B-forest] Average the three trees into the random forest prediction;
           check that the forest curve is the pointwise mean of the tree
           curves and that the forest's mean squared prediction error on all
           366 days is smaller than the average of the trees' errors.
[B-splits] Grow a 60-tree forest on four features of the day, record the
           feature tested at every split, and report the importance of a
           feature as the fraction of all splits that test it.  Each tree
           sees randomly selected days and a randomly selected pair of
           the four features.  Written to randomforest_importance.csv.
[B-trade]  On 120 training sets of 200 days each, grow two trees per
           training set, once with all four features offered and once
           with a random pair; estimate at every day the variance of a
           single tree's prediction and the correlation between the two
           trees, as bagging.py does.  Withholding features lowers the
           correlation and raises the variance -- the trade.  Written to
           randomforest_trade.csv.

Outputs
-------
randomforest_weather.csv : date, tmin, tmax for the 366 days of 2020
randomforest_tree1.csv   : tmin, pred -- first tree on a tmin grid
randomforest_tree2.csv   : tmin, pred -- second tree on the grid
randomforest_tree3.csv   : tmin, pred -- third tree on the grid
randomforest_forest.csv  : tmin, pred -- the averaged (forest) curve
randomforest_importance.csv : feature, number of splits testing it, that
                           count as a fraction of all splits, and the
                           bar position in the entry's figure
randomforest_trade.csv   : features offered, mean correlation between two
                           trees, mean variance of one tree
randomforest.png         : preview (checking only) -- the three trees and
                           the forest, and the importance scores
"""

import json
import urllib.request
from pathlib import Path

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

OUT_DIR = Path(__file__).parent

report = []                         # collects (check name, pass/fail) pairs


def check(name, ok):                # records and prints one verification
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")

B-fetch

Download the 366 daily records at Krems for 2020 and write the temperature pairs to randomforest_weather.csv; check the count and one pinned value against the archive.

URL = ("https://dataset.api.hub.geosphere.at/v1/station/historical/"
       "klima-v2-1d?parameters=tlmin,tlmax,so_h&station_ids=3805"
       "&start=2020-01-01&end=2020-12-31")
with urllib.request.urlopen(URL, timeout=120) as resp:
    payload = json.load(resp)
params = payload["features"][0]["properties"]["parameters"]
stamps = [t[:10] for t in payload["timestamps"]]
tmin = np.array(params["tlmin"]["data"], dtype=float)
tmax = np.array(params["tlmax"]["data"], dtype=float)
with open(OUT_DIR / "randomforest_weather.csv", "w") as f:
    f.write("date,tmin,tmax\n")
    for d, a, b in zip(stamps, tmin, tmax):
        f.write(f"{d},{a:.1f},{b:.1f}\n")
check("[B-fetch] 366 daily temperature pairs downloaded for 2020",
      len(tmin) == 366)
feb1 = stamps.index("2020-02-01")
check("[B-fetch] the record matches the archive (Feb 1: 4.2 to 17.0)",
      tmin[feb1] == 4.2 and tmax[feb1] == 17.0)
  [ok] [B-fetch] 366 daily temperature pairs downloaded for 2020
  [ok] [B-fetch] the record matches the archive (Feb 1: 4.2 to 17.0)

B-trees

Train three depth-2 regression trees, each on a random selection of the days (fixed seeds); check that each tree is piecewise constant with at most four pieces, fits its selection better than a constant, and that the trees differ pairwise.

def best_split(x, y):
    """Threshold minimizing the summed squared error of the two parts,
    or None if no split with at least 5 data points per part exists."""
    order = np.argsort(x)
    xs, ys = x[order], y[order]
    best, best_sse = None, np.inf
    for i in range(5, len(xs) - 5 + 1):
        if xs[i - 1] == xs[i]:
            continue
        left, right = ys[:i], ys[i:]
        sse = ((left - left.mean()) ** 2).sum() \
            + ((right - right.mean()) ** 2).sum()
        if sse < best_sse:
            best_sse, best = sse, (xs[i - 1] + xs[i]) / 2
    return best


def depth2_tree(x, y):
    """Thresholds and leaf means of a depth-2 regression tree."""
    t0 = best_split(x, y)
    cuts = []
    for side in (x <= t0, x > t0):
        t = best_split(x[side], y[side])
        if t is not None:
            cuts.append(t)
    cuts = sorted(cuts + [t0])
    edges = [-np.inf] + cuts + [np.inf]
    means = [y[(x > lo) & (x <= hi)].mean()
             for lo, hi in zip(edges[:-1], edges[1:])]
    return np.array(cuts), np.array(means)


def predict(cuts, means, xq):
    return means[np.searchsorted(cuts, xq)]


grid = np.linspace(tmin.min() - 1.0, tmin.max() + 1.0, 400)
trees, curves = [], []
for seed in (1, 2, 3):
    rng = np.random.default_rng(seed)
    idx = rng.integers(0, len(tmin), len(tmin))     # random selection, with replacement
    cuts, means = depth2_tree(tmin[idx], tmax[idx])
    trees.append((idx, cuts, means))
    curves.append(predict(cuts, means, grid))
    np.savetxt(OUT_DIR / f"randomforest_tree{seed}.csv",
               np.stack([grid, curves[-1]], 1),
               delimiter=",", header="tmin,pred", comments="", fmt="%.2f")
check("[B-trees] each tree is piecewise constant with at most 4 pieces",
      all(len(means) <= 4 for _, _, means in trees))
check("[B-trees] each tree fits its own selection of the days better than a constant",
      all(((tmax[idx] - predict(cuts, means, tmin[idx])) ** 2).mean()
          < tmax[idx].var() for idx, cuts, means in trees))
check("[B-trees] the random selection makes the trees differ pairwise",
      all(np.max(np.abs(curves[i] - curves[j])) > 0.5
          for i in range(3) for j in range(i + 1, 3)))
  [ok] [B-trees] each tree is piecewise constant with at most 4 pieces
  [ok] [B-trees] each tree fits its own selection of the days better than a constant
  [ok] [B-trees] the random selection makes the trees differ pairwise

B-forest

Average the three trees into the random forest prediction; check that the forest curve is the pointwise mean of the tree curves and that the forest's mean squared prediction error on all 366 days is smaller than the average of the trees' errors.

forest = np.mean(curves, axis=0)
np.savetxt(OUT_DIR / "randomforest_forest.csv",
           np.stack([grid, forest], 1),
           delimiter=",", header="tmin,pred", comments="", fmt="%.2f")
tree_risks = [((tmax - predict(cuts, means, tmin)) ** 2).mean()
              for _, cuts, means in trees]
forest_pred = np.mean([predict(cuts, means, tmin)
                       for _, cuts, means in trees], axis=0)
forest_risk = ((tmax - forest_pred) ** 2).mean()
check("[B-forest] the forest curve is the pointwise mean of the tree curves",
      np.allclose(forest, np.mean(curves, axis=0)))
check("[B-forest] the averaged prediction has a smaller mean squared prediction error "
      "than the trees on average",
      forest_risk < np.mean(tree_risks) - 1e-9)
print(f"  tree errors {[round(r, 2) for r in tree_risks]}, "
      f"forest error {forest_risk:.2f}")

fig, (ax, axb) = plt.subplots(1, 2, figsize=(10.4, 4.2),
                              gridspec_kw={"width_ratios": [1.5, 1]})
ax.plot(tmin, tmax, "o", ms=2, color="0.6", label="days of 2020 (Krems)")
for c, (style, name) in zip(curves, [(":", "tree 1"), ("--", "tree 2"),
                                     ("-.", "tree 3")]):
    ax.plot(grid, c, style, lw=1.2, label=name)
ax.plot(grid, forest, "-", lw=2.2, color="black", label="random forest")
ax.set_xlabel("minimum temperature of the day (°C)")
ax.set_ylabel("maximum temperature of the day (°C)")
ax.set_title("three trees and their average (random forest)")
ax.legend(frameon=False, fontsize=8)
  [ok] [B-forest] the forest curve is the pointwise mean of the tree curves
  [ok] [B-forest] the averaged prediction has a smaller mean squared prediction error than the trees on average
  tree errors [np.float64(25.2), np.float64(26.51), np.float64(25.29)], forest error 24.10

B-splits

Grow a 60-tree forest on four features of the day, record the feature tested at every split, and report the importance of a feature as the fraction of all splits that test it. Each tree sees randomly selected days and a randomly selected pair of the four features. Written to randomforest_importance.csv.

# A forest is grown on four features of the day, and every split it makes is
# recorded.  The importance of a feature is the fraction of all splits in the
# forest that test it -- read straight off the trees, with no impurity
# bookkeeping.  Each base learner sees randomly selected days AND a randomly
# selected pair of the four features, so a feature cannot be used by a tree
# that was not offered it.
sun = np.array(params["so_h"]["data"], dtype=float)
doy = np.arange(1.0, len(tmax) + 1.0)
FEATURE_NAMES = ["minimum temperature", "sunshine hours",
                 "day of the year", "maximum temperature of the previous day"]
X = np.column_stack([tmin[1:], sun[1:], doy[1:], tmax[:-1]])
Y = tmax[1:]
check("[B-splits] no feature has a missing value over the 365 usable days",
      X.shape == (365, 4) and not np.isnan(X).any() and not np.isnan(Y).any())


def fit_tree(xs, ys, cols, depth, splits=None):
    """Depth-limited regression tree over the feature columns in `cols`.

    Returns a nested tuple, ("leaf", mean) or ("node", j, cut, left,
    right), and appends the column index of every split to `splits`."""
    if depth == 0 or len(ys) < 8:
        return ("leaf", float(ys.mean()))
    best = None
    for j in cols:
        v = xs[:, j]
        o = np.argsort(v)
        vs, ws = v[o], ys[o]
        for i in range(4, len(vs) - 4):
            if vs[i] == vs[i - 1]:
                continue
            lo, hi = ws[:i], ws[i:]
            sse = ((lo - lo.mean()) ** 2).sum() + ((hi - hi.mean()) ** 2).sum()
            if best is None or sse < best[0]:
                best = (sse, j, 0.5 * (vs[i] + vs[i - 1]))
    if best is None:
        return ("leaf", float(ys.mean()))
    _, j, cut = best
    if splits is not None:
        splits.append(j)
    left = xs[:, j] <= cut
    return ("node", j, cut,
            fit_tree(xs[left], ys[left], cols, depth - 1, splits),
            fit_tree(xs[~left], ys[~left], cols, depth - 1, splits))


def tree_predict(node, xs):
    """Evaluate a fitted tree on every row of xs."""
    out = np.empty(len(xs))
    for r in range(len(xs)):
        n = node
        while n[0] == "node":
            n = n[3] if xs[r, n[1]] <= n[2] else n[4]
        out[r] = n[1]
    return out


srng = np.random.default_rng(11)
split_cols = []
N_TREES, N_FEAT = 60, 2
for _ in range(N_TREES):
    rows = srng.integers(0, len(Y), len(Y))       # randomly selected days
    cols = srng.choice(4, size=N_FEAT, replace=False)   # and features
    fit_tree(X[rows], Y[rows], cols, 3, splits=split_cols)
counts = np.bincount(split_cols, minlength=4)
frac = counts / counts.sum()
order = np.argsort(-frac)
print(f"    {len(split_cols)} splits over {N_TREES} trees; "
      f"importance = fraction of splits testing the feature")
for j in order:
    print(f"      {FEATURE_NAMES[j]:42s} {counts[j]:4d}  {frac[j]*100:5.1f}%")
check("[B-splits] the importance scores are fractions that sum to one",
      abs(frac.sum() - 1.0) < 1e-12 and bool(np.all(frac >= 0)))
check("[B-splits] every feature was offered to some tree, so a zero score "
      "would mean unused rather than unavailable", bool(np.all(counts > 0)))
check(f"[B-splits] the two temperature features together take "
      f"{(frac[0] + frac[3]) * 100:.0f}% of the splits, more than the "
      f"calendar day at {frac[2] * 100:.0f}%", frac[0] + frac[3] > frac[2])
# rank = bar position in the entry's figure, 1 for the smallest share
with open(OUT_DIR / "randomforest_importance.csv", "w") as f:
    f.write("feature,splits,fraction,rank\n")
    for pos, j in enumerate(order):
        f.write(f"{FEATURE_NAMES[j]},{counts[j]},{frac[j]:.4f},{len(order) - pos}\n")

# ---- preview, second panel: the importance shares of [B-splits]
axb.barh([FEATURE_NAMES[j].replace(" of the previous day", "\nof the previous day")
          for j in order][::-1], frac[order][::-1], color="0.8", edgecolor="k")
axb.set_xlabel("share of all splits that test the feature")
axb.set_ylabel("feature of the day")
axb.set_title("share of splits per feature")
axb.tick_params(axis="y", labelsize=7)
fig.tight_layout()
fig.savefig(OUT_DIR / "randomforest.png", dpi=150)
  [ok] [B-splits] no feature has a missing value over the 365 usable days
    420 splits over 60 trees; importance = fraction of splits testing the feature
      maximum temperature of the previous day     157   37.4%
      day of the year                             120   28.6%
      minimum temperature                          72   17.1%
      sunshine hours                               71   16.9%
  [ok] [B-splits] the importance scores are fractions that sum to one
  [ok] [B-splits] every feature was offered to some tree, so a zero score would mean unused rather than unavailable
  [ok] [B-splits] the two temperature features together take 55% of the splits, more than the calendar day at 29%
    features offered: all four       rho = 0.102   sigma^2 = 4.82
    features offered: a random pair  rho = 0.022   sigma^2 = 8.81
Preview figure produced by randomforest.py
The preview figure the block B-splits writes when the script runs

B-trade

On 120 training sets of 200 days each, grow two trees per training set, once with all four features offered and once with a random pair; estimate at every day the variance of a single tree's prediction and the correlation between the two trees, as bagging.py does. Withholding features lowers the correlation and raises the variance -- the trade. Written to randomforest_trade.csv.

# The entry says restricting the features is a trade: it lowers the
# correlation rho between the base learners and raises the variance sigma^2
# of each of them.  Measured here the way bagging.py measures the variance
# identity: 120 training sets of 200 days, two trees on each, the same two
# random selections of days for both settings, so the only difference is
# whether a tree is offered all four features or a random pair.  At every
# day, sigma^2 is the variance of one tree's prediction over (training set,
# tree) and rho the correlation between the two trees of a training set.
trng = np.random.default_rng(13)
N_TS, TS_SIZE = 120, 200
ALL, PAIR = np.arange(4), None
tr_pred = {"all four": np.empty((N_TS, 2, len(Y))),
           "a random pair": np.empty((N_TS, 2, len(Y)))}
for t in range(N_TS):
    ts = trng.choice(len(Y), size=TS_SIZE, replace=False)
    for b in range(2):
        sel = ts[trng.integers(0, TS_SIZE, TS_SIZE)]      # one selection ...
        pair = trng.choice(4, size=2, replace=False)      # ... one feature pair
        tr_pred["all four"][t, b] = tree_predict(fit_tree(X[sel], Y[sel], ALL, 3), X)
        tr_pred["a random pair"][t, b] = tree_predict(fit_tree(X[sel], Y[sel], pair, 3), X)
trade = {}
for name, pr in tr_pred.items():
    c = pr - pr.mean(axis=(0, 1))                          # centre per day
    sigma2 = (c ** 2).mean(axis=(0, 1))
    rho = (c[:, 0] * c[:, 1]).mean(axis=0) / sigma2
    trade[name] = (float(rho.mean()), float(sigma2.mean()))
    print(f"    features offered: {name:14s} rho = {rho.mean():.3f}   "
          f"sigma^2 = {sigma2.mean():.2f}")
r4, s4 = trade["all four"]; r2, s2 = trade["a random pair"]
check(f"[B-trade]  offering a random pair instead of all four features lowers "
      f"the correlation between two trees, {r4:.3f} -> {r2:.3f}", r2 < r4)
check(f"[B-trade]  and raises the variance of a single tree's prediction, "
      f"{s4:.2f} -> {s2:.2f}", s2 > s4)
with open(OUT_DIR / "randomforest_trade.csv", "w") as f:
    f.write("features_offered,rho,sigma2\n")
    for name, (rho_m, s2_m) in trade.items():
        f.write(f"{name},{rho_m:.4f},{s2_m:.4f}\n")

failed = [name for name, ok in report if not ok]
print(f"{len(report) - len(failed)}/{len(report)} checks passed"
      + (f", FAILED: {failed}" if failed else ""))
if failed:
    raise SystemExit(1)
  [ok] [B-trade]  offering a random pair instead of all four features lowers the correlation between two trees, 0.102 -> 0.022
  [ok] [B-trade]  and raises the variance of a single tree's prediction, 4.82 -> 8.81
13/13 checks passed