Skip to content
Expedify
13 min

Greedy search — why the best step is not the best path

The tree searched too. It just never looked back at a question it had already answered. Build every tree it could have built instead, and one of them is better — starting with the question you were taught to reject.

The cost is a choice argued with the third of the four steps. This lesson argues with the fourth.

The line was found by a formula that lands straight on the answer. The tree was found by searching, one question at a time.

The tree searched too. It just never looked back at a question it had already answered. That turns out to cost something, and on 24 films it can be measured exactly.

Greedy takes the best question available now, then starts again on each side

Growing it, and knowing when to stop built the tree this way. Score every question by the impurity it removes, take the best one, then repeat on each side with the questions that are left.

This lesson restricts the tree to two questions deep, so that every tree it could have been can be built and scored. Here is the one greedy builds.

The tree greedy builds, two questions deep
yesyesnonoyesnoA star?24 films - 12 hit3,000+ screens?10 films - 8 hithit7 films - 6 hithit3 films - 2 hitA sequel?14 films - 4 hithit3 films - 2 hitflop11 films - 2 hit
It gets 19 of the 24 films right.

Its first question is the best first question. Is there a star the audience turns up for? removes 0.129 of impurity, more than any other question in the table.

There are 125 two-question trees on these 24 films

Small enough to build all of them. A two-question tree is a first question, and then each side either stops or asks one more.

Every tree two questions deep, counted.

The first question

Choices
5

On the yes side: stop, or one of the rest

Choices
5

On the no side: stop, or one of the rest

Choices
5

Trees in total

Choices
5 x 5 x 5 = 125

So greedy chose one of 125, and it chose without looking at the other 124. Every one of them can now be scored.

Greedy's tree is 17th on the very measure it used at every step

Start with impurity, because that is what greedy was consulting. Add up the impurity left in a finished tree's leaves, weighted by how many films each leaf holds.

Each of the 125 trees, scored on the impurity it leaves behind.how-a-model-is-fitted/a-search-that-cannot-go-back.ipynb

Greedy's tree scores 0.319. 16 of the 125 score lower, and the best of them reaches 0.264.

Nothing ever added the tree up and asked

That is not a bug in the code. Gini was computed at each split, for that split, and the best available one was taken. The finished tree was never scored at all.

This is what no cost it minimises means, and it is now measured. The four steps in both left that cell of the table empty. A tree does not have a number it is making small — it has a rule it applies locally.

The line is the other way round. The line wrote down one number for the whole model and then went looking for the parameters that make it smallest.

Only 1 of the 125 trees beats greedy

Now the other ranking, and the one anybody would actually ask for. Score all 125 trees on how many films they get right.

The same 125 trees, counted by films they get right.how-a-model-is-fitted/a-search-that-cannot-go-back.ipynb

Greedy gets 19 of 24. One tree gets 20. Greedy is second, out of everything it could have built.

The better tree starts with the question that removed no impurity at all

Look at what that one tree asks first.

The tree greedy builds, and the best tree available.

The tree greedy builds

First question
Is there a star the audience turns up for?
Films right
19 of 24
Total impurity
0.319

The best two-question tree

First question
Did it open in summer?
Films right
20 of 24
Total impurity
0.264

Its first question is Did it open in summer?, and its gain is 0.000.

Which question to ask first measured that question and held it up as the worst in the table. It splits the films twelve and twelve, both halves six hits and six flops, and removes nothing.

The best two-question tree, 20 of 24
yesyesnonoyesnoOpened in summer?24 films - 12 hit3,000+ screens?12 films - 6 hithit6 films - 5 hitflop6 films - 1 hitA sequel?12 films - 6 hithit8 films - 6 hitflop4 films - 0 hit
A first question worth nothing, followed by two that are worth a great deal.

A question that buys nothing on its own can set up two that buy a lot. Greedy cannot see that, because greedy asks what is best now.

Taking the best step is not the same as taking the best path

This is the whole finding, and it is worth stating plainly. Greedy is a search that commits. Once a question is asked, it is never revisited.

It is used anyway, and for a good reason. Allowing three questions instead of two puts the number of trees past what any enumeration can reach. A real tree on real data is far deeper than three.

Greedy is what you use when checking every option is impossible. It returns a good answer quickly. It does not return the best one, and nothing in the tree tells you which you got.

A line's bowl has one bottom. A tree's search has no bowl and no promise.

Both models searched. The difference is what the search was walking on.

Step four, in the two models you have built.

What it searched

The line
Two numbers, both free to move
The tree
Which question to ask next

What told it where to go

The line
The cost, which is lower on one side
The tree
The gain of each question available now

Can it go back?

The line
It never needs to — there is one bottom
The tree
No. A question asked is not revisited.

What you get

The line
The best parameters for that form
The tree
A good tree, and no way to know how good

So knowing which kind of search fitted your model tells you what its answer is worth. One of them came with a guarantee. The other came with a good result and no claim about it.

What you can now do

  • Ask four questions of any model. What shape may the answer be, what is free, what is it scoring, and how did it search.
  • Expect two of them to come back empty sometimes. A tree has no fixed list of parameters and no cost it makes small, and it is still a model worth using.
  • Ask what the cost was measuring before you trust what it scored. It was a choice, and a different one gives a different model from the same data.
  • Ask whether the search could have stopped early. A formula lands on the answer. A greedy search returns something good and says nothing about what it missed.

One thing this path never did: check any of it on data the model has not seen. Every number in these three lessons was measured on the rows the model was fitted to.

Perfect on what it has seen and The honest number both showed why that matters. Fitting a model and knowing whether to trust it are two different jobs.

All 125 two-question trees, built and scored twice

content/notebooks/how-a-model-is-fitted/a-search-that-cannot-go-back.ipynb

Allow three questions instead of two and watch the count of trees run away from you — which is the reason greedy exists. Also worth trying: score the trees by entropy instead of gini and see whether the same one still wins.

Show the code6 cells
The same 24 films decision-trees used
QUESTIONS = {
    'star':   'Is there a star the audience turns up for?',
    'summer': 'Did it open in summer?',
    'sequel': 'Is it a sequel?',
    'wide':   'Did it open on 3,000 screens or more?',
    'budget': 'Did it cost over $100 million?',
}
KEYS = list(QUESTIONS)
SHORT = {'star': 'A star?', 'summer': 'Opened in summer?', 'sequel': 'A sequel?',
         'wide': '3,000+ screens?', 'budget': 'Over $100m?'}

#                          star summer sequel wide budget  hit
FILMS = {
    'Harbour Lights':       (1,   1,     0,     0,   0,     1),
    'Ironwake II':          (1,   1,     1,     1,   0,     1),
    'The Quiet Ledger II':  (1,   0,     1,     0,   0,     1),
    'Nightfall Divide':     (1,   1,     0,     1,   1,     1),
    'Ironwake III':         (1,   0,     1,     1,   1,     1),
    'Cinder Coast II':      (1,   0,     1,     1,   1,     1),
    'Redline Returns':      (1,   0,     1,     1,   1,     1),
    'Paper Kingdoms':       (1,   1,     0,     1,   0,     1),
    'Glass Monsoon':        (1,   1,     0,     0,   1,     0),
    'Cinder Coast III':     (1,   0,     1,     1,   0,     0),
    'Vermilion Rising II':  (0,   0,     1,     1,   1,     1),
    'Saltwater Sunday':     (0,   1,     0,     1,   1,     1),
    'Field of Static II':   (0,   0,     1,     0,   0,     1),
    'Wildflower County':    (0,   1,     0,     1,   0,     1),
    'Tin Sky':              (0,   1,     0,     0,   0,     0),
    'The Cartographer':     (0,   0,     0,     0,   0,     0),
    'Neon Bazaar':          (0,   1,     0,     0,   1,     0),
    'Ash & Ivory II':       (0,   0,     1,     1,   0,     0),
    'Slow River':           (0,   0,     0,     0,   0,     0),
    'The Understudy':       (0,   1,     0,     0,   0,     0),
    'Meridian':             (0,   0,     0,     1,   1,     0),
    'Copper Harbour':       (0,   1,     0,     1,   1,     0),
    'The Winter Post':      (0,   0,     0,     0,   0,     0),
    'Little Eden':          (0,   1,     0,     0,   1,     0),
}
ALL = list(FILMS)
answers = lambda f: FILMS[f][:len(KEYS)]
hit = lambda f: FILMS[f][-1]
record('n_films', len(ALL))
record('n_questions', len(KEYS))
print(f'{len(ALL)} films, {sum(hit(f) for f in ALL)} of them hits')
Impurity, gain, and the greedy rule
def split(films, q):
    i = KEYS.index(q)
    return [f for f in films if answers(f)[i]], [f for f in films if not answers(f)[i]]

def gini(films):
    if not films:
        return 0.0
    p = sum(hit(f) for f in films) / len(films)
    return 1 - p * p - (1 - p) ** 2

def gain(films, q):
    a, b = split(films, q)
    return gini(films) - (len(a) * gini(a) + len(b) * gini(b)) / len(films)

record('root_gini', round(gini(ALL), 3))
for q in sorted(KEYS, key=lambda q: -gain(ALL, q)):
    print(f'{QUESTIONS[q]:<42} gain {gain(ALL, q):.3f}')
The tree greedy builds, restricted to two questions
greedy_root = max(KEYS, key=lambda q: gain(ALL, q))
g_yes, g_no = split(ALL, greedy_root)
greedy_l = max([q for q in KEYS if q != greedy_root], key=lambda q: gain(g_yes, q))
greedy_r = max([q for q in KEYS if q != greedy_root], key=lambda q: gain(g_no, q))

def leaves_of(root, ql, qr):
    yes, no = split(ALL, root)
    out = []
    for side, q in ((yes, ql), (no, qr)):
        if q is None:
            out.append(side)
        else:
            a, b = split(side, q)
            out += [a, b]
    return out

def right(films):
    return max(sum(hit(f) for f in films), len(films) - sum(hit(f) for f in films))

score = lambda root, ql, qr: sum(right(l) for l in leaves_of(root, ql, qr))
total_gini = lambda root, ql, qr: sum(len(l) * gini(l) for l in leaves_of(root, ql, qr)) / len(ALL)

def as_tree(root, ql, qr):
    yes, no = split(ALL, root)
    def side(films, q):
        if q is None:
            return leaf(films)
        a, b = split(films, q)
        return {'q': SHORT[q], 'note': f'{len(films)} films - {sum(hit(f) for f in films)} hit',
                'yes': leaf(a), 'no': leaf(b)}
    def leaf(films):
        return {'leaf': 'hit' if sum(hit(f) for f in films) * 2 > len(films) else 'flop',
                'note': f'{len(films)} films - {sum(hit(f) for f in films)} hit'}
    return {'q': SHORT[root], 'note': f'{len(ALL)} films - {sum(hit(f) for f in ALL)} hit',
            'yes': side(yes, ql), 'no': side(no, qr)}

greedy_score = score(greedy_root, greedy_l, greedy_r)
greedy_gini = total_gini(greedy_root, greedy_l, greedy_r)
record('greedy_question', QUESTIONS[greedy_root])
record('greedy_gain', round(gain(ALL, greedy_root), 3))
record('greedy_right', greedy_score)
record('greedy_gini', round(greedy_gini, 3))
record('greedy_tree', as_tree(greedy_root, greedy_l, greedy_r))
print(f'greedy: {QUESTIONS[greedy_root]} -> {greedy_score}/{len(ALL)}, total gini {greedy_gini:.4f}')
All 125 two-question trees, built and scored twice
trees = []
for root in KEYS:
    others = [q for q in KEYS if q != root]
    for ql in [None] + others:
        for qr in [None] + others:
            trees.append({'root': root, 'yes': ql, 'no': qr,
                          'right': score(root, ql, qr), 'gini': total_gini(root, ql, qr)})

record('n_trees', len(trees))
record('count_columns', ['', 'Choices'])
record('count_rows', [
    ['The first question', f'{len(KEYS)}'],
    ['On the yes side: stop, or one of the rest', f'{len(KEYS)}'],
    ['On the no side: stop, or one of the rest', f'{len(KEYS)}'],
    ['Trees in total', f'{len(KEYS)} x {len(KEYS)} x {len(KEYS)} = {len(trees)}'],
])
print(f'{len(trees)} trees')
Greedy's rank on the very measure it used
by_gini = sorted(trees, key=lambda t: t['gini'])
lower = [t for t in by_gini if t['gini'] < greedy_gini - 1e-9]
best_g = by_gini[0]

record('n_lower_gini', len(lower))
record('greedy_gini_rank', len(lower) + 1)
record('best_gini', round(best_g['gini'], 3))
record('best_gini_question', QUESTIONS[best_g['root']])

def plot_gini(ax):
    vals = [t['gini'] for t in trees]
    ax.hist(vals, bins=28, color='#c9c9d1')
    ax.axvline(greedy_gini, color='#e2574c', lw=2.2)
    ax.axvline(best_g['gini'], color='#2f9e6e', lw=2.2, ls='--')
    ax.annotate(f"greedy  {greedy_gini:.3f}", (greedy_gini, ax.get_ylim()[1] * 0.92),
                textcoords='offset points', xytext=(7, 0), fontsize=9, color='#e2574c')
    ax.annotate(f"best  {best_g['gini']:.3f}", (best_g['gini'], ax.get_ylim()[1] * 0.72),
                textcoords='offset points', xytext=(-8, 0), ha='right', fontsize=9, color='#2f9e6e')
    ax.set_xlabel('Total impurity left in the finished tree (lower is better)')
    ax.set_ylabel('Trees')
    ax.grid(axis='x', visible=False)

save_fig('no-global-cost', plot_gini, figsize=(7, 3.6))
print(f"{len(lower)} of {len(trees)} trees have a lower total gini than greedy's {greedy_gini:.4f}")
Greedy against every tree, on films it gets right
best_r = max(trees, key=lambda t: t['right'])
better = [t for t in trees if t['right'] > greedy_score]

record('n_better', len(better))
record('best_right', best_r['right'])
record('best_question', QUESTIONS[best_r['root']])
record('best_gain_of_root', f"{gain(ALL, best_r['root']):.3f}")
record('best_tree', as_tree(best_r['root'], best_r['yes'], best_r['no']))

record('compare_columns', ['', 'First question', 'Films right', 'Total impurity'])
record('compare_rows', [
    ['The tree greedy builds', QUESTIONS[greedy_root], f'{greedy_score} of {len(ALL)}', f'{greedy_gini:.3f}'],
    ['The best two-question tree', QUESTIONS[best_r['root']], f"{best_r['right']} of {len(ALL)}", f"{best_r['gini']:.3f}"],
])

def plot_right(ax):
    vals = sorted({t['right'] for t in trees}, reverse=True)
    counts = [sum(1 for t in trees if t['right'] == v) for v in vals]
    colors = ['#2f9e6e' if v == best_r['right'] else '#e2574c' if v == greedy_score else '#c9c9d1'
              for v in vals]
    ax.bar([str(v) for v in vals], counts, color=colors)
    for i, (v, c) in enumerate(zip(vals, counts)):
        if v in (best_r['right'], greedy_score):
            ax.text(i, c + 1.2, 'best' if v == best_r['right'] else 'greedy', ha='center',
                    fontsize=9, color='#2f9e6e' if v == best_r['right'] else '#e2574c')
    ax.set_xlabel(f'Films right, out of {len(ALL)}')
    ax.set_ylabel('Trees')
    ax.grid(axis='x', visible=False)

save_fig('greedy-against-every-tree', plot_right, figsize=(7, 3.6))
print(f"greedy {greedy_score}/{len(ALL)}; best {best_r['right']}/{len(ALL)}; "
      f"{len(better)} tree(s) beat greedy")
print(f"best tree's first question: {QUESTIONS[best_r['root']]} — gain {gain(ALL, best_r['root']):.3f}")

Related lessons