Decision trees
Learn a flowchart of yes/no questions.
Create a free account Try part of it here
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
- A flowchart learned from data
- How a split is chosen: purity
- Scoring a question
- Trying every question
- Find the best cutoff by hand
- Depth: from too simple to memorizing
- 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))
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 withweightsis a leaf.weightscounts the training customers that end up there: how many stayed, then how many churned.classis 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.
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))
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:
One symbol at a time:
- is the Gini impurity of one group.
- stands for a class, such as "stayed" or "churned".
- is the share of the group in class . If 3 of 4 customers stayed, the share for "stayed" is 0.75.
- uses the Greek letter sigma. It means "add this up over every class", like a
forloop.
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.
- Shares: 4 ÷ 5 = 0.8 stayed, and 1 ÷ 5 = 0.2 churned.
- Squares: 0.8 × 0.8 = 0.64, and 0.2 × 0.2 = 0.04.
- Add them: 0.64 + 0.04 = 0.68.
- 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 is its share, . The chance that both picks are class is , which is . 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.
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.
- is the number of examples in the group before the split.
- is the number that go left.
- is the Gini impurity of the left side.
- is the number that go right.
- 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 order | 10 | 30 | 60 | 120 | 200 | 300 |
|---|---|---|---|---|---|---|
| churned | 0 | 0 | 0 | 1 | 0 | 1 |
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?":
- Left side: the customers at 10, 30 and 60 days. All 3 stayed, so its impurity is 0.
- Right side: the customers at 120, 200 and 300 days. One stayed and 2 churned, so its impurity is 4/9, about 0.444.
- Each side holds 3 of the 6 customers, a share of 1/2.
- Score: 1/2 × 0 + 1/2 × 0.444 = 0.222.
One question cut the mixing in half, from 0.444 to 0.222.
print(1 - (0.5 ** 2 + 0.5 ** 2))
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.
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}")
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.
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.
Raise the depth and watch the score on seen points climb to 100% while the score on new points stops improving.
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
| Strengths | Weaknesses |
|---|---|
| rules a person can read | unstable: a small change in the data can change the whole tree |
| no rescaling needed; handles numbers and categories together | overfits when deep |
| catches cutoffs and combinations ("only if both are true") | predictions jump in steps instead of changing smoothly |
| fast | a 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
Try a quiz question
Why don't decision trees need feature scaling?