Skip to content
Expedify
Decision Trees

Module · Decision Trees

Gini impurity — how mixed is this pile?

Lesson 2 of 8 · 12 min

Twenty questions chose questions by how evenly they split the field. That rule works until a leaf holds a mixture of answers.

Then you need to know how mixed. That is a number, and this lesson builds it.

The number is called Gini impurity. Every later lesson in this path uses it.

Ten deals: 6 won and 4 lost

The whole dataset for this lesson.

won

Deals
6
Share of the pile
6 / 10 = 0.6

lost

Deals
4
Share of the pile
4 / 10 = 0.4

It is ten deals so that you can check every number on this page on paper. The job is to put one number on that pile. An all-won pile should score lowest. A half-and-half pile should score highest.

Gini impurity is how often two picks from a pile disagree

Reach into the pile and take a deal. Put it back. Take another. Did the two agree?

The definition, before any arithmetic.

all won

How often two picks disagree
never

half won, half lost

How often two picks disagree
about half the time

6 won, 4 lost

How often two picks disagree
somewhere in between

That is Gini impurity. It behaves the way a tree needs, and it was defined without a formula.

So play it before deriving anything. The notebook takes two deals at random, a hundred thousand times, and counts the disagreements. That run gives 0.483.

They agree 0.52 of the time, so they disagree 0.48

Now the arithmetic. Two picks agree if both are won, or if both are lost.

  • both won: 0.6 × 0.6 = 0.36
  • both lost: 0.4 × 0.4 = 0.16
  • they agree: 0.36 + 0.16 = 0.52
  • so they disagree: 1 − 0.52 = 0.48

Square each share, add them up, subtract from one. That is all of it.

The arithmetic gives 0.48. Playing the game gives 0.483.

Gini=1kpk2\text{Gini} = 1 - \sum_{k} p_k^{\,2}
p is the share of the pile with one outcome. Square it, add up all of them, subtract from one.

The formula and the game give the same answer. The squares are there for a reason. A share squared is the chance of drawing that outcome twice in a row.

A pile that agrees scores 0. A half-and-half pile scores 0.5.

All eleven possible piles of ten, scored.decision-trees/how-mixed-is-this-pile.ipynb

The curve is zero at both ends and highest in the middle. That is the behaviour a tree needs from this number.

Impurity falls slowly at first and then quickly.

5 won, 5 lost

Gini impurity
0.5

6 won, 4 lost

Gini impurity
0.48

9 won, 1 lost

Gini impurity
0.18

10 won, 0 lost

Gini impurity
0

Notice how flat the top is. Moving one deal from a 5-and-5 pile changes the score by only 0.02. A tree can therefore make a split that looks small and find a much better one below it.

With three outcomes the worst score is 0.667, not 0.5

Nothing above assumed there were two outcomes. Add a third, for deals that broke even, and the same arithmetic works. Five won, three lost and two even scores 0.62.

The ceiling is 1 − 1/k, where k is the number of outcomes.

2

Worst possible impurity
0.5

3

Worst possible impurity
0.667

4

Worst possible impurity
0.75

10

Worst possible impurity
0.9

So 0.6 is a bad score for a yes-or-no question and a good one for a question with five answers. Compare gini scores inside one problem. Never compare them across two.

Weight the two sides of a split by how many deals are in them

A question does not score itself. It takes one pile and makes two. What you compare against is the impurity of the two piles you are left with.

Ten deals, split by whether the buyer had budget signed off.decision-trees/how-mixed-is-this-pile.ipynb

One side comes out clean and the other stays messy. The question now is how to combine those two numbers into one.

Four spotless deals on one side, six messy ones on the other.

weighted by how many deals are on each side

Result
0.267

plain average of the two sides

Result
0.222

Always weight by size. A plain average lets four clean deals count as much as six messy ones. It reports a better number for a worse split.

What you now have

One number for how mixed a pile is. It is zero when everything agrees. It peaks in the middle. Its ceiling depends on the number of outcomes. After a split, weight it by pile size.

There is a second measure that does the same job. It answers a question this path has already asked once: how many yes-or-no questions is the answer worth? That is entropy, and it is the next lesson.

Ten deals, the two-picks game, and every pile of ten

content/notebooks/decision-trees/how-mixed-is-this-pile.ipynb

Change the pile and predict the score before running it. Worth trying: replace “lost” with three different loss reasons. The pile has not changed and the impurity has.

Show the code6 cells
Ten closed deals — six won, four lost
DEALS = ["won"] * 6 + ["lost"] * 4

n = record("n_deals", len(DEALS))
won = record("n_won", DEALS.count("won"))
lost = record("n_lost", DEALS.count("lost"))
print(f"{won} won, {lost} lost, out of {n}")
print(f"share won  = {won}/{n} = {won / n}")
print(f"share lost = {lost}/{n} = {lost / n}")
Play the two-draws game 100,000 times and count the disagreements
import random
random.seed(3)

TRIALS = 100_000
disagreed = sum(1 for _ in range(TRIALS)
                if random.choice(DEALS) != random.choice(DEALS))

measured = record("measured_gini", round(disagreed / TRIALS, 3))
print(f"{disagreed} of {TRIALS} pairs disagreed  ->  {measured}")
Gini impurity, written out, and checked against the simulation
def gini(pile):
    """1 - the chance two draws agree."""
    if not pile:
        return 0.0
    shares = [pile.count(k) / len(pile) for k in set(pile)]
    return 1 - sum(s * s for s in shares)

by_hand = 1 - (6 / 10) ** 2 - (4 / 10) ** 2
print(f"by hand      {by_hand:.3f}")
print(f"by function  {gini(DEALS):.3f}")
print(f"by playing   {measured:.3f}")

record("gini_by_hand", round(by_hand, 3))
record("gap_to_simulation", round(abs(by_hand - measured), 3))
Every possible pile of ten, scored
piles = [(w, gini(["won"] * w + ["lost"] * (10 - w))) for w in range(11)]
for w, g in piles:
    print(f"{w:>2} won, {10 - w:>2} lost   {g:.2f}  {'#' * round(g * 40)}")

record("gini_5_5", round(gini(["won"] * 5 + ["lost"] * 5), 3))
record("gini_9_1", round(gini(["won"] * 9 + ["lost"]), 3))
record("gini_10_0", round(gini(["won"] * 10), 3))
record("gap_5_5_to_6_4", round(gini(["won"] * 5 + ["lost"] * 5) - by_hand, 3))

def plot(ax):
    ps = [i / 200 for i in range(201)]
    ax.plot(ps, [1 - p * p - (1 - p) ** 2 for p in ps], color="#9aa0aa", lw=1.4, zorder=1)
    ax.scatter([w / 10 for w, _ in piles], [g for _, g in piles], s=34, color="#e2574c", zorder=3)
    for w, g in [(0, 0.0), (5, 0.5), (6, by_hand)]:
        label = {0: "0 won\nnot mixed at all", 5: "5 and 5\nas mixed as it gets",
                 6: "our pile\n6 won, 4 lost"}[w]
        ax.annotate(label, (w / 10, g), textcoords="offset points",
                    xytext=(0, 14 if w == 0 else -40), ha="center", fontsize=9, color="#77777f")
    ax.set_xlabel("Share of the pile that were won")
    ax.set_ylabel("Gini impurity")
    ax.set_ylim(-0.16, 0.62)

save_fig("every-pile-of-ten", plot, figsize=(7, 4.0))
The ceiling rises with the number of outcomes
for k in range(2, 7):
    even = [str(i) for i in range(k)] * 12
    print(f"{k} outcomes, evenly spread   {gini(even):.3f}   (= 1 - {k} x (1/{k})^2)")

record("max_two", round(gini(["a", "b"] * 12), 3))
record("max_three", round(gini(["a", "b", "c"] * 12), 3))

three = ["won"] * 5 + ["lost"] * 3 + ["broke even"] * 2
record("gini_three_way", round(gini(three), 3))
print(f"\n5 won, 3 lost, 2 broke even  ->  {gini(three):.3f}")
The impurity you are left with after a split, weighted by pile size
SPLIT = {
    "budget signed off": ["won"] * 4,
    "no budget":         ["won"] * 2 + ["lost"] * 4,
}

after = 0.0
for side, pile in SPLIT.items():
    w = len(pile) / n
    print(f"{side:<20} {len(pile):>2} deals   impurity {gini(pile):.2f}   weight {w:.1f}")
    after += w * gini(pile)

record("after_split", round(after, 3))
record("plain_average", round(sum(gini(p) for p in SPLIT.values()) / len(SPLIT), 3))
print(f"\nweighted    {after:.3f}   <- what a tree compares against")
print(f"plain mean  {sum(gini(p) for p in SPLIT.values()) / len(SPLIT):.3f}   <- wrong: it lets 4 spotless deals count as much as 6 messy ones")

def plot2(ax):
    labels = ["before the split\n10 deals", "budget signed off\n5 deals", "no budget\n5 deals"]
    vals = [gini(DEALS), gini(SPLIT["budget signed off"]), gini(SPLIT["no budget"])]
    ax.bar(labels, vals, color=["#9aa0aa", "#e2574c", "#e2574c"], width=0.55)
    for i, v in enumerate(vals):
        ax.text(i, v + 0.015, f"{v:.2f}", ha="center", fontsize=10)
    ax.axhline(after, ls="--", lw=1.2, color="#3b6fd4")
    ax.text(2.42, after + 0.015, f"weighted\n{after:.2f}", ha="center", fontsize=9, color="#3b6fd4")
    ax.set_ylabel("Gini impurity")
    ax.set_ylim(0, 0.58)
    ax.grid(axis="x", visible=False)

save_fig("before-and-after-a-split", plot2, figsize=(7, 3.9))