""" Tutorial 101 - a random forest from scratch: a crowd of trees, and the importance score that trusts noise. The decision-tree tutorial (76) ends with a challenge: grow many trees on bootstrap resamples of the training games, give each split a random subset of the features, let the trees vote, and then rank the features by the Gini impurity each one removed. This script does exactly that, in numpy, on the same NBA home-win problem and the same held-out games, with four columns added: each side's days of rest, read from the schedule, and two planted columns that are pure noise, a random number and a random coin flip per game. The forest votes well enough: 500 trees that average 59.4% on their own reach 65.6% together. The lesson is in its built-in ranking. Mean decrease in impurity, the score a forest collects as it grows, puts the column of random numbers third of six, 1.2 points behind the away team's net rating and about four times either rest column, and it splits on that column more often than on any other. Breiman's out-of-bag permutation test, which shuffles one column on the games each tree never saw, scores the same column at zero. Written by hand: the sorted-cumulative-sum split search, the tree (checked against tutorial 76's), bootstrap resamples, random feature subsets, voting, out-of-bag accuracy, both importance scores, five fresh noise draws and a minimum-leaf floor. No scikit-learn, no scipy. Every number the tutorial page quotes is asserted below. Run: python downloads/101_random_forest_from_scratch.py Data: nba_ratings.csv (Basketball-Reference team ratings, 2023-24) and nba_home_results.csv (every 2023-24 game, 1,231 rows including the In-Season Tournament final), both bundled. """ import os import sys import matplotlib.pyplot as plt import numpy as np import pandas as pd import sdt_common as sdt try: sys.stdout.reconfigure(encoding="utf-8") except (AttributeError, ValueError): pass sdt.init("random-forest-from-scratch") os.chdir(os.path.dirname(os.path.abspath(__file__))) # the CSVs sit beside this script def near(a, b, tol=5e-4): return abs(a - b) < tol # ================================================================ 1. the data == with sdt.snippet("data"): import numpy as np import pandas as pd ratings = pd.read_csv("nba_ratings.csv") nrtg = dict(zip(ratings["Team"], ratings["NRtg"])) g = pd.read_csv("nba_home_results.csv", parse_dates=["date"]) g["home_win"] = (g["home_pts"] > g["away_pts"]).astype(int) g["home_nrtg"] = g["home_team"].map(nrtg) g["away_nrtg"] = g["away_team"].map(nrtg) # days off before each game, from the schedule alone (0 = the second night of a back-to-back) side = pd.concat([pd.DataFrame({"game": g.index, "side": "home", "team": g.home_team, "date": g.date}), pd.DataFrame({"game": g.index, "side": "away", "team": g.away_team, "date": g.date})]) side = side.sort_values(["team", "date"]) side["rest"] = side.groupby("team")["date"].diff().dt.days - 1 openers = int(side["rest"].isna().sum()) side["rest"] = side["rest"].fillna(3).clip(upper=3) # a season opener counts as rested; 3 means 3 or more rest = side.pivot(index="game", columns="side", values="rest") g["home_rest"], g["away_rest"] = rest["home"], rest["away"] # two planted columns that carry no information about the game at all noise_rng = np.random.default_rng(101) g["noise"] = noise_rng.random(len(g)) # a random number between 0 and 1 g["coin"] = noise_rng.integers(0, 2, len(g)) # a random 0 or 1 FEATURES = ["home_nrtg", "away_nrtg", "home_rest", "away_rest", "noise", "coin"] X = g[FEATURES].to_numpy(float) y = g["home_win"].to_numpy() idx = np.random.default_rng(76).permutation(len(X)) # the decision-tree tutorial's split, exactly cut = int(0.75 * len(X)) tr, te = idx[:cut], idx[cut:] print(f"{len(g):,} games: {len(tr)} to train on, {len(te)} held out " f"(home teams won {y[te].mean():.1%} of the held-out games)") print(f"rest, days off before a game, over {len(side):,} team-games ({openers} season openers counted as 3):") for d, n in side["rest"].value_counts().sort_index().items(): print(f" {int(d)}{'+' if d == 3 else ' '} {n:5,d}") print("distinct values in the training games:") for j, f in enumerate(FEATURES): print(f" {f:10s} {len(np.unique(X[tr, j])):5,d}") assert len(g) == 1231 and len(tr) == 923 and len(te) == 308 assert int(y[te].sum()) == 170 and near(y[te].mean(), 0.552) assert openers == 30 and len(side) == 2462 _rc = side["rest"].value_counts().sort_index() assert list(_rc.index) == [0, 1, 2, 3] and list(_rc) == [422, 1538, 359, 143] and sum(_rc) == 2462 assert (side["rest"] == 3).sum() - openers == 113 # 73 + 9 + 2 + 25 + 4 + ... gaps of 3+ days _dv = [len(np.unique(X[tr, j])) for j in range(6)] assert _dv == [30, 30, 4, 4, 923, 2], _dv assert g[FEATURES].notna().all().all() # ================================================================ 2. one tree, faster == with sdt.snippet("tree"): def gini(p): """Gini impurity of a group whose home-win share is p: 0 = pure, 0.5 = a coin flip.""" return 2 * p * (1 - p) def best_split(X, y, features): """The best (feature, threshold) among `features`. Sort once per feature and score every cut with cumulative sums, instead of re-splitting the data at every candidate threshold.""" n, pos = len(y), y.sum() best = (None, None, gini(pos / n)) # a split must beat the parent's impurity for f in features: order = np.argsort(X[:, f], kind="stable") v, yy = X[order, f], y[order] n_left = np.arange(1, n) # cut after the first 1, 2, ..., n-1 games pos_left = np.cumsum(yy)[:-1] imp = (n_left * gini(pos_left / n_left) + (n - n_left) * gini((pos - pos_left) / (n - n_left))) / n imp[v[:-1] == v[1:]] = np.inf # no cut between two equal values k = int(np.argmin(imp)) if imp[k] < best[2]: best = (f, v[k], imp[k]) return best def grow(X, y, rng=None, m=None, max_depth=None, min_leaf=1, log=None, depth=0): """Grow a tree. With rng and m, each split may only look at m features drawn at random.""" node = {"p": y.mean(), "n": len(y)} if (max_depth is not None and depth >= max_depth) or len(y) < 2 * min_leaf or y.min() == y.max(): return node features = range(X.shape[1]) if m is None else np.sort(rng.choice(X.shape[1], m, replace=False)) f, thr, imp = best_split(X, y, features) if f is None: return node left = X[:, f] <= thr if left.sum() < min_leaf or (~left).sum() < min_leaf: return node if log is not None: log.append((f, len(y), gini(y.mean()) - imp)) # which feature split, how many games, Gini removed node.update(f=f, thr=thr, left=grow(X[left], y[left], rng, m, max_depth, min_leaf, log, depth + 1), right=grow(X[~left], y[~left], rng, m, max_depth, min_leaf, log, depth + 1)) return node def predict(node, X): """Each row's leaf home-win share, walking all rows down the tree together.""" out = np.empty(len(X)) stack = [(node, np.arange(len(X)))] while stack: nd, rows = stack.pop() if "f" not in nd: out[rows] = nd["p"] continue go_left = X[rows, nd["f"]] <= nd["thr"] stack += [(nd["left"], rows[go_left]), (nd["right"], rows[~go_left])] return out def leaves(nd): return [nd] if "f" not in nd else leaves(nd["left"]) + leaves(nd["right"]) def accuracy(p, y): return ((p >= 0.5) == y).mean() # tutorial 76's tree: two features, depth 3, at least 20 games a leaf t76 = grow(X[tr][:, :2], y[tr], max_depth=3, min_leaf=20) print(f"depth-3 tree on the two net ratings: first split {FEATURES[t76['f']]} <= {t76['thr']}, " f"leaves of {[lf['n'] for lf in leaves(t76)]} games") print(f" held-out accuracy {accuracy(predict(t76, X[te][:, :2]), y[te]):.1%}") # the same code, grown all the way down on all six columns deep = grow(X[tr], y[tr]) print(f"one tree grown to the end on all six columns: {len(leaves(deep))} leaves, " f"training accuracy {accuracy(predict(deep, X[tr]), y[tr]):.1%}, " f"held-out accuracy {accuracy(predict(deep, X[te]), y[te]):.1%}") assert t76["f"] == 0 and t76["thr"] == -6.32 assert [lf["n"] for lf in leaves(t76)] == [71, 89, 63, 238, 73, 357, 32] assert [round(100 * lf["p"]) for lf in leaves(t76)] == [44, 21, 8, 76, 90, 48, 84] assert int(((predict(t76, X[te][:, :2]) >= 0.5) == y[te]).sum()) == 199 and near(199 / 308, 0.6461) _dleaves = leaves(deep) assert len(_dleaves) == 279, len(_dleaves) _dtr = int(((predict(deep, X[tr]) >= 0.5) == y[tr]).sum()) _dte = int(((predict(deep, X[te]) >= 0.5) == y[te]).sum()) assert (_dtr, _dte) == (923, 180) and near(180 / 308, 0.5844), (_dtr, _dte) # ================================================================ 3. the crowd == with sdt.snippet("forest"): def forest(X, y, n_trees=500, m=2, seed=101, min_leaf=1): """Bagging plus random feature subsets: every tree sees a bootstrap resample of the games, and every split in it may look at only m of the columns, drawn afresh at each split.""" rng = np.random.default_rng(seed) trees, in_bag, logs = [], [], [] for _ in range(n_trees): boot = rng.integers(0, len(y), len(y)) # len(y) draws, with replacement log = [] trees.append(grow(X[boot], y[boot], rng, m, min_leaf=min_leaf, log=log)) in_bag.append(np.bincount(boot, minlength=len(y)) > 0) logs.append(log) return trees, np.array(in_bag), logs def vote(trees, X): """Share of the trees that call each game a home win.""" return np.mean([predict(t, X) >= 0.5 for t in trees], axis=0) trees, in_bag, logs = forest(X[tr], y[tr]) print(f"a bootstrap resample leaves out (1 - 1/923)^923 = {(1 - 1 / 923) ** 923:.4f} of the games; " f"these 500 left out {1 - in_bag.mean():.4f} on average") single = np.array([accuracy(predict(t, X[te]), y[te]) for t in trees]) print(f"the 500 trees alone, on the held-out games: mean {single.mean():.1%}, " f"worst {single.min():.1%}, best {single.max():.1%}") print(f"the forest's vote: {accuracy(vote(trees, X[te]), y[te]):.1%}") trees2, _, _ = forest(X[tr][:, :2], y[tr], m=1) # the tutorial-76 challenge as set: two columns print(f"the same forest on the two net ratings only: {accuracy(vote(trees2, X[te][:, :2]), y[te]):.1%}") assert near((1 - 1 / 923) ** 923, 0.3677) and near(np.exp(-1), 0.3679) assert near(1 - in_bag.mean(), 0.3687, 5e-5), 1 - in_bag.mean() _fv = vote(trees, X[te]) _fc = int(((_fv >= 0.5) == y[te]).sum()) assert _fc == 202 and near(_fc / 308, 0.6558), _fc assert round(100 * single.mean(), 1) == 59.4 and int(round(single.min() * 308)) == 163 and int(round(single.max() * 308)) == 204, \ (single.mean(), single.min() * 308, single.max() * 308) assert near(163 / 308, 0.5292) and near(204 / 308, 0.6623) _c2 = int(((vote(trees2, X[te][:, :2]) >= 0.5) == y[te]).sum()) assert _c2 == 200 and near(_c2 / 308, 0.6494), _c2 # one game more than tutorial 76's tree _beat = int((single > _fc / 308).sum()) assert _beat == 1, _beat # one tree of 500 beats its own forest _se = np.sqrt(0.656 * 0.344 / 308) assert near(_se, 0.0271) and near(1.96 * _se, 0.0531) # ================================================================ 4. out of bag == with sdt.snippet("oob"): oob = ~in_bag # tree b never saw training game i calls = np.array([predict(t, X[tr]) >= 0.5 for t in trees], float) oob_share = (calls * oob).sum(0) / oob.sum(0) print(f"each training game sat out of {oob.sum(0).min()} to {oob.sum(0).max()} trees " f"(mean {oob.sum(0).mean():.1f})") print(f"out-of-bag accuracy over the 923 training games: {accuracy(oob_share, y[tr]):.1%}") # the same estimate as the forest grows: votes from the first k trees only k_votes = np.cumsum(calls * oob, axis=0) k_count = np.cumsum(oob, axis=0) curve = [] for k in range(1, len(trees) + 1): seen = k_count[k - 1] > 0 curve.append(((k_votes[k - 1][seen] / k_count[k - 1][seen] >= 0.5) == y[tr][seen]).mean()) for k in (1, 10, 50, 100, 250, 500): print(f" {k:3d} trees: out-of-bag accuracy {curve[k - 1]:.1%}") _oc = int(((oob_share >= 0.5) == y[tr]).sum()) assert _oc == 615 and near(_oc / 923, 0.6663), _oc assert oob.sum(0).min() == 152 and oob.sum(0).max() == 225 and round(oob.sum(0).mean(), 1) == 184.3, \ (oob.sum(0).min(), oob.sum(0).max(), oob.sum(0).mean()) assert [round(100 * curve[k - 1], 1) for k in (1, 10, 50, 100, 250, 500)] == [58.7, 62.4, 66.5, 67.3, 67.3, 66.6], \ [round(100 * curve[k - 1], 1) for k in (1, 10, 50, 100, 250, 500)] # ================================================================ 5. the built-in ranking == with sdt.snippet("mdi"): def impurity_importance(logs, n): """Mean decrease in impurity: every split's Gini drop, weighted by the share of the tree's games that reached it, summed per feature, averaged over the trees.""" total = np.zeros(len(FEATURES)) for log in logs: for f, games, drop in log: total[f] += games / n * drop return total / len(logs) mdi = impurity_importance(logs, len(tr)) share = mdi / mdi.sum() splits = np.bincount([f for log in logs for f, _, _ in log], minlength=len(FEATURES)) print("mean decrease in impurity, as a share of the total (the forest's built-in score):") for r, j in enumerate(np.argsort(-share), 1): print(f" {r}. {FEATURES[j]:10s} {share[j]:6.1%} {splits[j]:6,d} splits") root = trees[0] kids = [root["left"], root["right"]] print(f"tree 1's first split: {FEATURES[root['f']]} <= {root['thr']}, on {root['n']} resampled games " f"with {round(root['n'] * root['p'])} home wins") print(f" left {kids[0]['n']} games ({round(kids[0]['n'] * kids[0]['p'])} home wins), " f"right {kids[1]['n']} games ({round(kids[1]['n'] * kids[1]['p'])} home wins); " f"Gini drop {logs[0][0][2]:.5f}") _sh = {f: round(100 * s, 1) for f, s in zip(FEATURES, share)} assert _sh == {"home_nrtg": 31.9, "away_nrtg": 26.8, "home_rest": 6.3, "away_rest": 6.0, "noise": 25.6, "coin": 3.4}, _sh assert list(np.argsort(-share)) == [0, 1, 4, 2, 3, 5] # noise third of six assert near(share[4] / share[2], 4.09, 0.005) and near(share[1] - share[4], 0.0123, 1e-4), (share[4] / share[2], share[1] - share[4]) assert splits.sum() == sum(len(log) for log in logs) assert list(splits) == [26345, 25543, 10196, 10567, 28082, 6541], list(splits) assert splits.argmax() == 4 and splits.sum() == 107274 and near(splits[4] / splits.sum(), 0.2618), splits[4] / splits.sum() from fractions import Fraction as Fr # noqa: E402 assert root["f"] == 1 and root["thr"] == -2.09 and root["n"] == 923 and round(923 * root["p"]) == 519 assert (kids[0]["n"], round(kids[0]["n"] * kids[0]["p"]), kids[1]["n"], round(kids[1]["n"] * kids[1]["p"])) == (318, 254, 605, 265) _gp, _gl, _gr = 2 * Fr(519, 923) * Fr(404, 923), 2 * Fr(254, 318) * Fr(64, 318), 2 * Fr(265, 605) * Fr(340, 605) _drop = _gp - Fr(318, 923) * _gl - Fr(605, 923) * _gr assert round(float(_gp), 5) == 0.49224 and round(float(_gl), 5) == 0.32151 and round(float(_gr), 5) == 0.49232, (float(_gp), float(_gl), float(_gr)) assert round(float(Fr(318, 923) * _gl + Fr(605, 923) * _gr), 5) == 0.43347 and round(318 / 923, 5) == 0.34453 and round(605 / 923, 5) == 0.65547 assert round(float(_drop), 5) == 0.05877 and near(logs[0][0][2], float(_drop), 1e-12) assert near(254 / 318, 0.7987) and near(265 / 605, 0.4380) assert round(100 * 254 / 318, 1) == 79.9 and round(100 * 265 / 605, 1) == 43.8 and round(100 * splits[4] / splits.sum(), 1) == 26.2 # ================================================================ 6. shuffle instead == with sdt.snippet("perm"): def permutation_importance(trees, in_bag, X, y, seed=7): """Breiman's recipe, tree by tree: shuffle one column on the games a tree never saw, run them down that tree again, and record how much its error rises. Average over the trees.""" rng = np.random.default_rng(seed) rise = np.zeros((len(trees), X.shape[1])) for b, (t, o) in enumerate(zip(trees, ~in_bag)): rows = np.flatnonzero(o) Xo, yo = X[rows], y[rows] err = 1 - accuracy(predict(t, Xo), yo) for j in range(X.shape[1]): Xp = Xo.copy() Xp[:, j] = rng.permutation(Xp[:, j]) # noise up column j rise[b, j] = (1 - accuracy(predict(t, Xp), yo)) - err return rise.mean(0), rise.std(0, ddof=1) / np.sqrt(len(trees)) rise, se = permutation_importance(trees, in_bag, X[tr], y[tr]) print("rise in a tree's out-of-bag error when one column is shuffled, averaged over 500 trees:") for j in np.argsort(-rise): print(f" {FEATURES[j]:10s} {100 * rise[j]:+6.2f} points (+/- {200 * se[j]:.2f})") assert [round(100 * r, 2) for r in rise] == [5.41, 3.31, 0.17, -0.10, -0.04, 0.20], [round(100 * r, 2) for r in rise] assert [round(200 * e, 2) for e in se] == [0.28, 0.27, 0.17, 0.16, 0.24, 0.15], [round(200 * e, 2) for e in se] assert rise[4] + 2 * se[4] > 0 > rise[4] - 2 * se[4] # the random number: zero inside the interval assert rise[5] - 2 * se[5] > 0 # the coin's interval clears zero: a false alarm assert rise[2] - 2 * se[2] < 0.0005 and rise[3] + 2 * se[3] > 0 # neither rest column clears zero by much # ================================================================ 7. again, and with a floor == with sdt.snippet("repeat"): print("five fresh noise columns, a fresh forest each time:") print(" draw noise share rank shuffle rise, points: noise coin") Xr = X[tr].copy() redraw = [] for draw in range(1, 6): r = np.random.default_rng(1000 + draw) Xr[:, 4], Xr[:, 5] = r.random(len(tr)), r.integers(0, 2, len(tr)) t_r, ib_r, lg_r = forest(Xr, y[tr], seed=draw) s_r = impurity_importance(lg_r, len(tr)) s_r = s_r / s_r.sum() rise_r, se_r = permutation_importance(t_r, ib_r, Xr, y[tr], seed=draw) redraw.append((s_r[4], int((s_r > s_r[4]).sum()) + 1, rise_r[4], se_r[4], rise_r[5], se_r[5])) print(f" {draw:4d} {s_r[4]:11.1%} {redraw[-1][1]:4d} {100 * rise_r[4]:+21.2f} (+/- {200 * se_r[4]:.2f})" f" {100 * rise_r[5]:+.2f} (+/- {200 * se_r[5]:.2f})") t20, _, lg20 = forest(X[tr], y[tr], min_leaf=20) s20 = impurity_importance(lg20, len(tr)) s20 = s20 / s20.sum() print(f"with at least 20 games a leaf: noise share {s20[4]:.1%}, rank {int((s20 > s20[4]).sum()) + 1}; " f"held-out accuracy {accuracy(vote(t20, X[te]), y[te]):.1%}") # ================================================================ 8. the chart == import matplotlib.pyplot as plt LABELS = ["home net rating", "away net rating", "home rest", "away rest", "random number (planted)", "coin flip (planted)"] REAL, PLANTED = "#4A4F58", "#C56A1E" colors = [PLANTED if j >= 4 else REAL for j in range(len(FEATURES))] order = np.arange(len(FEATURES))[::-1] # first feature at the top fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.6), sharey=True) ax1.barh(order, 100 * share, color=colors, height=0.62) for j in range(len(FEATURES)): ax1.text(100 * share[j] + 0.6, order[j], f"{share[j]:.1%}", va="center", fontsize=9) ax1.set_yticks(order) ax1.set_yticklabels(LABELS) ax1.set_xlim(0, 38) ax1.set_xlabel("share of the forest's total Gini decrease (%)") ax1.set_title("Built-in score: the random number ranks third", fontsize=11) ax1.grid(axis="x") ax1.grid(axis="y", visible=False) ax2.barh(order, 100 * rise, xerr=200 * se, color=colors, height=0.62, error_kw={"ecolor": "#20242B", "capsize": 3, "lw": 1}) for j in range(len(FEATURES)): x = max(100 * rise[j] + 200 * se[j], 0) + 0.15 ax2.text(x, order[j], f"{100 * rise[j]:+.2f}", va="center", fontsize=9) ax2.axvline(0, color="#20242B", lw=0.8) ax2.set_xlim(-0.8, 6.8) ax2.set_xlabel("out-of-bag error rise when shuffled (points, +/- 2 SE)") ax2.set_title("Shuffle test: the random number scores zero", fontsize=11) ax2.grid(axis="x") ax2.grid(axis="y", visible=False) fig.suptitle("A 500-tree random forest on 923 NBA games, 2023-24: which columns matter?", fontsize=12, fontweight="bold") fig.tight_layout() sdt.save_fig(fig, "importance_two_ways", source="Basketball-Reference ratings + 2023-24 results (bundled)") _rd = [(round(100 * a, 1), b, round(100 * c, 2), round(200 * d, 2), round(100 * e, 2), round(200 * f, 2)) for a, b, c, d, e, f in redraw] assert _rd == [(25.2, 3, -0.39, 0.23, 0.10, 0.15), (26.1, 3, -0.05, 0.21, 0.17, 0.15), (26.0, 2, -0.10, 0.22, 0.09, 0.16), (25.8, 3, -0.04, 0.22, 0.02, 0.17), (26.3, 3, 0.16, 0.23, -0.03, 0.15)], _rd _noise_r = np.array([x[2] for x in redraw]) assert round(_noise_r.std(ddof=1) * 100, 2) == 0.20 and round(np.mean([x[3] for x in redraw]) * 100, 2) == 0.11 \ and near(_noise_r.std(ddof=1) / np.mean([x[3] for x in redraw]), 1.79, 0.005), \ (_noise_r.std(ddof=1) * 100, np.mean([x[3] for x in redraw]) * 100) _clear = sum(abs(x[2]) > 2 * x[3] for x in redraw) + sum(abs(x[4]) > 2 * x[5] for x in redraw) assert _clear == 2 and abs(redraw[0][2]) > 2 * redraw[0][3] and abs(redraw[1][4]) > 2 * redraw[1][5], _clear # draw 1's noise and draw 2's coin; with the main run's coin, 3 of the 12 planted-column intervals miss zero assert all(x[1] in (2, 3) for x in redraw) and min(x[0] for x in redraw) > 0.25 assert round(100 * s20[4], 1) == 8.5 and int((s20 > s20[4]).sum()) + 1 == 3 _c20 = int(((vote(t20, X[te]) >= 0.5) == y[te]).sum()) assert _c20 == 208 and near(208 / 308, 0.6753), _c20 print("all asserts passed")