Back to Classical ML
AI/ML · Classical ML

Decision Tree (CART)

Ask a sequence of yes/no questions about one feature at a time, and after each answer ask the next question of whichever group you landed in. Each question is chosen greedily: try every feature and every threshold, and keep whichever split makes the two resulting groups most internally homogeneous. Recurse until the groups are small or pure, then predict the majority class or the mean of each. The result is a partition of the feature space into axis-aligned boxes with a constant prediction inside each — which explains both its strengths and its ceiling, since no amount of depth makes a box into a diagonal.

SupervisedClassificationRegression

One algorithm, two impurity functions: Gini or entropy for classification, squared error for regression. Nothing else about the recursion changes, which is why CART covers both and why the regression variant predicts a leaf mean rather than a fitted line.

Also known as: CART, Classification and Regression Tree, C4.5, ID3, Recursive partitioning

Intuition

What it actually does

Ask a sequence of yes/no questions about one feature at a time, and after each answer ask the next question of whichever group you landed in. Each question is chosen greedily: try every feature and every threshold, and keep whichever split makes the two resulting groups most internally homogeneous. Recurse until the groups are small or pure, then predict the majority class or the mean of each. The result is a partition of the feature space into axis-aligned boxes with a constant prediction inside each — which explains both its strengths and its ceiling, since no amount of depth makes a box into a diagonal.

Assumptions it makes
  • The decision boundary is well approximated by axis-aligned splits — a diagonal boundary is representable only as a staircase, and needs depth to buy what one linear term would give
  • The target is well approximated as piecewise constant, which is why the model cannot extrapolate beyond the range of the training targets
  • Enough samples reach each leaf for its constant to mean something; a leaf of three points is a memorized triple
  • Features are informative individually or in sequence — a tree finds interactions only by splitting on one feature after another, so a pure XOR needs depth to represent what it cannot see in one split
Objective

Loss function

Of every question you could ask about one feature, pick the one that leaves the two resulting groups as internally uniform as possible, weighted by how many points land in each. That is the entire training rule, applied recursively. Two things worth noticing: the objective is local, so nothing here optimizes the tree as a whole, and Gini and entropy almost always choose the same split — the choice between them is far less consequential than the depth limit applied afterwards.

the feature and threshold being chosen — the split is always on one feature at a time, which is what makes it axis-aligned
the two child subsets produced by the split
impurity: Gini or entropy for classification, mean squared error for regression
size weighting, which is what stops the search preferring a split that isolates a single point
Optimization

How it is minimized

Greedy recursive partitioning, followed by cost-complexity pruning

the pruning objective: total leaf impurity plus a price per leaf
complexity cost. Zero grows the tree until the leaves are pure; large alpha prunes back to the root
the number of leaves, standing in for model complexity
one leaf, whose prediction is the majority class or the mean of the points that reach it

The split search is greedy because the alternative is intractable: finding the globally optimal tree is NP-hard, so every practical implementation takes the locally best split and never revisits it. That has a real consequence — a split that looks poor now can enable an excellent pair of splits below it, and greedy search will never find it. Pruning is the partial remedy. Grow the tree past the point of usefulness, then use alpha to walk back a nested sequence of subtrees and pick one by cross-validation. Growing-then-pruning beats stopping early, because a stopping rule has to judge a split before seeing what it enables, which is the same short-sightedness one level up.

Hyperparameters
  • max_depth

    The blunt capacity limit and usually the most consequential setting. Beyond about depth 5 the interpretability argument for a single tree has already evaporated (3 to 10 for an interpretable tree; unbounded only inside an ensemble)

  • min_samples_leaf

    Floor on leaf size. The most reliable single guard against overfitting, because it directly bounds how confidently a leaf can speak (1 to 5% of the training set)

  • ccp_alpha

    Cost-complexity pruning strength, chosen by cross-validation over the nested subtree sequence

  • criterion

    Gini, entropy, or log-loss for classification; squared or absolute error for regression. Gini and entropy agree on the split the overwhelming majority of the time

  • max_features

    How many features to consider per split. Meaningless for a single tree and essential inside a random forest, where it is the decorrelating mechanism

  • class_weight

    Rescales impurity contributions per class; the standard handling for imbalance, since a pure-majority leaf otherwise looks perfect

Convergence & cost

The recursion terminates — every split strictly reduces the node size — so there is no convergence question, only a question of what was built. The characteristic failure is instability: a decision tree is a high-variance estimator, and changing a handful of training rows can flip an early split and produce a structurally different tree with similar accuracy. That is not a tuning problem, it is the nature of a greedy recursive partition, and it is exactly why bagging exists. The second failure is overfitting by construction — an unpruned tree grows until every leaf is pure, which means zero training error and a memorized training set. The third is quieter: impurity-based feature importances are systematically biased toward continuous and high-cardinality features, because those offer more candidate thresholds and therefore more chances to reduce impurity by luck.

Training: O(n·d·log n) with pre-sorted features, or O(n·d) per level using histogram binning, times O(log n) levels for a balanced tree. Memory O(n·d) for the sorted indices or O(d·bins) for the histograms. Prediction: O(depth) — a handful of comparisons, with no arithmetic at all — which makes trees among the cheapest models to serve of anything in this section.

Applications

How, where, and why it is used

Time-Series Forecasting

Adapted
How

Reshape the series into a supervised table of lags, rolling statistics, and calendar features, then fit a tree on it. The tree finds thresholds — "when the 7-day mean is above 400 and it is a weekday" — which is a genuinely different hypothesis class from a linear model on the same features, and it captures interactions and regime changes without being told they exist.

Where
  • Interpretable regime detection, where the fitted thresholds are themselves the finding
  • Short-horizon forecasting on series with strong calendar and event effects that interact
  • The single-tree baseline that motivates a forecasting ensemble, since its variance makes the case for bagging concretely
Why

It handles interactions, mixed feature types, and missing values with no preprocessing, and it needs no scaling — which is a real advantage over every model authored so far. But the limitation is disqualifying for many forecasting problems and needs stating plainly: a tree predicts the mean of a leaf, so it is piecewise constant and cannot extrapolate. On a trending series, every future value falls outside the range of the training targets, and the forecast is pinned to the highest leaf mean the tree ever saw. Detrend first, or forecast the difference, or use a linear model — this is not something a deeper tree fixes.

Featurization
  • Detrend or difference before fitting, since the tree cannot predict a value outside the training target range
  • Lags, rolling statistics, and calendar flags — no scaling required, which removes an entire class of leakage bug
  • Encode seasonality as explicit features (day of week, week of year) rather than expecting the tree to discover periodicity, which it cannot represent
  • Let the tree find interactions rather than pre-building them; that is the one thing it does that a linear model does not
Evaluation

Rolling-origin backtesting scored with MASE against seasonal-naive. Refit on several adjacent windows and compare the resulting trees — if the top splits change between windows, the structure is noise, and any interpretation of it is fiction.

Pitfalls
  • Trend, which produces a forecast that flattens at the highest leaf mean and stays there for ever
  • Reading impurity-based feature importances as driver importance when continuous lags are structurally favoured over categorical flags
  • Treating one fitted tree as a stable description of the process, when a different sample would have produced a different tree

Anomaly Detection

Not applicable

A decision tree partitions by label, so with unlabelled data it has no objective at all, and with labelled data it just becomes a rare-class classifier. Splitting on isolation depth rather than on impurity is a different algorithm with a different criterion, which is what the isolation forest entry covers.

Optimization

Not applicable

The name is a false friend: a decision tree produces a prediction, not an allocation under constraints, and its own fitting is a greedy heuristic rather than a solver worth studying — finding the optimal tree is NP-hard and nothing here attempts it.

Also applied in

Risk & Fraud

Viable
How

Two distinct roles. As a rule generator: fit a shallow tree and read the root-to-leaf paths off as literal decision rules, which a policy team can implement in a rules engine without deploying a model at all. And as a segmentation step, splitting a portfolio into risk bands whose boundaries are explicit thresholds a committee can approve or override.

Where
  • Extracting candidate decline and review rules from historical outcomes
  • Risk-band segmentation where the band boundaries must be stated, defended, and occasionally overridden by hand
  • Root-cause exploration on a spike in losses, where the question is which combination of attributes the losses share
Why

The path to a leaf is a conjunction of thresholds, which is exactly the form a policy rule takes — so unlike every other model here, the deliverable can be the rules rather than a scoring service. That matters in a domain where a human must be able to defend a decline. The reasons to stop there are equally clear: a single tree is unstable, so the extracted rules change on refit, and its accuracy is well below the boosted ensemble it is usually competing against. It is a tool for finding and stating rules, not for scoring at volume.

Featurization
  • No scaling and no encoding of ordinals — a genuine advantage on messy intake data with mixed types
  • Constrain depth hard, since a rule nobody can read is not a rule
  • Use permutation importance rather than impurity importance, which is biased toward high-cardinality identifiers exactly where those are least useful as policy
  • Set class weights or a minimum leaf size, or the tree will find a pure-majority leaf and declare victory on an imbalanced problem
Evaluation

Precision and volume per extracted rule at the operating point, on an out-of-time split — a rule is judged by what it catches and what it costs, not by the tree’s aggregate accuracy. Test stability by refitting on bootstrap samples and measuring how often the same rules appear.

Pitfalls
  • Rules extracted from an unstable tree that do not reproduce on the next refit
  • Splitting on a leaked or post-outcome field, which a tree will find immediately and exploit completely
  • Very deep trees marketed as interpretable; past a few levels the path to a leaf is no longer an explanation anyone reads
Deployment

What it costs to run

Training

Cheap and predictable: O(n·d·log n) with sorted features, seconds on a million rows. The cost is dominated by the split search, which is why histogram binning — the single most important implementation choice — is what every production implementation uses.

Inference

The cheapest of anything in this section. A prediction is a walk down the tree: a handful of comparisons, no arithmetic, no matrix, and no scaling step. Trees export to SQL, to a switch statement, or to a rules engine, which is often the real deployment target.

Retraining

Cheap enough to refit whenever the data changes. The constraint is not compute but stability: each refit can produce a structurally different tree, so anything keyed to the fitted rules must be re-approved rather than silently swapped.

Drift & monitoring
  • Track the top splits across refits — a changed root split means the model a stakeholder approved no longer exists, even if accuracy is unchanged
  • Monitor the sample distribution across leaves; a leaf whose traffic share moves sharply is drift arriving at a specific, nameable place
  • Watch for leaves receiving traffic they never saw in training, where the prediction is a constant fitted on a handful of historical rows
  • Compare permutation importances between refits rather than impurity importances, which are biased and therefore uninformative about change
Production gotchas
  • The tree cannot extrapolate: any target outside the training range is predicted as the nearest leaf mean, and it will do this silently and for ever
  • Missing-value handling is a model decision — surrogate splits or a default direction — and it must be identical at training and inference, or rows take different paths
  • A new categorical level has no branch; the fallback direction must be explicit, not whatever the encoder produces by default
  • Impurity-based feature importance is biased toward continuous and high-cardinality features and should not be exported as a driver analysis
  • Prediction depends on strict-versus-non-strict comparison at the threshold, so a value exactly on a boundary can flip between implementations — this actually happens when a model is ported to SQL
Trade-offs

Pros and cons, in context

Strengths
  • Requires no scaling, no encoding, and tolerates mixed types and missing values

    Genuinely the least demanding model here on data preparation, which removes a whole class of production bug — a persisted scaler that drifts. Worth less when the pipeline is already built for a linear model.

  • A shallow tree is readable as literal rules

    The path to a leaf is a conjunction of thresholds, which is the form a policy rule already takes. This is real interpretability rather than an approximation of the model — and it evaporates entirely past a few levels of depth.

  • Finds interactions without being told they exist

    Splitting on one feature and then another is an interaction, discovered rather than specified. That is exactly what a linear model cannot do, and it is why trees dominate tabular problems where interactions carry the signal.

  • Prediction is a handful of comparisons

    No arithmetic, no preprocessing, trivially portable to SQL or a rules engine. Decisive at extreme volume or on constrained hardware; irrelevant when a millisecond of inference is free.

Limitations
  • High variance — a small change in the data produces a different tree

    The defining weakness, and the reason bagging and boosting exist. It also undercuts the interpretability advantage: a structure that changes with the sample is not a description of the world, and treating one fitted tree as a finding is the most common misuse.

  • Overfits by construction unless constrained

    Grown unchecked it reaches zero training error by memorizing. Depth limits, leaf-size floors, and pruning are not refinements here; without them the model is a lookup table.

  • Cannot extrapolate, and boundaries are axis-aligned

    A piecewise-constant model pinned to the range of its training targets, approximating a diagonal boundary with a staircase. Disqualifying for trending series and for smooth relationships a single linear term would capture exactly.

  • A single tree is beaten by its own ensembles, decisively

    Random forests and gradient boosting are strictly better predictors built from this exact component. The single tree survives where the structure itself is the deliverable — rule extraction, segmentation, explanation — and essentially nowhere else.

Implementation

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.

1. Intuitive

The math, transcribed literally. No dependencies.

"""CART classification tree - the split criterion, transcribed.

For every feature and every candidate threshold, split, measure the weighted
impurity of the two children, and keep the best. That exhaustive scan is the
objective above, written out literally: nothing is cached, nothing is sorted,
and every candidate recomputes both children from scratch.
"""


def gini(labels):
    """H(S) = 1 - sum_c p_c^2"""
    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 = None
    best_count = -1
    for label, count in counts.items():
        if count > best_count:
            best_label = label
            best_count = count
    return best_label


def best_split(X, y):
    """Exhaustive search over every feature and every midpoint."""
    n = len(X)
    d = len(X[0])
    best = (None, None, gini(y))

    for feature in range(d):
        values = sorted({X[i][feature] for i in range(n)})

        # Candidate thresholds are midpoints between adjacent distinct values.
        for position in range(len(values) - 1):
            threshold = (values[position] + values[position + 1]) / 2.0

            left_labels = []
            right_labels = []
            for i in range(n):
                if X[i][feature] <= threshold:
                    left_labels.append(y[i])
                else:
                    right_labels.append(y[i])

            if not left_labels or not right_labels:
                continue

            # Weighted by child size, which is what stops the search from
            # preferring a split that isolates a single point.
            weighted = (
                len(left_labels) / n * gini(left_labels)
                + len(right_labels) / n * gini(right_labels)
            )

            if weighted < best[2]:
                best = (feature, threshold, weighted)

    return best


def build(X, y, depth=0, max_depth=5, 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)}

    feature, threshold, _ = best_split(X, y)
    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(left_X, left_y, depth + 1, max_depth, min_samples_leaf),
        "right": build(right_X, right_y, depth + 1, max_depth, min_samples_leaf),
    }


def predict_one(node, x):
    while not node["leaf"]:
        node = node["left"] if x[node["feature"]] <= node["threshold"] else node["right"]
    return node["prediction"]

O(n^2 * d) per node — every candidate threshold rebuilds both child label lists and recounts them from scratch.

2. IdiomaticNumPy

How this language actually wants it written.

"""CART - typed, presorted, incremental class counts."""

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]


@dataclass(frozen=True)
class Split:
    feature: int
    threshold: float
    impurity: float


@dataclass(frozen=True)
class Node:
    """A leaf carries a prediction; an internal node carries a split.

    Modelled as one frozen type with optional children rather than a dict,
    so a malformed node is a construction error rather than a KeyError during
    prediction.
    """

    prediction: int
    split: Split | None = None
    left: "Node | None" = None
    right: "Node | None" = None

    @property
    def is_leaf(self) -> bool:
        return self.split is None


def _best_split(X: Matrix, y: NDArray, n_classes: int, min_samples_leaf: int) -> Split | None:
    """Scan each feature in sorted order, moving one point at a time.

    The key change: sorting the feature once means the class counts for the two
    children can be updated incrementally as the boundary sweeps right, instead
    of being recomputed for every candidate threshold.
    """
    n, d = X.shape
    parent_counts = np.bincount(y, minlength=n_classes)
    best: Split | None = None
    best_impurity = 1.0 - float((parent_counts / n) ** 2 @ np.ones(n_classes))

    for feature in range(d):
        order = np.argsort(X[:, feature], kind="stable")
        values = X[order, feature]
        labels = y[order]

        left_counts = np.zeros(n_classes, dtype=np.int64)
        right_counts = parent_counts.copy()

        for position in range(n - 1):
            label = labels[position]
            left_counts[label] += 1
            right_counts[label] -= 1

            # Skip boundaries inside a run of equal values: they are not
            # separable, and splitting there produces two identical children.
            if values[position] == values[position + 1]:
                continue

            n_left = position + 1
            n_right = n - n_left
            if n_left < min_samples_leaf or n_right < min_samples_leaf:
                continue

            gini_left = 1.0 - float(((left_counts / n_left) ** 2).sum())
            gini_right = 1.0 - float(((right_counts / n_right) ** 2).sum())
            weighted = (n_left * gini_left + n_right * gini_right) / n

            if weighted < best_impurity:
                best_impurity = weighted
                best = Split(
                    feature=feature,
                    threshold=float((values[position] + values[position + 1]) / 2.0),
                    impurity=weighted,
                )

    return best


def build(
    X: Matrix,
    y: NDArray,
    n_classes: int,
    depth: int = 0,
    max_depth: int = 5,
    min_samples_leaf: int = 1,
) -> Node:
    """Grow a tree. 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 min_samples_leaf < 1:
        raise ValueError(f"min_samples_leaf must be at least 1, got {min_samples_leaf}")

    prediction = int(np.bincount(y, minlength=n_classes).argmax())

    if depth >= max_depth or np.unique(y).size == 1 or y.size < 2 * min_samples_leaf:
        return Node(prediction=prediction)

    split = _best_split(X, y, n_classes, min_samples_leaf)
    if split is None:
        return Node(prediction=prediction)

    mask = X[:, split.feature] <= split.threshold
    return Node(
        prediction=prediction,
        split=split,
        left=build(X[mask], y[mask], n_classes, depth + 1, max_depth, min_samples_leaf),
        right=build(X[~mask], y[~mask], n_classes, depth + 1, max_depth, min_samples_leaf),
    )


def predict(node: Node, X: Matrix) -> NDArray:
    out = np.empty(X.shape[0], dtype=np.int64)
    for index, row in enumerate(X):
        current = node
        while not current.is_leaf:
            assert current.split is not None
            current = (
                current.left if row[current.split.feature] <= current.split.threshold
                else current.right
            )
            if current is None:
                raise ValueError("malformed tree: internal node with a missing child")
        out[index] = current.prediction
    return out

What changed: The algorithmic change dominates: sorting each feature once and sweeping the split boundary left to right lets the child class counts be updated one point at a time instead of recomputed per candidate, which turns an O(n^2) scan per node into O(n log n). Two correctness details come with it — boundaries inside a run of equal values are skipped, because splitting there yields two children that are not actually separable, and the leaf-size floor is enforced during the scan rather than after. Structurally the dict-based node becomes a frozen dataclass whose leaf/internal distinction is a property rather than a key, so a malformed tree fails at construction instead of as a KeyError halfway through prediction.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(n * d * log n) per node for the sorts, plus an O(n) incremental sweep per feature.

3. OptimizedNumPy

Faster, and the justification for each change.

"""CART - histogram binning, one pass per feature, preallocated buffers."""

import numpy as np
from numpy.typing import NDArray

Matrix = NDArray[np.float64]


class HistogramTreeBuilder:
    """Split finding by binned histograms - what every production tree does.

    Features are quantized ONCE into at most 256 bins, before any tree is
    grown. Finding a split then means accumulating a per-bin class histogram in
    one linear pass and scanning 255 candidate boundaries, instead of sorting
    and sweeping n values. The per-node cost stops depending on n except through
    the single accumulation pass.

    The trade is exact for what it is: splits are now chosen from bin edges
    rather than from every midpoint, so the fitted thresholds are approximate.
    On real data the accuracy cost is tiny and the speedup is large, which is
    why this is the default - but on a feature whose signal lives in a narrow
    range, the binning can quantize the split away entirely.
    """

    N_BINS = 256

    def __init__(self, X: Matrix, y: NDArray, n_classes: int) -> None:
        self._n_classes = n_classes
        self._y = np.ascontiguousarray(y, dtype=np.int64)

        # Quantile edges, computed once for the whole tree - and reusable
        # across every tree in an ensemble, which is where this really pays.
        design = np.ascontiguousarray(X, dtype=np.float64)
        self._edges = np.stack([
            np.quantile(design[:, feature], np.linspace(0, 1, self.N_BINS + 1)[1:-1])
            for feature in range(design.shape[1])
        ])

        # uint8 binned matrix: 8x smaller than float64, so far more of it stays
        # in cache during the accumulation pass.
        self._binned = np.empty(design.shape, dtype=np.uint8, order="F")
        for feature in range(design.shape[1]):
            np.searchsorted(
                self._edges[feature], design[:, feature], side="left",
            ).astype(np.uint8, copy=False, casting="unsafe")
            self._binned[:, feature] = np.searchsorted(
                self._edges[feature], design[:, feature], side="left"
            )

        # Histogram buffer, allocated once and reused at every node.
        self._histogram = np.empty((self.N_BINS, n_classes), dtype=np.int64)

    def best_split(self, indices: NDArray, min_samples_leaf: int) -> tuple[int, int, float]:
        """Returns (feature, bin, impurity) for the best split of this node."""
        n = indices.size
        labels = self._y[indices]
        best = (-1, -1, np.inf)

        for feature in range(self._binned.shape[1]):
            # One pass over the node's rows builds the whole histogram: bincount
            # over the flattened (bin, class) index, no Python-level loop.
            bins = self._binned[indices, feature].astype(np.int64, copy=False)
            flat = bins * self._n_classes + labels
            counts = np.bincount(flat, minlength=self.N_BINS * self._n_classes)
            self._histogram[:] = counts.reshape(self.N_BINS, self._n_classes)

            # Prefix sums turn "counts per bin" into "counts left of each
            # boundary" for all 255 candidates at once.
            left = np.cumsum(self._histogram, axis=0)
            total = left[-1]
            right = total - left

            n_left = left.sum(axis=1)
            n_right = n - n_left
            valid = (n_left >= min_samples_leaf) & (n_right >= min_samples_leaf)
            if not valid.any():
                continue

            with np.errstate(invalid="ignore", divide="ignore"):
                gini_left = 1.0 - ((left / n_left[:, None]) ** 2).sum(axis=1)
                gini_right = 1.0 - ((right / n_right[:, None]) ** 2).sum(axis=1)
                weighted = (n_left * gini_left + n_right * gini_right) / n

            weighted[~valid] = np.inf
            candidate = int(weighted.argmin())
            if weighted[candidate] < best[2]:
                best = (feature, candidate, float(weighted[candidate]))

        return best

What changed: Split finding stops being a sort-and-sweep and becomes a histogram scan. Features are quantized once, before any tree exists, into at most 256 bins held as uint8 — eight times smaller than float64, so far more of the matrix stays in cache. Finding a split is then one accumulation pass over the node’s rows followed by a prefix sum that evaluates all 255 candidate boundaries simultaneously, so the per-node cost no longer depends on sorting. The honest cost is stated in the docstring: thresholds now come from bin edges rather than from every midpoint, which makes the fitted split approximate, and on a feature whose signal sits inside one bin the binning removes it entirely.

  • Eliminate Python-level loops over samples

    The per-bin class histogram is one bincount over a flattened (bin, class) index, and the prefix sum evaluates every candidate boundary at once instead of one loop iteration per threshold.

    Trade-off: Candidate thresholds are restricted to bin edges, so the chosen split is approximate — a real accuracy cost that is usually negligible and occasionally decisive on a narrow-range feature.

  • Ensure contiguous memory layout and a single dtype

    The binned matrix is uint8 in Fortran order, so a feature column is contiguous during the accumulation pass and eight times more of it fits in cache than the float64 original.

    Trade-off: Costs a full extra copy of the design matrix at construction, and the uint8 dtype hard-caps the design at 256 bins — raising the bin count means changing the dtype and the memory story with it.

  • Pre-allocate output arrays and use in-place operations

    The histogram buffer is allocated once and rewritten at every node, so growing a deep tree does not allocate a fresh (bins, classes) array per node per feature.

    Trade-off: The buffer is shared mutable state on the builder, which makes the object non-reentrant — two threads growing nodes from one builder would corrupt each other silently.

O(n) accumulation plus O(bins * classes) scan per feature per node. Illustrative, not a measured benchmark.