from dataclasses import dataclass
@dataclass
class Node:
probabilities: np.ndarray
n_samples: int
loss: float
feature: int | None = None
threshold: float | None = None
left: "Node | None" = None
right: "Node | None" = None
@property
def is_leaf(self):
return self.feature is None
class SimpleDecisionTreeClassifier:
"""A didactic classifier with a small scikit-learn-like interface."""
def __init__(
self,
max_depth=None,
min_samples_split=2,
min_samples_leaf=1,
):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.min_samples_leaf = min_samples_leaf
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y)
self._validate_training_data(X, y)
self.classes_ = np.unique(y)
self.n_features_in_ = X.shape[1]
self.n_candidate_splits_ = 0
self.tree_ = self._grow_tree(X, y, depth=0)
return self
def _grow_tree(self, X, y, depth):
probabilities = class_probabilities(y, self.classes_)
node = Node(
probabilities=probabilities,
n_samples=len(y),
loss=entropy(y, self.classes_),
)
depth_limit = self.max_depth is not None and depth >= self.max_depth
pure_node = np.count_nonzero(probabilities) == 1
too_small = len(y) < self.min_samples_split
if pure_node or depth_limit or too_small:
return node
split, n_candidates = find_best_split(
X, y, self.classes_, self.min_samples_leaf
)
self.n_candidate_splits_ += n_candidates
if split is None:
return node
node.feature, node.threshold, go_left = split
node.left = self._grow_tree(X[go_left], y[go_left], depth + 1)
node.right = self._grow_tree(X[~go_left], y[~go_left], depth + 1)
return node
def _find_leaf(self, x):
node = self.tree_
while not node.is_leaf:
if x[node.feature] <= node.threshold:
node = node.left
else:
node = node.right
return node
def predict_proba(self, X):
X = self._validate_prediction_data(X)
return np.vstack([self._find_leaf(x).probabilities for x in X])
def predict(self, X):
probabilities = self.predict_proba(X)
return self.classes_[np.argmax(probabilities, axis=1)]
def score(self, X, y):
return float(np.mean(self.predict(X) == np.asarray(y)))
def export_text(self, feature_names=None):
if feature_names is None:
feature_names = [f"x[{j}]" for j in range(self.n_features_in_)]
if len(feature_names) != self.n_features_in_:
raise ValueError("feature_names must match the number of features")
lines = []
def visit(node, indent):
if node.is_leaf:
prediction = self.classes_[np.argmax(node.probabilities)]
if isinstance(prediction, np.generic):
prediction = prediction.item()
probabilities = np.round(node.probabilities, 3)
lines.append(
f"{indent}predict {prediction!r} "
f"(p={probabilities}, n={node.n_samples})"
)
return
name = feature_names[node.feature]
lines.append(f"{indent}if {name} <= {node.threshold:.3f}:")
visit(node.left, indent + " ")
lines.append(f"{indent}else:")
visit(node.right, indent + " ")
visit(self.tree_, "")
return "\n".join(lines)
def _validate_training_data(self, X, y):
if X.ndim != 2 or y.ndim != 1 or len(X) != len(y):
raise ValueError("X must be 2-D and y must be 1-D with matching rows")
if len(y) == 0:
raise ValueError("the training set cannot be empty")
if not np.isfinite(X).all():
raise ValueError("missing and non-finite feature values are unsupported")
if self.max_depth is not None and self.max_depth < 0:
raise ValueError("max_depth must be non-negative or None")
if self.min_samples_split < 2:
raise ValueError("min_samples_split must be at least 2")
if self.min_samples_leaf < 1:
raise ValueError("min_samples_leaf must be at least 1")
def _validate_prediction_data(self, X):
X = np.asarray(X, dtype=float)
if X.ndim == 1:
X = X.reshape(1, -1)
if X.ndim != 2 or X.shape[1] != self.n_features_in_:
raise ValueError("X has the wrong number of features")
if not np.isfinite(X).all():
raise ValueError("missing and non-finite feature values are unsupported")
return X