Index Ai Academy
Phase 3 · Machine learning · Models

Decision trees

Learn a flowchart of yes/no questions.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A decision tree learns rules you can read out loud. For example: "if a customer hasn't ordered in 180 days and has opened more than one support ticket, they're likely to leave." Rules like that are easy to explain to a manager or a client. Trees also cope well with messy business data. They take numbers and categories side by side, and you don't have to rescale anything first. They also pick up effects that only kick in past a cutoff, or only when two things happen together. One tree on its own tends to memorize its training data. But groups of trees, called random forests and gradient boosting (next lesson), are among the best models for business data. On spreadsheet-style data they often beat neural networks.

What you'll be able to do

  • Read a decision tree as a flowchart of yes/no questions
  • Measure how mixed a group is with Gini impurity
  • Find the best split for a feature, the way tree algorithms do
  • Control a tree's depth to keep it from memorizing the data

What it covers

  1. A flowchart learned from data
  2. How a split is chosen: purity
  3. Scoring a question
  4. Trying every question
  5. Find the best cutoff by hand
  6. Depth: from too simple to memorizing
  7. Strengths and weaknesses

A flowchart learned from data

You've probably followed a flowchart like this one without calling it that:

Is it raining?
├── yes → take an umbrella
└── no  → Is the sky gray?
          ├── yes → take an umbrella
          └── no  → leave it at home

Each question has a yes or no answer. You follow the branch for your answer, then ask the next question. When you run out of questions, you have your answer.

A decision tree is a flowchart like this that a computer builds from data. You give it past examples with their answers. It works out which questions to ask, and in what order.

A few words you'll need:

  • Each question sits at a node. The first node, at the top, is the root.
  • A question looks at one feature and compares it to a cutoff, called a threshold. For example: "is days since last order more than 180?"
  • An end point with no more questions is a leaf. It holds the prediction.

What does a leaf predict? For classification, like churn yes or no, it predicts the answer most of its training examples had. It can also give a probability: the share of them that churned. For regression, like next month's spend, it predicts the average of its training examples.

Here's a real tree, trained on the shop's customer data. max_depth=2 allows at most two questions in a row.

import pandas as pd
import requests
from sklearn.tree import DecisionTreeClassifier, export_text

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["days_since_last_order", "orders_last_year", "support_tickets", "trail_club"]
tree = DecisionTreeClassifier(max_depth=2, random_state=0).fit(df[features], df["churned"])
print(export_text(tree, feature_names=features, show_weights=True))
It runs right here in your browser, with nothing to install.

How to read the printout:

  • A |--- line with <= or > is one side of a question. A question shows up twice: once with <= (at or below the cutoff) and once with > (above it).
  • Lines indented further sit under the answer just above them.
  • A |--- line with weights is a leaf. weights counts the training customers that end up there: how many stayed, then how many churned. class is the leaf's prediction: 0 for stayed, 1 for churned.

Follow any path from the top down to a leaf and you get a rule a marketer could act on.

Quick check
Here's a tiny tree written as Python. What does this print?
def predict(days_since_last_order, support_tickets):
    if days_since_last_order > 180:
        if support_tickets > 1:
            return "churn"
        return "stay"
    return "stay"

print(predict(200, 3), predict(200, 0), predict(30, 5))
  1. churn stay churn
  2. churn churn churn
  3. churn stay stay
  4. stay stay churn
Show the answer

churn stay stay

200 days is over 180 and 3 tickets is over 1, so the first is churn. The second has 0 tickets, so it stays. The third never passes the first question: 30 days is not over 180, so it stays.

How a split is chosen: purity

Picture two jars of marbles. One holds only blue marbles. The other is half blue, half red. The first jar is pure: everything in it is one kind. The second is as mixed as two colors get.

A good question splits the customers into two groups that are each as pure as possible. Ideally the stayers end up on one side and the churners on the other. To compare questions, the tree needs a number for how mixed a group is. The usual one is Gini impurity.

Here's one way to picture it. Pick one customer from the group at random and put them back. Then pick again. Gini impurity is the chance that your two picks have different labels.

  • If everyone in the group stayed, your two picks always match. The impurity is 0.
  • If the group is half stayers and half churners, they differ half the time. The impurity is 0.5.

As a formula:

G=1−∑kpk2G = 1 - \sum_k p_k^2

One symbol at a time:

  • GG is the Gini impurity of one group.
  • kk stands for a class, such as "stayed" or "churned".
  • pkp_k is the share of the group in class kk. If 3 of 4 customers stayed, the share for "stayed" is 0.75.
  • ∑k\sum_k uses the Greek letter sigma. It means "add this up over every class", like a for loop.

In plain words: for each class, take its share of the group and square it. Add those squares up, then subtract the total from 1.

Worked example

A group of 5 customers: 4 stayed and 1 churned.

  1. Shares: 4 ÷ 5 = 0.8 stayed, and 1 ÷ 5 = 0.2 churned.
  2. Squares: 0.8 × 0.8 = 0.64, and 0.2 × 0.2 = 0.04.
  3. Add them: 0.64 + 0.04 = 0.68.
  4. Subtract from 1: 1 − 0.68 = 0.32.

So the impurity is 0.32. The group is mostly one class, but not completely.

The same calculation in Python. np.unique counts how many of each label there are. Dividing the counts by the total turns them into shares.

import numpy as np

def gini(labels):
    _, counts = np.unique(labels, return_counts=True)
    p = counts / counts.sum()
    return 1 - np.sum(p ** 2)

print("all stay:      ", gini([0, 0, 0, 0]))
print("half and half: ", gini([0, 1, 0, 1]))
print("1 in 5 churn:  ", round(gini([0, 0, 0, 0, 1]), 3))

It prints 0.0, 0.5 and 0.32. A pure group scores 0. A 50/50 group scores 0.5, which is the most mixed a group with two classes can be.

Go deeper Why squaring the shares works

The chance that one random pick is class kk is its share, pkp_k. The chance that both picks are class kk is pk×pkp_k \times p_k, which is pk2p_k^2. Adding that over every class gives the chance that the two picks match. One minus that is the chance they differ.

For the group of 4 stayers and 1 churner: both picks stayed with chance 0.64. Both churned with chance 0.04. So they match with chance 0.68 and differ with chance 0.32.

Note: some tree software measures mixing with entropy, which you met in Phase 2, instead of Gini. The results are usually similar.

Quick check
A node holds 8 customers who stayed and 2 who churned. What's its Gini impurity?
  1. 0.8
  2. 0.2
  3. 0.5
  4. 0.32
Show the answer

0.32

The shares are 0.8 and 0.2, so 1 − (0.8² + 0.2²) = 1 − 0.68 = 0.32. That's the same as the 4-in-5 group above: Gini depends only on the shares, not on the group's size.

Scoring a question

A question splits one group into two sides. The left side holds the examples at or below the cutoff. The right side holds the ones above it.

To score the question, the tree finds the Gini impurity of each side. Then it averages the two, giving each side weight by its size. A side with more customers counts for more. Lower scores are better.

score=nLnGL+nRnGR\text{score} = \frac{n_L}{n} G_L + \frac{n_R}{n} G_R
  • nn is the number of examples in the group before the split.
  • nLn_L is the number that go left.
  • GLG_L is the Gini impurity of the left side.
  • nRn_R is the number that go right.
  • GRG_R is the Gini impurity of the right side.

In plain words: take each side's impurity and multiply it by that side's share of the examples. Add the two results. That's the question's score.

Worked example

Six customers, with the days since each one's last order:

days since last order103060120200300
churned000101

Before any split, 4 stayed and 2 churned. The shares are 2/3 and 1/3, so the impurity is 1 − (4/9 + 1/9) = 4/9, about 0.444.

Now try the question "days since last order 60 or fewer?":

  1. Left side: the customers at 10, 30 and 60 days. All 3 stayed, so its impurity is 0.
  2. Right side: the customers at 120, 200 and 300 days. One stayed and 2 churned, so its impurity is 4/9, about 0.444.
  3. Each side holds 3 of the 6 customers, a share of 1/2.
  4. Score: 1/2 × 0 + 1/2 × 0.444 = 0.222.

One question cut the mixing in half, from 0.444 to 0.222.

Quick check
A group has 2 customers who stayed and 2 who churned. What is its impurity?
print(1 - (0.5 ** 2 + 0.5 ** 2))
  1. 1.0
  2. 0.25
  3. 0.0
  4. 0.5
Show the answer

0.5

Half of each gives 1 − (0.25 + 0.25) = 0.5. It's as mixed as two classes can get. A group that's all one class would score 0.

Trying every question

The tree tries every feature and every possible cutoff, and keeps the question with the lowest score. Then it repeats the search inside each side, and inside those sides, and so on.

This approach is greedy. At each step it takes the best question right now. It never looks ahead to check whether a different question would lead to a better tree later. Think of a shopper who grabs the cheapest item in each aisle without planning the whole basket. It's fast and usually good enough, but it doesn't promise the best possible tree.

scikit-learn, the library used here, puts each cutoff halfway between two neighboring values, such as 180.5 between 180 and 181. That's why its printed cutoffs often end in .50.

Worked example

Find the best cutoff by hand

Here are the six customers from the worked example. The code tries each value of days as a cutoff and prints its score. It uses plain Python, with no NumPy.

days    = [10, 30, 60, 120, 200, 300]
churned = [0,  0,  0,  1,   0,   1]

def gini(labels):
    n = len(labels)
    return 1 - sum((labels.count(c) / n) ** 2 for c in set(labels))

def split_score(cutoff):
    left = [y for d, y in zip(days, churned) if d <= cutoff]
    right = [y for d, y in zip(days, churned) if d > cutoff]
    n = len(churned)
    return len(left) / n * gini(left) + len(right) / n * gini(right)

print(f"no split yet:   {gini(churned):.3f}")
for cutoff in days[:-1]:
    print(f"days <= {cutoff:>3}:  {split_score(cutoff):.3f}")
In the lesson you can change this and run it.

It prints 0.444 before any split. The cutoffs then score 0.400, 0.333, 0.222, 0.417 and 0.267. The lowest is days <= 60 at 0.222, the same answer you worked out by hand. So a tree would ask that question first. days[:-1] skips the largest value, 300. A cutoff of 300 would put everyone on the left and leave the right side empty. You'll build this same search with NumPy in the exercise.

Quick check
A split sends 4 customers left (all stayed) and 4 right (2 stayed, 2 churned). What's its score?
  1. 0.5
  2. 0.25
  3. 0
  4. 0.125
Show the answer

0.25

Left: impurity 0, weighted 4/8 × 0 = 0. Right: a 50/50 group has impurity 0.5, weighted 4/8 × 0.5 = 0.25. Add them: 0 + 0.25 = 0.25.

Depth: from too simple to memorizing

Think of two students preparing for an exam with last year's paper. One learns the main ideas. The other memorizes every answer word for word. The second scores 100% on the old paper and struggles on the new one.

A tree that's left to grow acts like the second student. It keeps splitting until every leaf is pure. Real data is noisy: some customers leave for reasons no column records. So on noisy data, pure leaves mean a leaf for nearly every customer. The tree is perfect on its training data and poor on new data. You control how far it grows with:

  • max_depth: the most questions it can ask in a row.
  • min_samples_leaf: every leaf must hold at least this many training examples. A leaf with one customer is a memory, not a pattern.
  • Pruning: grow the full tree, then cut back branches that don't help on validation data (data held back from training).

The code below holds back 30% of the customers as a test set (test_size=0.3). The tree never trains on them. stratify keeps the share of churners the same in both parts. Then it trains trees of growing depth, where None means no limit. For each tree it prints the number of leaves, and its accuracy on training and test data. Accuracy is the share of customers it predicts correctly.

import pandas as pd
import requests
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["days_since_last_order", "orders_last_year", "support_tickets", "trail_club", "tenure_months", "avg_order_value"]
X_tr, X_te, y_tr, y_te = train_test_split(df[features], df["churned"], test_size=0.3, random_state=0, stratify=df["churned"])
for depth in [1, 2, 3, 5, 8, None]:
    t = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(X_tr, y_tr)
    print(f"max_depth {str(depth):<5} leaves {t.get_n_leaves():>3}  train {t.score(X_tr, y_tr):.3f}  test {t.score(X_te, y_te):.3f}")

Training accuracy climbs to 1.0 as depth grows. Test accuracy peaks early and then slides. The gap between the two is overfitting: the deep tree has memorized its training customers. It gets its own lesson soon.

Try it now

Raise the depth and watch the score on seen points climb to 100% while the score on new points stops improving.

Quick check
Tree A has no depth limit: train accuracy 1.000, test 0.71. Tree B has max_depth=3: train 0.84, test 0.81. Which would you rather use on new customers?
  1. Either one, they're equally good
  2. Neither, a tree should always score 1.000 on test data
  3. Tree B, because it does better on customers it never saw
  4. Tree A, because it's perfect on training data
Show the answer

Tree B, because it does better on customers it never saw

The test score is the one that predicts real use. Tree A's perfect training score is a sign of memorizing, and its test score shows it.

Strengths and weaknesses

StrengthsWeaknesses
rules a person can readunstable: a small change in the data can change the whole tree
no rescaling needed; handles numbers and categories togetheroverfits when deep
catches cutoffs and combinations ("only if both are true")predictions jump in steps instead of changing smoothly
fasta single tree is rarely the most accurate model

Why no rescaling? A tree only asks whether one feature is above a cutoff. Measure time in weeks instead of days and a cutoff of 182 days becomes 26 weeks. The same customers land on the same side.

Why step-shaped? Every customer who reaches a leaf gets that leaf's prediction. Nudge a feature and the prediction stays put until it crosses a cutoff. Then it jumps.

Why unstable? The first question shapes everything below it. Change a few rows, and a different first question might win by a hair. Then the whole tree underneath changes too.

Instability and overfitting are exactly what ensembles fix. They grow many trees and combine their answers.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

Why don't decision trees need feature scaling?

  1. They split on thresholds of one feature at a time, so a feature's units don't matter
  2. They scale every feature internally before they look for the first split in the data
  3. They only work with 0 or 1 features, so everything else gets converted before training
  4. Scaling makes trees overfit, because the smaller numbers let splits fall closer together

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson