Decision trees: a model made of yes/no questions

ml basics

On this page
  1. The idea: twenty questions
  2. A tree for stress months
  3. How it picks the questions: Gini impurity
  4. The big danger: trees memorise
  5. The code
  6. Strengths and weaknesses
  7. Summary
  8. Next
  9. References

Decision trees are covered in sections 8.1 and 8.2 of An Introduction to Statistical Learning. They matter for two reasons. On their own, they are one of the easiest models to explain to a business user. And they are the building block of the strongest everyday models in finance: bagging, random forests and boosting, which are the next posts in this series.

The linear regression post drew a straight line. A tree does something different: it asks questions.

The idea: twenty questions

A good risk manager, asked “will next month be rough?”, doesn’t do arithmetic. They ask questions:

Has the market been jumpy lately? Yes? Then it’s risky. Has it also been falling? Yes? Even riskier.

A decision tree is exactly that, learned from data. Each node asks one yes/no question about one feature. Each answer sends you down a branch. At the bottom, each leaf gives the prediction: for classification, the share of past months in that leaf that were stress months.

A tree for stress months

I trained a small tree (at most 3 questions deep) on the 3,685 ticker-months of 2019 to 2021 from the regression vs classification post, 20.5% of which were followed by a stress month (next 20 days’ volatility above 30% a year). The seven features are the ones from that post: the last 1-, 5- and 20-day returns, the 5- and 20-day volatility, the price against its 20-day average, and the Bollinger position.

This is the tree it learned. Each leaf shows the share of training months there that turned out stressed:

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

20-day volatility above 35%?

above 23%?

5-day volatility above 50%?

above 17%?

20-day return below −9%?

20-day volatility above 41%?

above 59%?

3% stress, 1,529 months

12% stress, 775 months

24% stress, 776 months

71% stress, 72 months

42% stress, 141 months

69% stress, 157 months

81% stress, 89 months

99% stress, 146 months

Read it like a flowchart. SPY in late July 2023, with 20-day volatility of 7.7%: not above 35% → not above 23% → not above 17% → the calm leaf, a 3%\key{3\%} chance of a stress month (it came in at 11%). SPY in May 2022, volatility 35.2% and down 11.9% on the month: above 35% → 5-day volatility 40.8%, not above 50% → not above 41% → 42%\key{42\%} (it came in at 29%, just under the line).

Notice what the tree found without being told:

  • Recent volatility is the first question. It’s the single most useful thing to know, and the tree asks about it again and again at different levels.
  • “Falling” only matters in the middle. For months already at 23 to 35% volatility, a 9% fall turns a 24% chance into 71%. In calm months the tree never asks about returns at all. A straight line can’t do this: it gives each feature one fixed effect everywhere.
  • Four of the seven features never appear. The 1- and 5-day returns, the moving-average ratio and the Bollinger position didn’t help once volatility was known, so the tree ignores them.

How it picks the questions: Gini impurity

At each step the tree tries every feature and every possible cut-off, and keeps the question that best separates stress from calm. To score “how mixed” a group is, it uses the Gini index. An Introduction to Statistical Learning (ISLP) writes it as equation 8.6:

the impurity score: G (Gini) and D (entropy)the class shares in a group

G=∑k=1Kp^mk(1−p^mk)\key{G} = \sum_{k=1}^{K} \good{\hat{p}_{mk}}\left(1 - \good{\hat{p}_{mk}}\right)

where p^mk\good{\hat{p}_{mk}} is the share of group (region) mm that belongs to class kk. Because the shares add up to 1, this is the same number as the form scikit-learn’s documentation uses:

G=∑kpk−∑kpk2=1−∑kpk2\key{G} = \sum_k \good{p_k} - \sum_k \good{p_k^2} = 1 - \sum_k \good{p_k^2}
SymbolMeaning
kkeach class, here “calm” and “stress”
pk\good{p_k}the share of the group in class kk
G\key{G}0 if the group is pure (all one class); higher the more mixed

Plain meaning: pick two months at random from the group. G\key{G} is the chance they belong to different classes. A pure group scores 0. A 50/50 group scores 0.5, the maximum for two classes.

Worked example: the first question

All 3,685 training months: 756 stress (20.52%) and 2,929 calm (79.48%).

Gall=1−(0.79482+0.20522)=0.3261\key{G_{\text{all}}} = 1 - (0.7948^2 + 0.2052^2) = 0.3261

For a number like volatility, the tree also has to choose a cut-off; it tries every value in the data and keeps the best. For 20-day volatility the best cut-off is 35.3%:

GroupMonthsStressGini
Volatility at or below 35.3%3,1523720.2082
Above 35.3%5333840.4028

The score of a split is the weighted average of its groups’ Gini, weighted by group size:

Gsplit=3,1523,685×0.2082+5333,685×0.4028=0.2363\begin{aligned} \key{G_{\text{split}}} &= \frac{3{,}152}{3{,}685} \times 0.2082 + \frac{533}{3{,}685} \times 0.4028 \\[10pt] &= 0.2363 \end{aligned}

The split lowers impurity by 0.3261−0.2363=0.08980.3261 - 0.2363 = 0.0898. The best cut-off on 5-day volatility lowers it by 0.0790, on the 20-day return by 0.0474\key{0.0474}, and on the Bollinger position by only 0.0051. The 20-day volatility wins, so it becomes the first question. Then the tree repeats the same search inside each group, and keeps going.

One thing to notice: the “above 35.3%” group (0.4028) is more mixed than the starting group. That’s fine. It’s small, and the big “calm” group became much purer. The weighted average is what counts.

An alternative score is entropy, ISLP equation 8.7: D=−∑kp^mklog⁡p^mk\key{D} = -\sum_k \good{\hat{p}_{mk}} \log \good{\hat{p}_{mk}}. Mitchell’s Machine Learning (chapter 3) builds his whole tree algorithm on it, using base-2 logs and choosing the split with the biggest drop in entropy, which he calls information gain. In practice it usually picks the same questions as Gini. For regression trees (predicting a number), the score is the spread of the numbers in each group, measured by squared error, and each leaf predicts the average of its training values.

The big danger: trees memorise

If you let a tree keep asking questions, it will eventually put every training month in its own leaf, and be “perfect” on the data it has seen. I trained trees of different depths and scored them with AUC: the chance that the model gives a randomly chosen stress month a higher risk score than a randomly chosen calm one. 0.5 is a coin flip, 1.0 is perfect. The test months are 2022 and 2023, after the training years.

Max depthLeavesScore on training monthsScore on test months
120.7290.608
240.8350.787
380.8680.804
5290.8940.797
8950.9280.758
122050.9780.609
no limit3641.0000.615

The fully grown tree is perfect on training months and barely better than the one-question stump on new ones. It learned the noise in its training data, like a student who memorised last year’s exam answers. This is overfitting, and it’s the subject of the bias and variance post.

In practice you stop the tree early by limiting max_depth or the minimum number of rows in a leaf (min_samples_leaf), and choose the limit by testing on data the tree hasn’t seen.

The code

from sklearn.tree import DecisionTreeClassifier, export_text

# X: one row per ticker-day -> the seven features (returns, volatilities, moving-average ratio, Bollinger)
# y: 1 = the next 20 days were a stress month, 0 = calm
tree = DecisionTreeClassifier(max_depth=3, min_samples_leaf=20, random_state=0)
tree.fit(X_train, y_train)

print(export_text(tree, feature_names=names))          # the questions, as text
tree.predict_proba(X_test)[:, 1]                       # 0.03 for calm July 2023, 0.42 for May 2022
tree.feature_importances_                              # 0.87 of it on 20-day volatility

Strengths and weaknesses

StrengthsWeaknesses
Easy to explain: it’s a flowchartOverfits easily if grown deep
Handles “it depends” patterns (interactions) on its ownUnstable: a small change in the data can change the whole tree
No need to scale or transform featuresPredictions jump in steps, not smoothly
Ignores features that don’t helpOne tree is rarely the most accurate model

That instability sounds like a flaw. The next three posts are built on it. If one tree is unstable, train many trees on slightly different data and average them.

Summary

IdeaFormula / ruleRemember it as
A treeyes/no questions, leaves give the answerA learned flowchart
Gini indexG=∑kp^mk(1−p^mk)=1−∑kpk2\key{G} = \sum_k \good{\hat{p}_{mk}}(1 - \good{\hat{p}_{mk}}) = 1 - \sum_k \good{p_k^2}Chance two random picks differ
Best splitbiggest drop in weighted impurityThe question that best separates
Regression treesplit on squared error, leaf = averageSame idea, for numbers
Overfittingdeep tree: perfect on training, poor on new dataLimit depth, test on later data

Next

In the next posts: bias and variance, the trade-off behind overfitting, then bagging, the first way to combine many trees.

References