Decision Trees & CART Algorithm
Master the information theory of Shannon entropy, Gini impurity derivation, recursive binary space partitioning, cost-complexity pruning, and zero-dependency implementations in Python and Rust.
Decision Trees partition complex feature spaces into a hierarchy of disjoint, axis-aligned hyper-rectangles. Unlike linear models that enforce global geometric boundaries, decision trees employ a greedy, non-parametric divide-and-conquer strategy that yields highly interpretable, piecewise-constant prediction regions.
Measures the expected error rate of random classification under the node label distribution.
1. Information-Theoretic Splitting Criteria#
At each node, the decision tree algorithm selects a feature and split threshold that maximizes the purity of the resulting child subsets.
Shannon Entropy and Information Gain#
In Information Theory, the "surprise" or self-information of observing an event with probability is:
The Shannon Entropy is the expected surprise over all classes:
- If a node is completely pure (, for ), .
- If classes are uniformly distributed (), reaches maximum uncertainty.
The Information Gain (IG) measures the reduction in entropy achieved by partitioning dataset into subsets :
2. Derivation of Gini Impurity from First Principles#
In the Classification and Regression Trees (CART) algorithm, Gini Impurity serves as a computationally efficient surrogate for entropy, avoiding transcendental logarithmic operations.
Step 1: Physical Experiment Formulation
Suppose we assign a label to an item drawn randomly from set according to the empirical class distribution . What is the probability that this randomly labeled item is misclassified?
Step 2: Probability of Misclassification
For a specific class , the probability that the drawn item actually belongs to class is . The probability that we incorrectly assign it a different label is .
Summing over all classes :
Step 3: Expand and Simplify
Distribute the terms across the summation:
Because the class probabilities sum to unity ():
Mathematical Relationship Between Gini and Entropy#
Using the first-order Taylor series expansion of the natural logarithm centered at , we have:
Multiplying by :
Summing over all classes reveals that Gini Impurity is the first-order polynomial Taylor approximation of Entropy:
3. Minimal Cost-Complexity Pruning#
Unconstrained decision trees grow until every leaf contains a single sample, causing severe overfitting (zero bias, massive variance).
To prevent overfitting, Breiman's CART applies Minimal Cost-Complexity Pruning by balancing misclassification risk with tree size:
- : Empirical training error rate of subtree .
- : Number of terminal leaf nodes in .
- : Complexity parameter penalizing tree depth.
- When : The full, unpruned tree minimizes the cost.
- As : The root node alone with zero splits minimizes the cost.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations construct a recursive binary decision tree classifier from scratch in Python and Rust using only native primitives.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117from typing import List, Optional, Tuple from collections import Counter class TreeNode: def __init__( self, feature_index: Optional[int] = None, threshold: Optional[float] = None, left: Optional['TreeNode'] = None, right: Optional['TreeNode'] = None, leaf_value: Optional[int] = None ): self.feature_index = feature_index self.threshold = threshold self.left = left self.right = right self.leaf_value = leaf_value @property def is_leaf(self) -> bool: return self.leaf_value is not None class DecisionTreeFromScratch: def __init__(self, max_depth: int = 4, min_samples_split: int = 2): self.max_depth = max_depth self.min_samples_split = min_samples_split self.root: Optional[TreeNode] = None @staticmethod def _gini_impurity(labels: List[int]) -> float: if not labels: return 0.0 total = len(labels) counts = Counter(labels) sum_p_squared = sum((cnt / total) ** 2 for cnt in counts.values()) return 1.0 - sum_p_squared def _best_split(self, X: List[List[float]], y: List[int]) -> Tuple[Optional[int], Optional[float]]: num_samples = len(X) num_features = len(X[0]) current_gini = self._gini_impurity(y) best_gain = -1.0 best_feature = None best_threshold = None for feat in range(num_features): # Extract unique values as candidate thresholds values = set(row[feat] for row in X) for val in values: left_y = [y[i] for i in range(num_samples) if X[i][feat] <= val] right_y = [y[i] for i in range(num_samples) if X[i][feat] > val] if not left_y or not right_y: continue p_left = len(left_y) / num_samples p_right = len(right_y) / num_samples weighted_gini = p_left * self._gini_impurity(left_y) + p_right * self._gini_impurity(right_y) gain = current_gini - weighted_gini if gain > best_gain: best_gain = gain best_feature = feat best_threshold = val return best_feature, best_threshold def _build_tree(self, X: List[List[float]], y: List[int], depth: int = 0) -> TreeNode: num_samples = len(X) num_classes = len(set(y)) # Stopping criteria: pure node, max depth, or insufficient samples if depth >= self.max_depth or num_classes <= 1 or num_samples < self.min_samples_split: most_common = Counter(y).most_common(1)[0][0] return TreeNode(leaf_value=most_common) feature, threshold = self._best_split(X, y) if feature is None: most_common = Counter(y).most_common(1)[0][0] return TreeNode(leaf_value=most_common) # Partition children left_X, left_y, right_X, right_y = [], [], [], [] for i in range(num_samples): 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]) left_child = self._build_tree(left_X, left_y, depth + 1) right_child = self._build_tree(right_X, right_y, depth + 1) return TreeNode(feature_index=feature, threshold=threshold, left=left_child, right=right_child) def fit(self, X: List[List[float]], y: List[int]) -> None: self.root = self._build_tree(X, y, depth=0) def _traverse(self, sample: List[float], node: TreeNode) -> int: if node.is_leaf: return node.leaf_value if sample[node.feature_index] <= node.threshold: return self._traverse(sample, node.left) return self._traverse(sample, node.right) def predict(self, X: List[List[float]]) -> List[int]: return [self._traverse(sample, self.root) for sample in X] if __name__ == "__main__": X_train = [[2.0, 3.0], [1.0, 1.0], [5.0, 6.0], [6.0, 8.0], [1.5, 2.0], [7.0, 7.0]] y_train = [0, 0, 1, 1, 0, 1] clf = DecisionTreeFromScratch(max_depth=3) clf.fit(X_train, y_train) preds = clf.predict([[1.2, 1.8], [6.5, 7.2]]) print(f"Predictions: {preds}")
5. Active Knowledge Checks#
What is the operational advantage of Gini Impurity over Shannon Entropy in the CART decision tree algorithm?
In Cost-Complexity Pruning R_alpha(T) = R(T) + alpha * |T|, what role does parameter alpha play?