What it actually does
A single decision tree is accurate on average and wildly unstable — resample the data and you get a different tree. Averaging many trees cancels that instability, but only if the trees actually disagree, and trees grown on bootstrap samples of the same data do not disagree nearly enough: whichever feature is most predictive becomes the root split of almost every one of them. So the forest adds a second source of randomness that is easy to miss and does most of the work: at every split, only a random subset of the features may be considered. That forces trees to use different features, which decorrelates them, which is what makes the averaging pay.
- Trees can be made to disagree — if one feature dominates every split, correlation stays high and averaging cannot help
- The decision boundary is well approximated by an average of axis-aligned partitions; a diagonal boundary is still a staircase, just a smoother one
- The target lies within the range seen in training, since every component is piecewise constant
- Rows are exchangeable for the purposes of bootstrapping — which time series and clustered data violate, making out-of-bag error optimistic
Loss function
Average the predictions of many deep trees. The second expression is the entire argument for the method and worth reading carefully: the variance of the average has two parts, and only the second one shrinks as trees are added. Push B to infinity and you are left with rho times sigma-squared — a floor set by how correlated the trees are with each other. That is why bagging alone underperforms: bootstrap resampling leaves rho high, because every tree still picks the same dominant feature at the root. Restricting the features available at each split is what drives rho down, and it is the reason a random forest beats bagged trees.
- tree b, grown deep on a bootstrap sample under randomness Theta_b
- number of trees; more is never worse, which is why there is nothing to tune here
- pairwise correlation between tree predictions — the term that does not vanish with B
- variance of a single tree, which is large by design because the trees are grown unpruned
How it is minimized
Independent greedy tree growth on bootstrap samples, with a random feature subset considered at each split
- bootstrap sample — n draws with replacement, so about 37% of rows are left out of each tree
- the random feature subset drawn afresh at every node, not once per tree
- how many features a split may consider; the main decorrelation dial
- the same impurity as a single tree — nothing about the split criterion changes
There is no joint optimization at all: each tree is grown independently and greedily, exactly as a single CART would be, and the ensemble is a plain average taken afterwards. That is what makes the method embarrassingly parallel and almost free of interactions between hyperparameters. Two design choices are worth stating because they look like mistakes. The trees are deliberately grown deep and unpruned — high variance is fine, because averaging is what removes it, and pruning would only add bias the ensemble cannot undo. And the feature subset is redrawn at every node rather than once per tree, because drawing it once would simply produce a forest of trees each fitted on a fixed random subspace, which decorrelates far less.
n_estimators (B)
Number of trees. Monotone: more never hurts accuracy, it only costs time and memory, so this is a budget rather than a tuning parameter (100 to 1000; the curve is flat well before 500 on most problems)
max_features (m_try)
Features considered per split, and the parameter that actually matters. Small values decorrelate harder at the cost of individual tree quality (sqrt(d) for classification, d/3 for regression, then tuned around that)
min_samples_leaf
Leaf-size floor. The main capacity control, since depth is deliberately left unbounded (1 for classification, 5 for regression)
bootstrap / max_samples
Whether to resample and how much. Turning it off gives Extra Trees territory, where randomness comes from split thresholds instead
class_weight
Rescales impurity per class; needed on imbalanced data, since a majority-pure leaf otherwise looks perfect to every tree
Nothing iterates toward an optimum, so the only convergence in play is statistical: the ensemble prediction converges as B grows, and adding trees cannot cause overfitting — a fact worth stating because it is genuinely unusual and often disbelieved. What can go wrong is elsewhere. The variance floor is real, so on a problem with one dominant feature the trees stay correlated and the forest barely improves on a single tree. Extremely imbalanced classes produce trees whose every leaf is majority-class, so the vote is unanimous and useless without weighting. And the model inherits every structural limit of its component: axis-aligned splits, and no ability to predict outside the range of the training targets.
Training: O(B · n · d_try · log n), fully parallel across trees with no communication. Memory holds all B trees, which for deep unpruned trees on a large dataset is often larger than the dataset itself — the usual surprise when a forest is deployed. Prediction: O(B · depth), so a 500-tree forest is roughly 500 times the cost of one tree, which is still only a few thousand comparisons and is trivially parallel.
How, where, and why it is used
Time-Series Forecasting
AdaptedThe same lagged supervised table a single tree would use, with the forest averaging away the instability that makes one tree unreliable on a short series. The out-of-bag estimate is genuinely useful here as a sanity signal, though it must never replace a rolling-origin backtest — bootstrap rows are drawn without regard to time, so OOB error on a time series is optimistic by construction.
- Many-series forecasting where per-series hand-tuning is impossible and robustness to default hyperparameters matters more than peak accuracy
- Demand and load forecasting with numerous correlated calendar, weather, and promotional drivers
- A strong tabular baseline that a deep forecaster must beat before its cost is justified
It is the least fussy strong model available: it needs no scaling, tolerates mixed types and missing values, has essentially one hyperparameter that matters, and rarely embarrasses itself at defaults. For a fleet of thousands of series that combination beats a better-but-fragile model outright. What it does not fix is the ceiling it inherits — a forest of piecewise-constant trees is still piecewise constant, so it cannot extrapolate a trend, and averaging does not change that. Detrend first, or forecast differences.
- Detrend or difference before fitting; averaging trees does nothing about their inability to leave the training target range
- Lags, rolling statistics, and calendar features, with no scaling required
- Raise max_features above the sqrt default when lag features are highly correlated, or most splits are offered near-duplicates
- Add exogenous drivers freely — the forest is unusually tolerant of irrelevant features, which is a real practical advantage here
Rolling-origin backtesting scored with MASE against seasonal-naive. Use out-of-bag error only as a fast internal signal while iterating, never as the reported number: bootstrap sampling ignores time and leaks the future into every tree.
- Reporting out-of-bag error on a time series, which is optimistic because the resampling has no notion of order
- Trend, producing forecasts pinned at the highest leaf mean the forest ever saw
- Reading impurity-based importances as driver importance when continuous lags are structurally favoured over categorical flags
- Memory: a few hundred deep trees over a long multi-series history is frequently larger than the data it was fitted on
Anomaly Detection
Not applicableEvery split is chosen to separate labels, so with no labels there is no criterion and with labels it is simply a rare-class classifier; isolating points by random splits is a different algorithm with a different objective, which is what the isolation forest entry covers.
Optimization
Not applicableNeither the fit nor the output is an optimization problem: the trees are grown by a greedy heuristic and then averaged, and the deliverable is a prediction rather than an allocation under constraints.
Causal Inference & Uplift
AdaptedNot the standard forest — a modification of it. A causal forest keeps the ensemble structure but changes what a split optimizes: instead of reducing outcome impurity, splits maximize the heterogeneity of the estimated treatment effect between children, so the partition is built to find where the effect differs rather than where the outcome does. Honest sample splitting on top of that — growing the tree on one half and estimating leaf effects on the other — is what makes the resulting intervals valid.
- Estimating heterogeneous treatment effects on experimental data, to find which subgroups an intervention actually helps
- Uplift modelling for targeted offers, where the deliverable is who to treat rather than who will convert
- The nuisance-function estimator inside doubly-robust and orthogonalized estimators
- Policy learning, where the estimated effect surface feeds a constrained targeting decision
A standard random forest is the wrong tool here and the reason is worth being precise about: an accurate predictor of the outcome is not an estimator of the effect, and splitting on outcome impurity systematically finds prognostic variables rather than effect-modifying ones. The causal variant fixes the criterion, and honest splitting fixes the inference. What remains true of both is the ensemble’s virtue — flexible, low-variance estimates without specifying an interaction structure in advance, which is what makes heterogeneity discoverable rather than hypothesized.
- Include the covariates the identification argument requires; adding predictive-but-irrelevant features costs precision on the effect
- Never include post-treatment variables, which bias the estimate rather than sharpen it
- Use honest sample splitting: grow the partition on one subsample and estimate leaf effects on another, or the intervals are anticonservative
- Keep the treatment out of the feature set — it enters the criterion, not the splits
Interval coverage under simulation with a known effect, and the Qini or uplift curve on held-out data. Predictive accuracy on the outcome is the wrong metric and will actively mislead — a model that predicts churn perfectly can be useless for choosing whom to save.
- Using a plain random forest and reading differences in predictions as effects, which conflates prognosis with response
- Skipping honest splitting, which produces intervals with no coverage because the partition was chosen using the same data that estimates it
- Interpreting the fitted partition as the true structure of effect heterogeneity when a different sample gives a different partition
Risk & Fraud
ViableA strong default scorer over mixed tabular intake data — categorical merchant and device fields alongside continuous amounts and velocity features — with the out-of-bag estimate giving a free performance read during iteration and permutation importance giving a defensible driver analysis.
- Fraud and abuse scoring where the feature set is wide, messy, and changes often
- Application and claims risk models on mixed-type intake forms
- The robust benchmark a boosted model is expected to beat before the extra tuning burden is accepted
It tolerates the conditions this domain actually has — mixed types, missing values, irrelevant features, no time to tune — and it is hard to break. Against it: gradient boosting generally wins on ranking metrics with tuning, the model is large and opaque compared with the extracted rules a policy team may want, and the impurity importances that get exported as explanations are biased toward high-cardinality identifiers, which is exactly the wrong direction here.
- No scaling required, which removes a whole class of production drift bug
- Reduce identifier cardinality, or high-cardinality fields dominate impurity importance without dominating the predictions
- Set class weights or balanced subsampling; without them every leaf is majority-legitimate and the vote is unanimous
- Use permutation or out-of-bag importance for any exported explanation, never impurity importance
PR-AUC and precision at a fixed alert budget on an out-of-time split. Out-of-bag error is fine for iteration and not for reporting, since it says nothing about temporal generalization.
- Exporting impurity importances as a driver analysis when they favour continuous and high-cardinality features by construction
- Class imbalance producing unanimous majority votes and a probability estimate with no dynamic range
- Model size: several hundred deep trees is a large artefact to ship, version, and load at every scoring node
What it costs to run
Linear in tree count and fully parallel, with no communication between trees — the best scaling story of anything in this section. A few hundred trees on a million rows is minutes on a multicore machine, and the fit can be split across machines by simply splitting the tree budget.
B tree walks, so a few thousand comparisons for a typical forest. Fast in absolute terms and hundreds of times slower than a single tree, which matters at extreme volume. Also parallel across trees, though at that granularity the overhead usually exceeds the work.
Cheap enough to refit on a schedule. Unlike a single tree, refits are stable in output even though the individual trees differ, which is a genuine operational advantage: the predictions move smoothly and there is no approved structure to re-approve.
- Track out-of-bag error over successive refits as a free internal signal — it needs no holdout and moves before production metrics do
- Monitor prediction distribution rather than tree structure; individual trees are not meaningful and comparing them across refits is noise
- Watch the share of predictions at the extreme ends of the range, which is where the inability to extrapolate shows up first
- Compare permutation importances between refits, since impurity importances are biased and their movement is uninformative
- Model size is the surprise: hundreds of unpruned trees frequently exceed the size of the training data, and load time at every scoring node is a real cost
- Predicted probabilities are vote fractions, not calibrated probabilities — they cluster away from 0 and 1 and need isotonic or Platt calibration before being thresholded on cost
- Impurity-based feature importance is biased toward continuous and high-cardinality features and should not be shipped as an explanation
- The forest cannot extrapolate: a target beyond the training range is predicted as the nearest leaf means, averaged, silently and for ever
- Determinism requires pinning both the tree seeds and the thread count in some implementations, or two runs on the same data differ
Pros and cons, in context
Strong accuracy at default settings, with essentially one hyperparameter that matters
The most reliable "just works" model in this section, which is decisive when there are thousands of models to fit and no time to tune each. Less compelling when a single model justifies careful tuning, where boosting overtakes it.
Adding trees never hurts
Unusual and genuinely useful: B is a compute budget rather than a bias-variance tradeoff, so there is no overfitting risk to reason about. The trees themselves are still grown deep on purpose, and that is where the capacity control lives.
Out-of-bag error is a free validation estimate
Roughly 37% of rows are out of each bootstrap, giving an honest holdout without splitting the data — valuable when data is scarce. It is invalid the moment rows are not exchangeable, which makes it wrong for time series.
Embarrassingly parallel, with no preprocessing requirements
Trees are independent, so training scales across cores and machines with no coordination, and nothing needs scaling or encoding. Removes an entire class of pipeline bug at the cost of a large model artefact.
Usually beaten by gradient boosting on tabular data
Bagging attacks variance and boosting attacks bias, and on most tabular problems bias is the binding constraint. The forest keeps the "no tuning, no surprises" territory, which is a real and narrower niche.
The model is large and genuinely opaque
Hundreds of deep trees is not an explanation, and the interpretability that motivated a single tree is gone entirely. Anything shipped as a reason has to come from permutation importance or a surrogate, both approximations.
Cannot extrapolate, and the boundary is still axis-aligned
Every structural limitation of a decision tree survives averaging. Disqualifying for trending series and wasteful where one linear term would capture the relationship exactly.
Vote fractions are not probabilities
They are systematically compressed toward the middle, so a 0.7 is not a 70% chance. Any cost-based threshold or expected-value calculation needs a calibration step that is routinely skipped.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Random forest - bootstrap, random feature subsets, average. Transcribed.
Two sources of randomness, and the second is the one that matters. The
bootstrap gives each tree a different sample; the per-node feature subset
forces trees to use different features, which is what drives the pairwise
correlation down and makes the averaging worth doing.
"""
import random
def gini(labels):
if not labels:
return 0.0
counts = {}
for label in labels:
counts[label] = counts.get(label, 0) + 1
impurity = 1.0
for count in counts.values():
proportion = count / len(labels)
impurity -= proportion * proportion
return impurity
def majority(labels):
counts = {}
for label in labels:
counts[label] = counts.get(label, 0) + 1
best_label, best_count = None, -1
for label, count in counts.items():
if count > best_count:
best_label, best_count = label, count
return best_label
def best_split(X, y, feature_subset):
"""Same criterion as CART, restricted to a random subset of features."""
n = len(X)
best = (None, None, gini(y))
for feature in feature_subset:
values = sorted({X[i][feature] for i in range(n)})
for position in range(len(values) - 1):
threshold = (values[position] + values[position + 1]) / 2.0
left, right = [], []
for i in range(n):
if X[i][feature] <= threshold:
left.append(y[i])
else:
right.append(y[i])
if not left or not right:
continue
weighted = len(left) / n * gini(left) + len(right) / n * gini(right)
if weighted < best[2]:
best = (feature, threshold, weighted)
return best
def build_tree(X, y, m_try, rng, depth=0, max_depth=20, min_samples_leaf=1):
if depth >= max_depth or len(set(y)) == 1 or len(y) < 2 * min_samples_leaf:
return {"leaf": True, "prediction": majority(y)}
# Redrawn at EVERY node, not once per tree: drawing once would just give a
# forest of trees each fitted on one fixed random subspace, which
# decorrelates far less.
feature_subset = rng.sample(range(len(X[0])), m_try)
feature, threshold, _ = best_split(X, y, feature_subset)
if feature is None:
return {"leaf": True, "prediction": majority(y)}
left_X, left_y, right_X, right_y = [], [], [], []
for i in range(len(X)):
if X[i][feature] <= threshold:
left_X.append(X[i])
left_y.append(y[i])
else:
right_X.append(X[i])
right_y.append(y[i])
return {
"leaf": False,
"feature": feature,
"threshold": threshold,
"left": build_tree(left_X, left_y, m_try, rng, depth + 1, max_depth, min_samples_leaf),
"right": build_tree(right_X, right_y, m_try, rng, depth + 1, max_depth, min_samples_leaf),
}
def fit(X, y, n_trees=100, m_try=None, seed=0):
n = len(X)
d = len(X[0])
m_try = m_try or max(1, int(math_sqrt(d)))
rng = random.Random(seed)
forest = []
out_of_bag = [] # rows NOT drawn for each tree - the free holdout
for _ in range(n_trees):
indices = [rng.randrange(n) for _ in range(n)]
drawn = set(indices)
# About 37% of rows fall outside any given bootstrap sample.
out_of_bag.append([i for i in range(n) if i not in drawn])
sample_X = [X[i] for i in indices]
sample_y = [y[i] for i in indices]
forest.append(build_tree(sample_X, sample_y, m_try, rng))
return forest, out_of_bag
def math_sqrt(value):
return value ** 0.5
def predict_one(forest, x):
votes = {}
for tree in forest:
node = tree
while not node["leaf"]:
node = node["left"] if x[node["feature"]] <= node["threshold"] else node["right"]
votes[node["prediction"]] = votes.get(node["prediction"], 0) + 1
best_label, best_count = None, -1
for label, count in votes.items():
if count > best_count:
best_label, best_count = label, count
return best_labelO(B * n^2 * d_try) — every tree repeats the exhaustive split scan, and every recursion copies the rows it partitions.
How this language actually wants it written.
"""Random forest - typed, index-partitioned, with an out-of-bag estimate."""
from dataclasses import dataclass, field
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float64]
Labels = NDArray[np.int64]
@dataclass(frozen=True)
class TreeNode:
prediction: int
feature: int = -1
threshold: float = 0.0
left: int = -1
right: int = -1
@property
def is_leaf(self) -> bool:
return self.feature < 0
@dataclass
class Tree:
"""Nodes in one flat list; children are indices, so prediction is a walk."""
nodes: list[TreeNode] = field(default_factory=list)
def predict(self, X: Matrix) -> Labels:
out = np.empty(X.shape[0], dtype=np.int64)
for row_index, row in enumerate(X):
cursor = 0
while not self.nodes[cursor].is_leaf:
node = self.nodes[cursor]
cursor = node.left if row[node.feature] <= node.threshold else node.right
out[row_index] = self.nodes[cursor].prediction
return out
def _grow(
X: Matrix,
y: Labels,
rows: NDArray,
tree: Tree,
n_classes: int,
m_try: int,
rng: np.random.Generator,
depth: int,
max_depth: int,
min_samples_leaf: int,
) -> int:
counts = np.bincount(y[rows], minlength=n_classes)
index = len(tree.nodes)
tree.nodes.append(TreeNode(prediction=int(counts.argmax())))
if depth >= max_depth or counts.max() == rows.size or rows.size < 2 * min_samples_leaf:
return index
# Redrawn at every node. Drawing once per tree would give a forest of
# fixed random subspaces, which decorrelates far less.
subset = rng.choice(X.shape[1], size=m_try, replace=False)
best_impurity = 1.0 - float(((counts / rows.size) ** 2).sum())
best: tuple[int, float] | None = None
for feature in subset:
order = rows[np.argsort(X[rows, feature], kind="stable")]
values = X[order, feature]
left_counts = np.zeros(n_classes, dtype=np.int64)
right_counts = counts.copy()
for position in range(rows.size - 1):
label = y[order[position]]
left_counts[label] += 1
right_counts[label] -= 1
if values[position] == values[position + 1]:
continue
n_left, n_right = position + 1, rows.size - position - 1
if n_left < min_samples_leaf or n_right < min_samples_leaf:
continue
weighted = (
n_left * (1.0 - ((left_counts / n_left) ** 2).sum())
+ n_right * (1.0 - ((right_counts / n_right) ** 2).sum())
) / rows.size
if weighted < best_impurity:
best_impurity = weighted
best = (int(feature), float((values[position] + values[position + 1]) / 2.0))
if best is None:
return index
feature, threshold = best
mask = X[rows, feature] <= threshold
left = _grow(X, y, rows[mask], tree, n_classes, m_try, rng, depth + 1, max_depth, min_samples_leaf)
right = _grow(X, y, rows[~mask], tree, n_classes, m_try, rng, depth + 1, max_depth, min_samples_leaf)
tree.nodes[index] = TreeNode(
prediction=tree.nodes[index].prediction,
feature=feature,
threshold=threshold,
left=left,
right=right,
)
return index
@dataclass(frozen=True)
class RandomForest:
trees: list[Tree]
n_classes: int
oob_score: float
def predict(self, X: Matrix) -> Labels:
votes = np.zeros((X.shape[0], self.n_classes), dtype=np.int64)
for tree in self.trees:
votes[np.arange(X.shape[0]), tree.predict(X)] += 1
return votes.argmax(axis=1)
def fit(
X: Matrix,
y: Labels,
n_trees: int = 100,
m_try: int | None = None,
max_depth: int = 20,
min_samples_leaf: int = 1,
seed: int = 0,
) -> RandomForest:
"""Grow a forest. Raises ValueError on malformed input."""
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
if n_trees < 1:
raise ValueError(f"n_trees must be at least 1, got {n_trees}")
n, d = X.shape
n_classes = int(y.max()) + 1
features_per_split = m_try or max(1, int(np.sqrt(d)))
rng = np.random.default_rng(seed)
trees: list[Tree] = []
# Out-of-bag votes: about 37% of rows sit outside each bootstrap sample,
# which gives an honest holdout estimate without splitting the data.
oob_votes = np.zeros((n, n_classes), dtype=np.int64)
for _ in range(n_trees):
indices = rng.integers(0, n, size=n)
tree = Tree()
_grow(X, y, indices, tree, n_classes, features_per_split, rng, 0, max_depth, min_samples_leaf)
trees.append(tree)
oob_mask = np.ones(n, dtype=bool)
oob_mask[indices] = False
if oob_mask.any():
oob_votes[np.flatnonzero(oob_mask), tree.predict(X[oob_mask])] += 1
scored = oob_votes.sum(axis=1) > 0
oob_score = float((oob_votes[scored].argmax(axis=1) == y[scored]).mean()) if scored.any() else 0.0
return RandomForest(trees=trees, n_classes=n_classes, oob_score=oob_score)What changed: Three changes. Rows are partitioned by index arrays rather than by copying the data, so growing B deep trees no longer copies the dataset once per level per tree — the largest hidden cost in the previous stage. Split finding sorts each candidate feature once and sweeps incrementally, replacing the exhaustive rescan. And the out-of-bag estimate becomes a first-class output rather than a list of indices nobody used: the roughly 37% of rows outside each bootstrap are voted on by exactly the trees that never saw them, which is an honest holdout obtained without splitting the data. Nodes move to a flat list with index children, so a tree is one object rather than a graph of dicts.
O(B * n * d_try * log n), with index partitioning and no row copies.
Faster, and the justification for each change.
"""Random forest - shared binned features, flat trees, batched voting."""
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float64]
N_BINS = 256
class BinnedForest:
"""The two changes that matter once B is large.
First: the design matrix is quantized into 256 bins ONCE, before any tree
exists, and every tree reads the same uint8 matrix. Binning costs one pass
and is amortized across all B trees, which is where it genuinely pays -
for a single tree it is close to a wash.
Second: trees are stored as parallel flat arrays rather than objects, so
prediction over a batch is an index walk through contiguous int32 arrays
with no attribute lookup per node.
Splits are chosen from bin edges rather than every midpoint, so they are
approximate. That is the standing trade of histogram trees; averaging over
a forest tends to absorb it, which is why production forests bin.
"""
def __init__(self, X: Matrix, n_bins: int = N_BINS) -> None:
design = np.ascontiguousarray(X, dtype=np.float64)
self._edges = np.stack([
np.quantile(design[:, feature], np.linspace(0.0, 1.0, n_bins + 1)[1:-1])
for feature in range(design.shape[1])
])
# Fortran order: split finding walks one feature at a time, so the
# feature must be the contiguous axis. uint8 is 8x smaller than the
# float64 it replaces, so far more stays in cache.
self._binned = np.empty(design.shape, dtype=np.uint8, order="F")
for feature in range(design.shape[1]):
self._binned[:, feature] = np.searchsorted(
self._edges[feature], design[:, feature], side="left"
)
# Trees as parallel flat arrays, appended to as the forest grows.
self._feature: list[NDArray] = []
self._bin: list[NDArray] = []
self._left: list[NDArray] = []
self._right: list[NDArray] = []
self._value: list[NDArray] = []
def bin_queries(self, X: Matrix) -> NDArray:
"""Queries must be binned with the SAME edges the forest was fitted on."""
out = np.empty(X.shape, dtype=np.uint8, order="F")
for feature in range(X.shape[1]):
out[:, feature] = np.searchsorted(self._edges[feature], X[:, feature], side="left")
return out
def predict(self, X: Matrix, n_classes: int) -> NDArray:
"""Level-synchronous traversal: every row of the batch moves together."""
binned = self.bin_queries(X)
n = binned.shape[0]
votes = np.zeros((n, n_classes), dtype=np.int32) # allocated once
cursor = np.empty(n, dtype=np.int32)
rows = np.arange(n)
for tree in range(len(self._feature)):
feature = self._feature[tree]
split_bin = self._bin[tree]
left = self._left[tree]
right = self._right[tree]
value = self._value[tree]
cursor.fill(0)
active = rows
# One iteration per level, not one per row: all rows still at an
# internal node advance together, so the interpreter runs O(depth)
# times instead of O(n * depth).
while active.size:
nodes = cursor[active]
internal = feature[nodes] >= 0
if not internal.any():
break
moving = active[internal]
at = cursor[moving]
goes_left = binned[moving, feature[at]] <= split_bin[at]
np.copyto(cursor[moving], np.where(goes_left, left[at], right[at]))
active = moving
np.add.at(votes, (rows, value[cursor]), 1)
return votes.argmax(axis=1)What changed: Two structural changes, both of which only pay because B is large. The design matrix is quantized once into a uint8 matrix that every tree shares — binning costs one pass and is amortized across all B trees, so what is a wash for a single tree is decisive for a forest, and the eightfold size reduction keeps far more of it in cache. Trees are then stored as parallel flat int32 arrays rather than node objects, and prediction becomes level-synchronous: every row still sitting at an internal node advances together, so the interpreter runs once per level rather than once per row per level. The approximation from binning is stated rather than hidden — splits come from bin edges, and averaging over the forest is what absorbs it.
Ensure contiguous memory layout and a single dtype
One shared Fortran-ordered uint8 matrix serves every tree, so a feature column is contiguous during split finding and eight times more of it is cache-resident than the float64 original.
Trade-off: Costs a full extra copy of the design at construction, caps the design at 256 bins by dtype, and makes the split approximate — the query path must use the identical edges or every prediction is silently wrong.
Eliminate Python-level loops over samples
Level-synchronous traversal advances the whole batch one level at a time, so the interpreter cost is O(B * depth) rather than O(B * n * depth).
Trade-off: Every row pays for the deepest path in the tree rather than its own, so a badly unbalanced tree wastes work on rows that finished early — and the traversal is far less readable than a plain per-row walk.
Pre-allocate output arrays and use in-place operations
The vote accumulator and the cursor array are allocated once for the whole batch and reused across all B trees instead of per tree.
Trade-off: Both are mutable state shared across the tree loop, so the method is not reentrant and two threads scoring through one forest object would corrupt each other.
O(B * depth) interpreter steps for a whole batch, with binning amortized across all trees. Illustrative, not a measured benchmark.