Decision trees are a powerful prediction method and extremely popular .
Due to the final model is so easy to understand by practitioner and domain experts alike.
The final decision tree can explain exactly why a specific prediction was made, making it very attractive for operational use.
Decision trees also provide the foundation for more advanced ensemble methods such as bagging, random forests and gradient boosting.
After completing this tutorial, you will know:
- How to calculate and evaluate candidate split points in a data
- How to arrange splits into a decision tree structure.
- How to apply the classification and regression tree(CART) algorithm to a real problem.
1.1 Descriptions
This section provides a breif introduction to the Classification and Regression Tree algorithm and the Banknote dataset used in this tutorial.
1.1.1 Classification and Regression Trees
CART for shot is an acronym introduced by Leo Breiman to refer to Decision Tree algorithms that can be used for classification or regression predictive modeling problems. The representation of the CART model is a binary tree.
Creating a binary decision tree is actually a process of dividing up the input space. A greedy approach is used called recursive binary spliting. All input variables and all possible split points are evaluated and chosen in a greedy manner based on the cost function.
- Regression : The cost function that is minimized to choose split points is the sum squared error across all training samples that fall whthin the rectangle
- Classification: The Gini cost function is used which provides an indication of how pure the node are, where node purity refers to how mixed the training data assigned to each node is.
Spliting continues until nodes contain a minimum number of training examples or a maximum tree depth is reached.
1.1.2 Banknote Dataset
This dataset involves the discrimination between authentic and inauthentic banknotes.The baseline performance on the problem is approximately 50%. (data_banknote_authentication.csv)
1.2 Tutorial
This tutorial is broken down into 5 parts:
- Gini Index
- Create Split
- Build a Tree
- Making a Prediction
- Banknote Case Study
These steps will give you the foundation that you need to implement the CART algorithm from scratch and apply it to your own predictive modeling problems.
1.2.1 Gini Index
The Gini index is the name of the cost function used to evaluate splits in the dataset. A split in the dataset involves one input attribute and one value for that attribute. It can be used to divide training patterns into two group of rows.
A Gini score gives an idea of how good a split is by how mixed the classes are in the two groups created by the split.A perfect separation results in a Gini score of 0, whereas the worst case split that results in 50/50 classes in each group results in a Gini score of 0.5 (for a 2 class problem).
Calculating Gini is best demonstrated with an example. We have two groups of data with 2 rows in each group. The rows in the first group all belong to class 0 and the rows in the second group belong to class 1, so it’s a perfect split. We first need to calculate the proportion of classes in each group.
The Gini index for each group must then be weighted by the size of the group, relative to all of the samples in the parent, e.g. all samples that are currently being grouped. We can add this weighting to the Gini calculation for a group as follows:
The scores are then added across each child node at the split point to give a final Gini score for the split point that can be compared to other candidate split points. The Gini for this split point would then be calculated as 0.0 + 0.0 or a perfect Gini score of 0.0.
Below is a function named gini_index() that calculates the Gini index for a list of groups and a list of known class values. You can see that there are some safety checks in there to avoid a divide by zero for an empty group.
# Calculate the Gini index for a split dataset
def gini_index(groups, classes):
# count all samples at split point
n_instances = float(sum([len(group) for group in groups]))
# sum weighted Gini index for each group
gini = 0.0
for group in groups:
size = float(len(group))
# avoid divide by zero
if size == 0:
continue
score = 0.0
# score the group based on the score for each class
for class_val in classes:
p = [row[-1] for row in group].count(class_val) / size
score += p * p
# weight the group score by its relative size
gini += (1.0 - score) * (size / n_instances)
return gini
We can test this function with our worked example above. We can also test it for the worst case of a 50/50 split in each group. The complete example is listed below.
# Example of calculating Gini index
# Calculate the Gini index for a split dataset
def gini_index(groups, classes):
# count all samples at split point
n_instances = float(sum([len(group) for group in groups]))
# sum weighted Gini index for each group
gini = 0.0
for group in groups:
size = float(len(group))
# avoid divide by zero
if size == 0:
continue
score = 0.0
# score the group based on the score for each class
for class_val in classes:
p = [row[-1] for row in group].count(class_val) / size
score += p * p
# weight the group score by its relationship
gini += (1.0 - score) * (size / n_instances)
return gini
# test Gini values
print(gini_index([[[1, 1], [1, 0]], [[1, 1], [1, 0]]], [0, 1]))
print(gini_index([[[1, 0], [1, 0]], [[1, 1], [1, 1]]], [0, 1]))
Running the example prints the two Gini scores, first the score for the worst case at 0.5 followed by the score for the best case at 0.0.
1.2.2 Create Split
A split is comprised of an attribute in the dataset and a value. We can summarize this as the index of an attribute to split and the value by which to split rows on that attribute. This is just a useful shorthand for indexing into rows of data. Creating a split involves three parts, the first we have already looked at which is calculating the Gini score. The remaining two parts are:
1. Splitting a Dataset.
2. Evaluating All Splits.
Spliting a Dataset
Splitting a dataset means separating a dataset into two lists of rows given the index of an attribute and a split value for that attribute. Once we have the two groups, we can then use our Gini score above to evaluate the cost of the split. Splitting a dataset involves iterating over each row, checking if the attribute value is below or above the split value and assigning it to the left or right group respectively. Below is a function named test split() that implements this procedure.
# Split a dataset based on an attribute and an attribute value
def test_split(index, value, dataset):
left,right = list(),list()
for row in dataset:
if row[index] < value:
left.append(row)
else:
right.append(row)
return left,right
Evaluating All Splits
With the Gini function above and the test split function we now have everything we need to evaluate splits. Given a dataset, we must check every value on each attribute as a candidate split, evaluate the cost of the split and find the best possible split we could make. Once the best split is found, we can use it as a node in our decision tree.
This is an exhaustive and greedy algorithm. We will use a dictionary to represent a node in the decision tree as we can store data by name. When selecting the best split and using it as a new node for the tree we will store the index of the chosen attribute, the value of that attribute by which to split and the two groups of data split by the chosen split point.
Each group of data is its own small dataset of just those rows assigned to the left or right group by the splitting process. You can imagine how we might split each group again, recursively as we build out our decision tree. Below is a function named get_split() that implements this procedure. You can see that it iterates over each attribute (except the class value) and then each value for that attribute, splitting and evaluating splits as it goes. The best split is recorded and then returned after all checks are complete.
# Select the best split point for a dataset
def get_split(dataset):
class_values = list(set(row[-1] for row in dataset))
b_index, b_value, b_score, b_group = 999, 999, 999, None
for index in range(len(dataset[0])-1):
for row in dataset:
groups = test_split(index, row[index], dataset)
gini = gini_index(groups, class_values)
if gini < b_score:
b_index, b_value, b_score,b_groups = index, row[index],gini, groups
return {'index': b_index,'value': b_value,'groups':b_groups}
We can contrive a small dataset to test out this function and our whole dataset splitting process.
X1 X2 Y
2.771244718 1.784783929 0
1.728571309 1.169761413 0
3.678319846 2.81281357 0
3.961043357 2.61995032 0
2.999208922 2.209014212 0
7.497545867 3.162953546 1
9.00220326 3.339047188 1
7.444542326 0.476683375 1
10.12493903 3.234550982 1
6.642287351 3.319983761 1
We can plot this dataset using separate colors for each class. You can see that it would not be difficult to manually pick a value of X1 (x-axis on the plot) to split this dataset.
The example below puts all of this together
# Example of getting the best split
# Split a dataset based on an attribute and an attribute value
def test_split(index, value, dataset):
left,right = list(),list()
for row in dataset:
if row[index] < value:
left.append(row)
else:
right.append(row)
return left,right
# Calculate the Gini index for a split dataset
def gini_index(groups, classes):
# count all samples at split point
n_instances = float(sum([len(group) for group in groups]))
# sum weighted Gini index for each group
gini = 0.0
for group in groups:
size = float(len(group))
# avoid divide by zero
if size == 0:
continue
score = 0.0
# score the group based on the score for each class
for class_val in classes:
p = [row[-1] for row in group].count(class_val)/ size
score += p * p
# weight the group score by its relative size
gini += (1.0 - score) * (size / n_instances)
return gini
# Select the best split point for a dataset
def get_split(dataset):
class_values = list(set(row[-1] for row in dataset))
b_index,b_value,b_score,b_groups = 999,999,999,None
for index in range(len(dataset[0])-1):
for row in dataset:
groups = test_split(index, row[index], dataset)
gini = gini_index(groups,class_values)
print('X%d < %.3f Gini=%.3f' % ((index+1),row[index],gini))
if gini < b_score:
b_index,b_value,b_score,b_groups = index,row[index],gini,groups
return {'index':b_index,'value':b_value,'groups':b_groups}
# Test getting the best split
dataset = [[2.771244718,1.784783929,0],
[1.728571309,1.169761413,0],
[3.678319846,2.81281357,0],
[3.961043357,2.61995032,0],
[2.999208922,2.209014212,0],
[7.497545867,3.162953546,1],
[9.00220326,3.339047188,1],
[7.444542326,0.476683375,1],
[10.12493903,3.234550982,1],
[6.642287351,3.319983761,1]]
split = get_split(dataset)
print('Split: [X%d < %.3f]' % ((split['index']+1), split['value']))
The get split() function was modified to print out each split point and it’s Gini index as it was evaluated. Running the example prints all of the Gini scores and then prints the score of best split in the dataset of X1 < 6.642 with a Gini Index of 0.0 or a perfect split.
X1 < 2.771 Gini=0.444
X1 < 1.729 Gini=0.500
X1 < 3.678 Gini=0.286
X1 < 3.961 Gini=0.167
X1 < 2.999 Gini=0.375
X1 < 7.498 Gini=0.286
X1 < 9.002 Gini=0.375
X1 < 7.445 Gini=0.167
X1 < 10.125 Gini=0.444
X1 < 6.642 Gini=0.000
X2 < 1.785 Gini=0.500
X2 < 1.170 Gini=0.444
X2 < 2.813 Gini=0.320
X2 < 2.620 Gini=0.417
X2 < 2.209 Gini=0.476
X2 < 3.163 Gini=0.167
X2 < 3.339 Gini=0.444
X2 < 0.477 Gini=0.500
X2 < 3.235 Gini=0.286
X2 < 3.320 Gini=0.375
Split: [X1 < 6.642]
1.2.3 Build a Tree
Creating the root node of the tree is easy. We call the above get_split() function using the entire dataset. Adding more nodes to our tree is more interesting.Building a tree may be divided into 3 main parts:
- Terminal Nodes
- Recursive Spliting
- Building a Tree
Terminal Nodes
We need to decide when to stop growing a tree. We can do that using the depth and the number of rows that the node is responsible for in the training dataset.
- Maximum Tree Depth.
This is the maximum number of nodes from the root node of the tree. Once a maximum depth of the tree is met, we must stop adding new nodes.Deeper trees are more complex and are more likely to overifit the training data.
- Minimum Node Records
The is the minimum number of training patterns that a given node is responsible for.Once at or below this minimum, we must stop spliting and adding new nodes.Nodes that account for too few training patterns are expected to be too specific and are likely to overfit the training data.
Below is a function named to_terminal() that will select a class_value for a group of rows.It returns the most common output value in a list of rows.
# Create a terminal node value
def to_terminal(group):
outcomes = [row[-1] for row in group]
return max(set(outcomes),key=outcomes.count)
Recursive Spliting
We know how and when to create terminal nodes; now we can build our tree. Building a decision tree involves calling the above developed get_split() function over and over again on the groups created for each node.New nodes added to an existing node are called child nodes. A node may have zero children (a terminal node), one child (one side makes a prediction directly) or two child nodes. We will refer to the child nodes as left and right in the dictionary representation of a given node.
This function is best explained in steps:
1. Firstly, the two groups of data split by the node are extracted for use and deleted from the node. As we work on these groups the node no longer requires access to these data.
2.Next , we can check if either left or right group of rows is empty and if so we create a terminal node using what records we do have.
3. We then check if we have reached our maximum depth and if so we create a terminal node.
4. We then process the left child, creating a terminal node if the group of rows is too small, otherwise creating and adding the left node in a depth first fashion until the bottom of the tree is reached on this branch .
5.The right side is then processed in the same manner.as we rise back up the constructed tree to the root.
# Create child splits for a node or make terminal
def split(node, max_depth, min_size, depth):
left, right = node['groups']
del(node['groups'])
# check for a no split
if not left or not right:
node['left'] = node['right'] = to_terminal(left + right)
return
# check for max depth
if depth >= max_depth:
node['left'],node['right'] = to_terminal(left),to_terminal(right)
return
# process left child
if len(left) <= min_size:
node['left'] = to_terminal(left)
else:
node['left'] = get_split(left)
split(node['left'], max_depth, min_size, depth+1)
# process right child
if len(right) <= min_size:
node['right'] = to_terminal(right)
else:
node['right'] = get_split(right)
split(node['right'],max_depth,min_size, depth+1)
Building a Tree
We can now put all of the pieces together.Building the tree involves creating the root node and calling the split() function that then calls itself recursively to build out the whole tree.Below is the small build_tree() function that implements this procedure.
# Build a decision tree
def build_tree(train, max_depth,min_size):
root = get_split(train)
split(root, max_depth, min_size,1)
return root
We can test out this whole procedure using the small dataset we contrived above. Below is the complete example.Also included is a small print_tree() function that recursively prints out nodes of the decision tree with one line per node.Although not as striking as a real decision tree diagram, it gives an idea of the tree structure and decisions made throughout.
# Example of building a tree
# Split a dataset based on an attribute and an attribute value
def test_split(index, value, dataset):
left,right = list(),list()
for row in dataset:
if row[index] < value:
left.append(row)
else:
right.append(row)
return left,right
# Calculate the Gini index for a split dataset
def gini_index(groups, classes):
# count all samples at split point
n_instances = float(sum([len(group) for group in groups]))
# sum weighted Gini index for each group
gini = 0.0
for group in groups:
size = float(len(group))
# avoid divide by zero
if size ==0:
continue
score = 0.0
# score the group based on the score for each class
for class_val in classes:
p = [row[-1] for row in group].count(class_val)/size
score += p * p
# weight the group score by its relative size
gini += (1.0 - score) * (size / n_instances)
return gini
# select the best split point for a dataset
def get_split(dataset):
class_values = list(set(row[-1] for row in dataset))
b_index,b_value,b_score,b_groups = 999,999,999,None
for index in range(len(dataset[0])-1):
for row in dataset:
groups = test_split(index, row[index],dataset)
gini = gini_index(groups,class_values)
if gini < b_score:
b_index,b_value, b_score, b_groups = index,row[index], gini,groups
return {'index':b_index,'value':b_value,'groups':b_groups}
# create a terminal node value
def to_terminal(group):
outcomes = [row[-1] for row in group]
return max(set(outcomes),key=outcomes.count)
# Create child splits for a node or make terminal
def split(node, max_depth, min_size, depth):
left, right = node['groups']
del(node['groups'])
# check for a no split
if not left or not right:
node['left'] = node['right'] = to_terminal(left,right)
return
# check for max depth
if depth >= max_depth:
node['left'],node['right'] = to_terminal(left),to_terminal(right)
return
# process left child
if len(left) <= min_size:
node['left'] = to_terminal(left)
else:
node['left'] = get_split(left)
split(node['left'],max_depth,min_size,depth+1)
# process right child
if len(right) <= min_size:
node['right'] = to_terminal(right)
else:
node['right'] = get_split(right)
split(node['right'],max_depth,min_size,depth+1)
# Build a decision tree
def build_tree(train,max_depth,min_size):
root = get_split(train)
split(root, max_depth,min_size,1)
return root
# Print a decision tree
def print_tree(node,depth=0):
if isinstance(node,dict):
print('%s[X%d < %.3f]' % ((depth*' ',(node['index']+1),node['value'])))
print_tree(node['left'],depth+1)
print_tree(node['right'],depth+1)
else:
print('%s[%s]' % ((depth*' ',node)))
dataset = [[2.771244718,1.784783929,0],
[1.728571309,1.169761413,0],
[3.678319846,2.81281357,0],
[3.961043357,2.61995032,0],
[2.999208922,2.209014212,0],
[7.497545867,3.162953546,1],
[9.00220326,3.339047188,1],
[7.444542326,0.476683375,1],
[10.12493903,3.234550982,1],
[6.642287351,3.319983761,1]]
tree = build_tree(dataset, 1, 1)
print_tree(tree)
We can vary the maximum depth argument as we run this example and see the effect on the printed tree. With a maximum depth of 1 (the second parameter in the call to the build tree() function), we can see that the tree uses the perfect split we discovered in the previous section. This is a tree with one node, also called a decision stump.
1.2.4 Make a Prediction
Making predictions with a decision tree involves navigating the tree with the specifically provided row of data.Again, we can implement this using a recursive function, where the same prediction rountine is called again with the left or the right child nodes, depending on how the split affects the provided data.We must check if a child node is either a terminal value to be returned as the prediction, or if it is a dictionary node containing another level of the tree to be considered.
Below is the predict() function that implements this procedure. You can see how the index and value in a given node is used to evaluate whether the row of provided data falls on the left or the right of the split.
# Make a predicition with a decision tree
def predict(node, row):
if row[node['index']] < node['value']:
if isinstance(node['left'],dict):
return prediction(node['left'],row)
else:
return node['left']
else:
if isinstance(node['right'],dict):
return predict(node['right'],row)
else:
return node['right']
We can use our contrived dataset to test this function. Below is an example that uses a hard-coded decision tree with a single node that best splits the data (a decision stump). The example makes a prediction for each row in the dataset.
# Example of making predictions
# Make a prediction with a decision tree
def predict(node, row):
if row[node['index']] < node['value']:
if isinstance(node['left'],dict):
return predict(node['left'],now)
else:
return node['left']
else:
if isinstance(node['right'],dict):
return predict(node['right'],row)
else:
return node['right']
# contrived dataset
dataset = [[2.771244718,1.784783929,0],
[1.728571309,1.169761413,0],
[3.678319846,2.81281357,0],
[3.961043357,2.61995032,0],
[2.999208922,2.209014212,0],
[7.497545867,3.162953546,1],
[9.00220326,3.339047188,1],
[7.444542326,0.476683375,1],
[10.12493903,3.234550982,1],
[6.642287351,3.319983761,1]]
# predict with a stump
stump = {'index': 0, 'right': 1, 'value': 6.642287351, 'left': 0}
for row in dataset:
prediction = predict(stump, row)
print('Expected=%d, Got=%d' % (row[-1], prediction))
Running the example prints the correct prediction for each row, as expected.
1.2.5 Banknote Case Study
This section applies the CART algorithm to the Bank Note dataset. The first step is to load the dataset and convert the loaded data to numbers that we can use to calculate split points. For this we will use the helper function load_csv() to load the file and str_column_to_float() to convert string numbers to floats.
We will evaluate the algorithm using k-fold cross-validation with 5 folds. This means that 1372 5 = 274.4 or just over 270 records will be used in each fold. We will use the helper functions evaluate _algorithm() to evaluate the algorithm with cross-validation and accuracy_metric() to calculate the accuracy of predictions. A new function named decision_tree() was developed to manage the application of the CART algorithm, first creating the tree from the training dataset, then using the tree to make predictions on a test dataset. The complete example is listed below.
# Example of CART on the Banknote dataset
from random import seed
from random import randrange
from csv import reader
# load a csv file
def load_csv(filename):
dataset = list()
with open(filename,'r') as file:
csv_reader = reader(file)
for row in csv_reader:
if not row:
continue
dataset.append(row)
return dataset
# Convert string column to float
def str_column_to_float(dataset, column):
for row in dataset:
row[column] = float(row[column].strip())
# Split a dataset into k folds
def cross_validation_split(dataset, n_folds):
dataset_split = list()
dataset_copy = list(dataset)
fold_size = int(len(dataset) / n_folds)
for i in range(n_folds):
fold = list()
while len(fold) < fold_size:
index = randrange(len(dataset_copy))
fold.append(dataset_copy.pop(index))
dataset_split.append(fold)
return dataset_split
# Calculate accuracy percentage
def accuracy_metric(actual, predicted):
correct = 0
for i in range(len(actual)):
if actual[i] == predicted[i]:
correct += 1
return correct / float(len(actual)) * 100.0
# Evaluate an algorithm using a cross validation split
def evaluate_algorithm(dataset, algorithm,n_folds,*args):
folds = cross_validation_split(dataset, n_folds)
scores = list()
for fold in folds:
train_set = list(folds)
train_set.remove(fold)
trian_set = sum(train_set,[])
test_set = list()
for row in fold:
row_copy = list(row)
test_set.append(row_copy)
row_copy[-1] = None
predicted = algorithm(train_set, test_set, *args)
actual = [row[-1] for row in fold]
accuracy = accuracy_metric(actual,predicted)
scores.append(accuracy)
return scores
# Split a dataset based on an attribute and an attribute value
def test_split(index, value, dataset):
left, right = list(),list()
for row in dataset:
if row[index] < value:
left.append(row)
else:
right.append(row)
return left, right
# Calculate the Gini index for a split dataset
def gini_index(groups, classes):
# counr all samples at split point
n_instances = float(sum([len(group) for group in groups]))
# sum weighted Gini index for each group
gini = 0.0
for group in groups:
size = float(len(group))
# avoid divide by zero
if size == 0:
continue
score = 0.0
# score the group based on the score for each class
for class_val in classes:
p = [row[-1] for row in group].count(class_val) / size
score += p * p
# weight the group score by its relative size
gini += (1.0 - score) * (size / n_instances)
return gini
# Select the best split point for a dataset
def get_split(dataset):
class_values = list(set(row[-1] for row in dataset))
b_index, b_value,b_score,b_groups = 999, 999, 999, None
for index in range(len(dataset[0])-1):
for row in dataset:
groups = test_split(index, row[index], dataset)
gini = gini_index(groups, class_values)
if gini < b_score:
b_index,b_value,b_score,b_groups = index,row[index],gini,groups
return {'index': b_index,'value':b_value,'groups':b_groups}
# create a terminal node value
def to_terminal(group):
outcomes = [row[-1] for row in group]
return max(set(outcomes),key=outcomes.count)
# Create child splits for a node or make terminal
def split(node, max_depth, min_size, depth):
left,right = node['groups']
del(node['groups'])
# check for a no split
if not left or not right:
node['left'] = node['right'] = to_terminal(left + right)
return
# check for max depth
if depth >= max_depth:
node['left'],node['right'] = to_terminal(left),to_terminal(right)
return
# process left child
if len(left) <= min_size:
node['left'] = to_terminal(left)
else:
node['left'] = get_split(left)
split(node['left'], max_depth, min_size, depth+1)
# process right child
if len(right) <= min_size:
node['right'] = to_terminal(right)
else:
node['right'] = get_split(right)
split(node['right'], max_depth, min_size, depth+1)
# Build a decision tree
def build_tree(train,max_depth, min_size):
root = get_split(train)
split(root, max_depth,min_size, 1)
return root
# Make a prediction with a decision tree
def predict(node, row):
if row[node['index']] < node['value']:
if isinstance(node['left'],dict):
return predict(node['left'],row)
else:
return node['left']
else:
if isinstance(node['right'],dict):
return predict(node['right'],row)
else:
return node['right']
# Classification and regression Tree Algorithm
def decision_tree(train, test, max_depth, min_size):
tree = build_tree(train, max_depth, min_size)
prediction = list()
for row in test:
prediction = predict(tree, row)
predictions.append(prediction)
return (predictions)
# Test CART on Bank Note dataset
seed(1)
# load and prepare data
filename = 'banknote_authentication.csv'
dataset = load_csv(filename)
# convert string attributes to integers
for i in range(len(dataset[0])):
str_column_to_float(dataset,i)
# evaluate algorithm
n_folds = 5
max_depth = 5
min_size = 10
scores = evaluate_algorithm(dataset, decision_tree, n_folds, max_depth,min_size)
print('Scores: %s' % scores)
print('Mean Accuracy: %.3f%%' % (sum(scores)/float(len(scores))))