Module · How a Model is Fitted
Greedy search — why the best step is not the best path
Lesson 3 of 3 · 13 min
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.
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.
| Choices | |
|---|---|
| The first question | 5 |
| On the yes side: stop, or one of the rest | 5 |
| On the no side: stop, or one of the rest | 5 |
| Trees in total | 5 x 5 x 5 = 125 |
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.
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.
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.
| First question | Films right | Total impurity | |
|---|---|---|---|
| The tree greedy builds | Is there a star the audience turns up for? | 19 of 24 | 0.319 |
| The best two-question tree | Did it open in summer? | 20 of 24 | 0.264 |
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.
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.
| The line | The tree | |
|---|---|---|
| What it searched | Two numbers, both free to move | Which question to ask next |
| What told it where to go | The cost, which is lower on one side | The gain of each question available now |
| Can it go back? | It never needs to — there is one bottom | No. A question asked is not revisited. |
| What you get | The best parameters for that form | A good tree, and no way to know how good |
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.
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
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')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}')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}')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')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}")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}")
