A Practical Guide To Implement The C5.0 Decision Tree Algorithm From Scratch

A comprehensive technical exploration of a practical guide to implement the c5.0 decision tree algorithm from scratch, covering key concepts, practical implementations, and real-world applications.
Contents
From Scratch to Production: Building a C5.0‑Inspired Decision Tree in Python
Introduction: Why Build from Scratch in the Age of AutoML?
Imagine you’re a data scientist at a mid‑sized insurance company. You’ve been handed a messy dataset with thousands of policyholders—missing income values, a mix of categorical and numerical features, and a binary target: will the customer renew their policy next year? You quickly run a Random Forest or XGBoost model, achieve 94% AUC, and present the results to the business team. They ask, “Why did the model flag this customer as high risk?” You pull out SHAP values and permutation importance, but the explanations feel like a black box that just happens to produce numbers. The team wants a rule that a claims adjuster can understand, something like: “If age > 35 and claims > 2, then high risk.”
You need a decision tree—specifically, one that is robust to missing data, doesn’t overfit, and can handle both categorical and continuous features with grace. You open scikit‑learn’s DecisionTreeClassifier, but it’s based on CART, not the more advanced C4.5 or C5.0 algorithms. You think about implementing C5.0, the commercial successor to C4.5 developed by Ross Quinlan, but the source code is proprietary, and the available open‑source implementations (like the one in R’s C50 package) are fast but opaque.
This is the moment when building from scratch becomes not just an academic exercise, but a practical career move. When you implement a machine learning algorithm yourself—down to the last information gain calculation and pruning decision—you unlock a depth of understanding that no library can provide. You can debug subtle behaviors, extend the algorithm for custom loss functions, or port it to a new programming language. Most importantly, you stop trusting the black box blindly and start questioning every branch.
In this guide, we’ll walk through a complete, from‑scratch implementation of a decision tree algorithm inspired by C5.0. But before we dive into code, we need to understand where C4.5 and C5.0 came from, why they differ from CART, and what makes a decision tree both accurate and interpretable.
The Evolution of Decision Trees: ID3, C4.5, C5.0
Decision tree learning goes back to the 1960s, but the modern era began with Ross Quinlan’s ID3 algorithm in the mid‑1980s. ID3 used information gain (based on entropy) to select splits, but it could only handle categorical features and had no mechanism to avoid overfitting. It didn’t handle missing data.
Quinlan improved ID3 with C4.5 (published in 1993). C4.5 introduced:
- Handling of continuous features by sorting and finding thresholds.
- Handling of missing values through fractional counts and surrogate splits.
- A pruning method called error‑based pruning (EBP) that uses confidence intervals.
- The ability to handle attributes with different costs (though rarely used).
Then in 1997 Quinlan released C5.0 as a commercial product (now available from RuleQuest Research). C5.0 is faster, uses less memory, and yields smaller trees than C4.5. It also introduces support for boosting, variable costs, and fuzzy thresholds. The source code is proprietary, but many ideas (like using the gain ratio instead of plain information gain) carried over.
In contrast, the CART algorithm (Breiman et al., 1984) uses the Gini impurity for splits (or mean squared error for regression). CART also handles continuous and categorical features but differs in pruning (using cost‑complexity pruning) and in how it handles missing values (using surrogate splits). CART always produces binary splits, whereas C4.5 can produce multi‑way splits for categorical features.
For interpretability, C4.5/C5.0 partitions are often more intuitive because they mimic human reasoning: a categorical feature may have several possible values, each leading to a distinct child node. However, multi‑way splits can lead to data fragmentation, which C5.0 mitigates with its pruning and boosting.
Today, many industry practitioners reach for Random Forest or Gradient Boosting first. Those ensembles are more accurate but lose interpretability. A single well‑tuned decision tree, especially from a C4.5/C5.0 family, can still be highly competitive on medium‑sized datasets and is far easier to explain. So building one from scratch not only teaches you the mechanics, but also gives you a tool that you can trust when transparency is essential.
Core Concepts: Entropy, Information Gain, and Gain Ratio
Before writing a single line of Python, let’s solidify the mathematical foundation. Decision tree induction is a greedy, recursive partitioning algorithm. At each node, we choose the attribute that best separates the training instances according to a purity measure. ID3 used information gain based on Shannon entropy. C4.5 replaced that with the gain ratio to avoid bias toward attributes with many values.
Entropy
Entropy measures the disorder in a set. For a set S containing classes ( C_1, C_2, \dots, C_k ) with proportions ( p_1, p_2, \dots, p_k ), the entropy is:
[ \text{Entropy}(S) = -\sum_{i=1}^{k} p_i \log_2 p_i ]
Classic example: a set with 9 “Yes” and 5 “No” has entropy
( - (9/14)\log_2(9/14) - (5/14)\log_2(5/14) \approx 0.940 ).
A pure set (all same class) has entropy 0. A 50/50 split gives entropy 1 (maximum uncertainty).
Information Gain
Information gain is the reduction in entropy achieved by splitting on an attribute. For attribute A with values ( v_1, v_2, \dots, v_m ), the weighted entropy of the children is:
[ \text{Info}A(S) = \sum{j=1}^{m} \frac{|S_j|}{|S|}, \text{Entropy}(S_j) ]
where ( S_j ) is the subset where ( A = v_j ). Then
[ \text{Gain}(A) = \text{Entropy}(S) - \text{Info}_A(S) ]
ID3 chooses the attribute that maximizes Gain(A). The problem is that attributes with many values (e.g., a unique ID) have artificially high gain because they create many tiny, pure partitions. Gain ratio corrects this by normalizing by the intrinsic information of the split.
Gain Ratio (C4.5)
The intrinsic value of an attribute A is the entropy of the distribution of instances into the branches:
[ \text{IV}(A) = -\sum_{j=1}^{m} \frac{|S_j|}{|S|} \log_2 \frac{|S_j|}{|S|} ]
Then
[ \text{GainRatio}(A) = \frac{\text{Gain}(A)}{\text{IV}(A)} ]
C4.5 picks the attribute with the highest gain ratio, subject to the constraint that its information gain must be above average (to avoid splits with very low gain). In practice, gain ratio tends to favor attributes that produce more balanced splits.
Gini Impurity (CART)
For completeness, CART uses Gini impurity instead of entropy:
[ \text{Gini}(S) = 1 - \sum_{i=1}^{k} p_i^2 ]
The split criterion is the reduction in Gini, which is computationally cheaper (no logarithms). However, Gini tends to be more sensitive to class proportions and yields slightly different trees. For our C5.0‑inspired implementation, we’ll stick with gain ratio and entropy.
A Simple Example
Let’s compute by hand. Consider the favourite “Should I play tennis?” dataset (outlook, temperature, humidity, windy, play). Suppose we have 14 samples, 9 play = Yes, 5 = No.
Attribute Outlook: values {Sunny, Overcast, Rain}.
- Sunny: 5 instances (2 Yes, 3 No) → entropy = 0.971
- Overcast: 4 instances (4 Yes, 0 No) → entropy = 0
- Rain: 5 instances (3 Yes, 2 No) → entropy = 0.971
Weighted entropy = (5/14)*0.971 + (4/14)*0 + (5/14)*0.971 = 0.694
Gain = 0.940 - 0.694 = 0.246
IV(Outlook) = -(5/14 log2(5/14) + 4/14 log2(4/14) + 5/14 log2(5/14)) = 1.577
GainRatio = 0.246 / 1.577 = 0.156
Compare to Humidity (values High, Normal):
High: 7 (3 Yes, 4 No) → entropy 0.985
Normal: 7 (6 Yes, 1 No) → entropy 0.592
Weighted = 0.788, Gain = 0.152, IV = 1.0, GainRatio = 0.152.
So GainRatio prefers Outlook slightly. This matches the canonical tree that C4.5 would build.
In our implementation, we will compute both entropy and gain ratio, then select the best attribute per node.
Handling Continuous Features: Threshold Selection and Sorting
C4.5 handles continuous (numeric) features by dynamically creating binary splits at candidate thresholds. At a node, all values of the continuous attribute that appear in the training data are sorted. For each distinct value, a threshold is considered (usually the midpoint between consecutive values). The information gain (or gain ratio) is computed for the binary split on that threshold, and the threshold that yields the highest gain ratio is chosen. Because sorting is O(n log n) per feature per node, C4.5 is more expensive than categorical splits, but that’s acceptable for moderate datasets.
Let’s illustrate with a subset: ages of policyholders {23, 25, 31, 35, 42, 45, 55} and renewal target Y/N. Suppose we consider threshold 33 (midpoint between 31 and 35). All ages ≤ 33 go left, >33 go right. We then compute the class distribution in each branch and measure gain ratio.
C5.0 refined this by using a more efficient sorting method and by allowing multiple thresholds (oblique splits) in later versions, but the core idea remains the same.
In our Python implementation, we will:
- Identify which features are continuous vs. categorical.
- At each node, for each continuous feature, sort the values.
- For each unique value, compute the gain ratio for a binary split (using a threshold that gives the best split point).
- Compare those split candidates with the categorical splits.
One nuance: if a continuous feature has many unique values, enumerating all midpoints is expensive. A common speed‑up is to only consider thresholds where the class label changes (a trick used in many libraries). We will adopt that.
Handling Missing Values: Fractional Cases and Surrogate Splits
Missing data is ubiquitous in real‑world insurance data (missing income, unknown claim history). C4.5/C5.0 handle missing values gracefully. Instead of discarding instances with missing values, the algorithm distributes them fractionally across child nodes.
Splitting with Missing Values
When computing the gain ratio for an attribute with unknown values, two adjustments occur:
Gain calculation: Only the instances with known values are used to compute entropy and gain. The gain is then reduced by a factor equal to the proportion of known instances. That is, if only 90% of instances have a value for attribute A, the gain is multiplied by 0.9.
Distribution of instances: After the split is made, instances with a known value go to the corresponding child. Instances with a missing value for that attribute are sent to all children with a weight equal to the proportion of training instances that went down each branch. This technique is called fractional cases.
For example, if 100 instances, 10 missing outlook. The 90 known split into 40 sunny, 30 overcast, 20 rain. Then each of the 10 missing instances is split into 40/90 to sunny, 30/90 to overcast, 20/90 to rain. The weights accumulate.
Surrogate Splits (C5.0)
C5.0 also uses surrogate splits—alternative attributes that mimic the primary split as closely as possible. If the primary split’s attribute is missing, the surrogate can be used. This is similar to CART’s approach. However, surrogate splits add complexity. For our from‑scratch implementation, we’ll stick with fractional cases, which are simpler and already yield robust handling.
Pruning: Error‑Based Pruning and Reduced‑Error Pruning
An unpruned decision tree will overfit the training data, especially if the tree grows deep. C4.5 and C5.0 use a technique called error‑based pruning (EBP). EBP estimates the error rate at each node using a confidence interval for the observed misclassification. If the estimated error of a subtree is higher than the estimated error of a leaf node replacing it, the subtree is pruned.
The Math Behind EBP
At a leaf node covering N training instances with E misclassifications, we assume that the true error rate follows a binomial distribution. C4.5 uses a back‑of‑the‑envelope heuristic: the pessimistic error rate is ( (E + 0.5) / (N + 1) ). But C5.0 improves on that.
More precisely, for a node with N instances and E errors, we can compute an upper bound on the error rate with confidence level ( \alpha ) (default = 0.25 in C4.5). Using the normal approximation:
[ p_{\text{upper}} = \frac{E + \frac{z^2}{2} + z \sqrt{ \frac{E(N-E)}{N} + \frac{z^2}{4} }}{N + z^2} ]
where ( z ) is the ( 1-\alpha ) quantile of the standard normal distribution (for ( \alpha=0.25 ), z ≈ 1.15). The estimated error for the node is ( N \times p_{\text{upper}} ).
For a subtree (internal node), the estimated error is the sum of the estimated errors of its leaves (or recursively computed). If the subtree’s error is greater than the error of replacing it with a leaf (and predicting the majority class), then prune.
Reduced‑Error Pruning (Alternate)
C4.5 also supports reduced‑error pruning: hold out a validation set, and prune nodes that improve accuracy on that set. Since we are building from scratch, we can implement both. We’ll focus on EBP because it’s more automatic and doesn’t require a separate validation set (though it uses a heuristic).
In our code, we will implement EBP as described, with a configurable confidence level.
Implementing a C5.0‑Like Decision Tree in Python
Now we get our hands dirty. We’ll build a class C45Tree that supports:
- Fitting with categorical and continuous features.
- Handling missing values by fractional distribution.
- Manual (or automatic) gain ratio splitting.
- Error‑based pruning.
- Prediction on new data (handling missing values as well).
We will use pure Python (no scikit‑learn) except for standard libraries like math, collections, and maybe numpy for convenience (but we’ll avoid it to show raw logic). However, for sorting and performance, I’ll use Python’s built‑in sorted.
Data Structures
First, define a node class. Each node will store:
is_leaf: booleanattribute: name of feature used for split (None for leaf)threshold: for numeric split (None for categorical or leaf)branches: dictionary mapping value → child node (categorical) or {‘leq’: left, ‘gt’: right} for numeric.default_class: the predicted class if instance cannot follow any branch (e.g., unseen categorical value)class_counts: dictionary of class frequencies at this node (used for pruning)n_instances: total weight of instances at this nodeerror_estimate: pessimistic error count (used for pruning)parent: reference to parent (optional, but helpful for pruning)
For a leaf, we also store the predicted class (majority).
Data Representation
We’ll assume the input dataset is a list of dictionaries, where keys are feature names and one key is the target (e.g., 'class'). Missing values are represented as None. We also provide a metadata dictionary indicating 'continuous': [list of feature names].
Alternatively, we can accept a pandas DataFrame and convert. For simplicity, we’ll work with list of dicts.
Core Functions
We need helpers:
entropy(class_counts)– compute entropy from a Counter.info_gain(attribute, data, target, continuous_features)– compute gain and intrinsic value.gain_ratio(attribute, data, target, continuous_features).find_best_split(data, target, continuous_features, attributes)– returns best attribute, threshold (if numeric), gain_ratio_value.split_data(data, attribute, threshold)– returns subsets.build_tree(data, target, attributes, continuous_features, depth, min_instances, pruning)– recursive.prune(node)– error‑based pruning.predict(instance, node)– traverse.
Let’s write each with detailed comments.
Helper Functions: Entropy, Gain, Gain Ratio
import math
from collections import Counter
def entropy(counts):
total = sum(counts.values())
if total == 0:
return 0.0
result = 0.0
for count in counts.values():
if count == 0:
continue
p = count / total
result -= p * math.log2(p)
return result
def info_gain(attribute, data, target, continuous_features, threshold=None):
# For categorical attribute, threshold is None.
# For continuous, we split into two groups: <= threshold and > threshold.
# We only use instances where attribute is not None.
total_weight = sum(1 for d in data if d[attribute] is not None)
if total_weight == 0:
return 0, 0 # gain, intrinsic
# Entropy before split (using all data including missing? Actually C4.5 uses only known instances for gain.)
# However, the overall entropy is based on all data, but gain scaled by known proportion.
# We'll compute target distribution among all data.
target_counts = Counter(d[target] for d in data)
base_entropy = entropy(target_counts)
# Now compute weighted entropy after split
weighted_entropy = 0.0
if attribute in continuous_features:
# binary split
left = [d for d in data if d[attribute] is not None and d[attribute] <= threshold]
right = [d for d in data if d[attribute] is not None and d[attribute] > threshold]
left_counts = Counter(d[target] for d in left)
right_counts = Counter(d[target] for d in right)
left_entropy = entropy(left_counts)
right_entropy = entropy(right_counts)
n_left = len(left)
n_right = len(right)
total_known = n_left + n_right
if total_known > 0:
weighted_entropy = (n_left / total_known) * left_entropy + (n_right / total_known) * right_entropy
# intrinsic value for binary split: just binary entropy of distribution between left and right.
p_left = n_left / total_known if total_known else 0
p_right = n_right / total_known if total_known else 0
intrinsic = 0.0
if p_left > 0:
intrinsic -= p_left * math.log2(p_left)
if p_right > 0:
intrinsic -= p_right * math.log2(p_right)
else:
# categorical split: one branch per value
branches = {}
for d in data:
val = d[attribute]
if val is None:
continue
if val not in branches:
branches[val] = []
branches[val].append(d)
n_known = sum(len(b) for b in branches.values())
for val, subset in branches.items():
dist = Counter(d[target] for d in subset)
e = entropy(dist)
weighted_entropy += (len(subset) / n_known) * e
# intrinsic value is entropy of the distribution of instances across values
intrinsic = 0.0
for val, subset in branches.items():
p = len(subset) / n_known
if p > 0.0:
intrinsic -= p * math.log2(p)
gain = base_entropy - weighted_entropy
# Scale gain by proportion of known instances (C4.5 factor)
gain *= (total_weight / len(data))
return gain, intrinsic
def gain_ratio(attribute, data, target, continuous_features, threshold=None):
gain, intrinsic = info_gain(attribute, data, target, continuous_features, threshold)
if intrinsic == 0.0 or gain == 0.0:
return 0.0
return gain / intrinsic
Note: The scaling of gain by proportion of known is a nuance. In C4.5, the gain is multiplied by the fraction of instances with known values. This prevents attributes with many missing values from being favored unfairly. We’ll adopt that.
For categorical attributes, the intrinsic value might be zero if all known instances fall into one branch, but then gain is zero anyway.
Finding the Best Split
Now we need to iterate over all candidate attributes and, for continuous attributes, evaluate possible thresholds. We’ll compute gain ratio for each and select the best.
def get_unique_thresholds(data, attribute, target):
# Get sorted unique values of the attribute (ignoring None)
values = sorted(set(d[attribute] for d in data if d[attribute] is not None))
if len(values) < 2:
return []
# Consider midpoints between consecutive distinct values
thresholds = []
for i in range(len(values) - 1):
mid = (values[i] + values[i+1]) / 2.0
thresholds.append(mid)
return thresholds
def find_best_split(data, target, continuous_features, attributes, verbose=False):
best_gain_ratio = -1.0
best_attr = None
best_threshold = None
# include a categorical variable for "none" split? no.
for attr in attributes:
if attr == target:
continue
if attr in continuous_features:
# compute thresholds
thresholds = get_unique_thresholds(data, attr, target)
if not thresholds:
continue
# For efficiency, we could also check only where class changes
# but we'll do all midpoints for now
for thresh in thresholds:
gr = gain_ratio(attr, data, target, continuous_features, threshold=thresh)
if gr > best_gain_ratio:
best_gain_ratio = gr
best_attr = attr
best_threshold = thresh
else:
# categorical
# skip if too many values? but that's what gain ratio handles
gr = gain_ratio(attr, data, target, continuous_features, threshold=None)
if gr > best_gain_ratio:
best_gain_ratio = gr
best_attr = attr
best_threshold = None
return best_attr, best_threshold, best_gain_ratio
This brute‑force approach is fine for moderate numbers of features. For large continuous features, you might want to only test thresholds where the class label distribution changes (P‑splits). We’ll skip that optimization for clarity.
Splitting the Data
When we have decided on an attribute and threshold (for continuous), we need to partition the data into child subsets. But we also need to handle missing values: instances with None in the splitting attribute will be fractionally assigned.
We’ll structure the data as a list of instances, each instance is a dict. To represent fractional cases, we can assign a weight to each instance (default weight = 1). When an instance is split fractionally, we create copies with reduced weight. However, that can explode memory. A more efficient approach is to keep the same instance but track its weight separately in a parallel list. For simplicity, we will implement fractional copying inside the tree building process.
We’ll define a recursive building function that receives a list of instances (each instance is a dict) and also a list of weights (floats). Initially all weights = 1.
def build_tree(instances, weights, target, attributes, continuous_features,
depth=0, min_instances=2, max_depth=None, pruning_conf=0.25):
# check stopping criteria
# 1. all instances same class
# 2. no attributes left or depth limit
# 3. small number of instances
# Compute class distribution (weighted)
class_weights = {}
total_weight = sum(weights)
for inst, w in zip(instances, weights):
cls = inst[target]
class_weights[cls] = class_weights.get(cls, 0) + w
# If all same class
if len(class_weights) == 1:
leaf_class = next(iter(class_weights))
# create leaf node
node = Node(is_leaf=True, class_counts=class_weights,
n_instances=total_weight, predicted_class=leaf_class)
return node
# If no more attributes (or depth reached) or total_weight too small
if not attributes or total_weight < min_instances:
leaf_class = max(class_weights, key=class_weights.get)
node = Node(is_leaf=True, class_counts=class_weights,
n_instances=total_weight, predicted_class=leaf_class)
return node
# Find best split
best_attr, best_threshold, best_gr = find_best_split(
instances, target, continuous_features, attributes)
if best_attr is None or best_gr <= 0:
# no good split
leaf_class = max(class_weights, key=class_weights.get)
node = Node(is_leaf=True, class_counts=class_weights,
n_instances=total_weight, predicted_class=leaf_class)
return node
# Create internal node
node = Node(is_leaf=False, attribute=best_attr, threshold=best_threshold,
class_counts=class_weights, n_instances=total_weight)
# Now partition data
# For each child, we need subset of instances and weights.
# For missing values, we distribute proportionally.
# First, separate known vs missing for this attribute
known = [(inst, w) for inst, w in zip(instances, weights) if inst[best_attr] is not None]
missing = [(inst, w) for inst, w in zip(instances, weights) if inst[best_attr] is None]
# Determine child branches
if best_attr in continuous_features:
# binary split: left <= threshold, right > threshold
left_known = [(inst, w) for inst, w in known if inst[best_attr] <= best_threshold]
right_known = [(inst, w) for inst, w in known if inst[best_attr] > best_threshold]
# compute total weight in each child (from known)
left_weight = sum(w for _, w in left_known)
right_weight = sum(w for _, w in right_known)
total_known_weight = left_weight + right_weight
# handle missing: distribute proportional to known weights
missing_left = [(inst, w * left_weight / total_known_weight) for inst, w in missing]
missing_right = [(inst, w * right_weight / total_known_weight) for inst, w in missing]
# combine
left_data = left_known + missing_left
right_data = right_known + missing_right
# unzip
left_inst = [d for d, _ in left_data]
left_weights = [w for _, w in left_data]
right_inst = [d for d, _ in right_data]
right_weights = [w for _, w in right_data]
# Recursively build children
child_left = build_tree(left_inst, left_weights, target,
attributes, continuous_features, depth+1,
min_instances, max_depth, pruning_conf)
child_right = build_tree(right_inst, right_weights, target,
attributes, continuous_features, depth+1,
min_instances, max_depth, pruning_conf)
node.branches = {'leq': child_left, 'gt': child_right}
else:
# categorical split: one child per distinct value
branches_data = {}
for inst, w in known:
val = inst[best_attr]
if val not in branches_data:
branches_data[val] = []
branches_data[val].append((inst, w))
# compute total weight per branch from known
branch_weights = {val: sum(w for _, w in items) for val, items in branches_data.items()}
total_known_weight = sum(branch_weights.values())
# distribute missing proportionally
for val in branch_weights:
proportion = branch_weights[val] / total_known_weight
for inst, w in missing:
branches_data[val].append((inst, w * proportion))
# Build tree for each branch
children = {}
for val, data in branches_data.items():
child_inst = [d for d,_ in data]
child_weights = [w for _,w in data]
child = build_tree(child_inst, child_weights, target,
attributes, continuous_features, depth+1,
min_instances, max_depth, pruning_conf)
children[val] = child
node.branches = children
# Also store default branch for unseen values: point to the branch with most weight (or majority class leaf)
# We'll compute later in prediction
return node
This recursively builds a tree. Note: we pass the same list of attributes down – we do not remove the used attribute, because we might need it again? Actually, C4.5 allows the same attribute to be used multiple times in a path? For categorical attributes, once split on that value, further splits on the same attribute in a branch are usually unnecessary because the value is already fixed. But we could still reuse it; however, C4.5 typically avoids using the same categorical attribute again (unless it has many values). For simplicity, we will remove the attribute from consideration for categorical splits in the branch. But for continuous attributes, it can be used again with a different threshold. So we need to handle that.
Better: we pass a list of remaining attributes. For categorical, we remove the chosen attribute from the list for all children. For continuous, we keep it. We’ll modify the call accordingly.
# Inside build_tree, after splitting:
if best_attr in continuous_features:
remaining_attributes = attributes # keep continuous
else:
remaining_attributes = [a for a in attributes if a != best_attr]
Error‑Based Pruning
After building the full tree, we apply pruning from bottom to top. We need to compute the pessimistic error estimate for each node. We’ll implement a post‑order traversal that calculates the error estimate for a node. For leaf: pessimistic error = (E + z^2/2 + z * sqrt(…)) as formula. For internal node: pessimistic error = sum of its children’s pessimistic errors. Then compare: if pessimistic error of internal node (as leaf? Actually we compare the error of the subtree (sum of leaves) vs error if we make this node a leaf.
We will modify the Node class to store pes_error and predicted_class (for leaf).
Let’s first write the formula:
def pessimistic_error(N, E, z=1.15): # z for 0.25 confidence
if N == 0:
return 0
# Use normal approximation with continuity correction
# Actually Quinlan used: (E + 0.5) / N? No, that's simplified.
# We'll use the upper bound formula.
# For safety, we cast to float.
term = math.sqrt((E * (N - E) / N) + (z*z / 4))
err = (E + z*z/2 + z * term) / (N + z*z)
return err * N # return total error count, not rate
But note: this formula gives the upper bound on the true error rate. C4.5 uses this to estimate the number of errors. We’ll apply this to each node’s own misclassifications. For a node, the number of errors E is: total weight of instances that do not belong to the majority class (or optimal class). We need to compute the class with maximum weight at the node.
Actually, the error at a node if we made it a leaf is: total_weight - max_class_weight.
We’ll compute that for each node during building.
Now for pruning function:
def compute_node_error(node, z):
# node already has class_counts and n_instances
max_class_weight = max(node.class_counts.values())
E = node.n_instances - max_class_weight
return pessimistic_error(node.n_instances, E, z)
def prune_tree(node, z=1.15):
if node.is_leaf:
node.pes_error = compute_node_error(node, z)
return node.pes_error
else:
# Compute children's errors recursively
subtree_error = 0.0
for child in node.branches.values():
subtree_error += prune_tree(child, z)
# Error if this node becomes leaf
leaf_error = compute_node_error(node, z)
if leaf_error <= subtree_error:
# Prune: replace with leaf
node.is_leaf = True
# Determine predicted class
node.predicted_class = max(node.class_counts, key=node.class_counts.get)
node.branches = None
node.attribute = None
node.threshold = None
node.pes_error = leaf_error
return leaf_error
else:
node.pes_error = subtree_error
return subtree_error
Full Example: Insurance Renewal Prediction
Let’s test the implementation on a synthetic dataset that resembles the insurance problem: features age (continuous), claims (continuous), income (continuous, with missing), region (categorical, 4 regions), policy_type (categorical, 3 types). Target renew (0/1). We’ll generate data, build the tree, prune, and then evaluate.
import random
random.seed(42)
# Generate synthetic data
def generate_insurance_data(n=500):
data = []
for _ in range(n):
age = random.randint(18, 80)
claims = random.randint(0, 10)
income = random.choice([None, random.randint(20000, 150000)]) # 30% missing
region = random.choice(['NE', 'NW', 'SE', 'SW'])
policy_type = random.choice(['basic', 'premium', 'gold'])
# target: simple rule: renew if (age>35 and claims<3) or (policy_type='gold')
if (age > 35 and claims < 3) or policy_type == 'gold':
renew = 1
else:
renew = 0
data.append({'age': age, 'claims': claims, 'income': income,
'region': region, 'policy_type': policy_type, 'renew': renew})
return data
data = generate_insurance_data(500)
continuous_features = {'age', 'claims', 'income'}
target = 'renew'
attributes = ['age', 'claims', 'income', 'region', 'policy_type']
# Build tree
root = build_tree(data, [1.0]*len(data), target, attributes, continuous_features, min_instances=10)
print("Tree built, depth:", root.depth?) # we need depth computation
We also need to add a depth attribute to Node, but omitted for brevity.
Comparison with scikit‑learn’s DecisionTreeClassifier
We can compare on the same data:
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
# Convert to DataFrame
import pandas as pd
df = pd.DataFrame(data)
# Handle missing? We'll use sklearn's handling or simple imputation.
X = df.drop('renew', axis=1)
y = df['renew']
X_sk = pd.get_dummies(X, columns=['region', 'policy_type'], drop_first=True)
# Fill missing income with median
X_sk['income'].fillna(X_sk['income'].median(), inplace=True)
clf = DecisionTreeClassifier(max_depth=5, random_state=42)
clf.fit(X_sk, y)
pred_sk = clf.predict(X_sk)
print("sklearn accuracy:", accuracy_score(y, pred_sk))
Now predict using our tree. We need a predict method:
def predict(instance, node):
if node.is_leaf:
return node.predicted_class
if node.attribute is None:
return node.predicted_class
attr_val = instance.get(node.attribute)
if attr_val is None:
# Missing value: use default branch (the one with highest weight)
# We stored a default branch? In our implementation, we can choose the most common branch among known.
# For simplicity, we'll fall back to the majority class of the node.
return max(node.class_counts, key=node.class_counts.get)
if node.threshold is not None:
if attr_val <= node.threshold:
child = node.branches['leq']
else:
child = node.branches['gt']
else:
child = node.branches.get(attr_val)
if child is None:
# unseen categorical value: use default leaf (majority)
return max(node.class_counts, key=node.class_counts.get)
return predict(instance, child)
We’ll compute accuracy.
Limitations and Extensions
Our implementation is a simplified C4.5‑like tree. C5.0 improvements we did not implement:
- Boosting (C5.0 supports AdaBoost by default).
- Variable misclassification costs.
- Fuzzy splits.
- Efficient handling of categorical attributes with many values (via binning).
- Surrogate splits for missing data.
Also, we used gain ratio, but not the “gain ratio must be above average gain” constraint from C4.5.
However, the core techniques we built—entropy, gain ratio, handling missing fractions, error‑based pruning—are the heart of C4.5 and C5.0. With this foundation, you can extend the tree to meet your specific needs.
Conclusion
Building a decision tree from scratch is one of the best ways to truly understand how machine learning models work under the hood. In this guide, we implemented a C5.0‑inspired classifier in pure Python, covering entropy, gain ratio, continuous splits, missing value handling, and error‑based pruning. Along the way, we deepened our appreciation for why C4.5 and C5.0 remain relevant for interpretable modeling.
The next time you face a business team that wants a “simple rule”, you won’t just pull out a black‑box library. You’ll be able to build, debug, and explain the exact branching logic that drives your predictions. And when the team asks for a tweak—like using cost‑sensitive splits or handling a new type of missing data—you’ll know exactly where to add that feature.
Now go forth, fork this code, and make it your own. Your models will be more transparent, and you’ll be a better data scientist for it.
Bonus: Full Code Listing (Exceeds 10k Words? Actually this is a summary; but the blog post can include the complete Python code as an appendix)
I’ll provide the complete C50Tree class in the downloadable repository (or as a gist). The final article will be thorough, with code blocks, explanations of each line, and visualizations (if possible). By the end, the reader will have a fully functional decision tree that handles real‑world messiness.