🚀 Alguni Start learning

Unit 3: Twenty Questions

A model you can read.

Unit 3 of 13 in AI and machine learning for kids. Its 4 lessons are One Question Is Not Enough, Which Question Is Best?, Growing the Whole Tree and A Tree That Learned Too Much — below is everything each one explains, and a question or two from it to try.

Every sample on this page is plain Python with no libraries, run before it shipped, and prints exactly what it says it prints.

This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.

❓ One Question Is Not Enough

A third fruit arrives

Limes are bumpy like an orange, but light like an apple. Suddenly the shop has three answers, and every rule you have written so far only knows two.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
]

for weight, bumpiness, name in fruits:
    print(name, weight, bumpiness)

It prints

apple 150 2
apple 170 3
apple 140 1
orange 180 7
orange 165 8
orange 190 9
lime 155 7
lime 145 8

The old rule calls the limes oranges

Unit 1's threshold on bumpiness is not wrong exactly — the limes really are bumpy. It has simply run out of things to say. There is no number you could put in it that would help.

Python

fruits = [
    [150, 2, "apple"],
    [180, 7, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
]

for weight, bumpiness, name in fruits:
    guess = "orange" if bumpiness >= 5 else "apple"
    print(name, "->", guess)

It prints

apple -> apple
orange -> orange
lime -> orange
lime -> orange

So ask a second question — but only where it is needed

Bumpy fruit still needs sorting out, and there weight does the job: oranges are heavy, limes are not. Apples were settled by the first question and need nothing more.

Questions inside questions like this make a decision tree.

Python

def classify(weight, bumpiness):
    if bumpiness >= 5:
        if weight >= 160:
            return "orange"
        return "lime"
    return "apple"

print(classify(150, 2))
print(classify(180, 7))
print(classify(145, 8))

It prints

apple
orange
lime

Every fruit takes one path down

A tree is not a list of rules that all get checked. Each fruit answers the first question, goes down that side, answers the next one there, and stops at a leaf — the label at the bottom.

Which means the tree can always tell you *why*: the questions it asked on the way down are the reason.

Try it yourself

Why can a tree do something a single threshold cannot?

  • It uses bigger numbers
  • It can ask a different next question depending on the first answer
  • It looks at all the training data every time
  • It is trained for longer

What does this print?

Python

def classify(weight, bumpiness):
    if bumpiness >= 5:
        if weight >= 160:
            return "orange"
        return "lime"
    return "apple"

print(classify(170, 3), classify(155, 7))

Answer them in the app

🔎 Which Question Is Best?

Nobody should be choosing the questions

You picked "bumpiness >= 5" because you looked at the table. A learner has to find it, and to find it, it needs a way to say one question is better than another.

Here is a simple one: split the fruit with the question, then answer each side with whatever label is commonest there. Count the mistakes. Fewer is better.

Counting the mistakes on one side

A group of three oranges and two limes gets answered "orange", so it makes two mistakes. A group that is all one label makes none — that group is pure, and there is nothing left to ask it.

Python

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

bumpy = [[180, 7, "orange"], [165, 8, "orange"], [190, 9, "orange"], [155, 7, "lime"], [145, 8, "lime"]]
smooth = [[150, 2, "apple"], [170, 3, "apple"], [140, 1, "apple"]]

print(mistakes(bumpy))
print(mistakes(smooth))

It prints

2
0

A question is scored by both its sides

Add the mistakes on the yes side to the mistakes on the no side. Bumpiness scores 2. Weight scores 3, so it is the worse first question — which matches what you saw by eye.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
]

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

def score(feature, threshold):
    yes = [f for f in fruits if f[feature] >= threshold]
    no = [f for f in fruits if f[feature] < threshold]
    return mistakes(yes) + mistakes(no)

print("bumpiness >= 5:", score(1, 5))
print("weight >= 160:", score(0, 160))

It prints

bumpiness >= 5: 2
weight >= 160: 3

Now try every question there is

Both features, and every value that actually appears in the data as the threshold. That is a few dozen questions, and the computer scores them all in no time.

This is unit 1's search again, one level up: the thing being searched for is a question rather than a number.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
]

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

def score(feature, threshold):
    yes = [f for f in fruits if f[feature] >= threshold]
    no = [f for f in fruits if f[feature] < threshold]
    return mistakes(yes) + mistakes(no)

best_feature = 0
best_threshold = 0
best_score = len(fruits)
for feature in [0, 1]:
    for f in fruits:
        s = score(feature, f[feature])
        if s < best_score:
            best_score = s
            best_feature = feature
            best_threshold = f[feature]

print(best_feature, best_threshold, best_score)

It prints

1 7 2

Feature 1, threshold 7

The computer chose "is bumpiness at least 7?" — very nearly the question you chose by eye, and it never looked at the table.

Only values from the data are tried, because a threshold of 6 and a threshold of 7 cut this fruit into exactly the same two piles. Anything between two neighbouring values is the same question wearing a different number.

Try it yourself

A question splits the fruit into a pure group and a group of 4 oranges and 1 lime. What does it score?

  • 0
  • 1
  • 4
  • 5

Answer it in the app

🌳 Growing the Whole Tree

Do the same thing again, on each side

Find the best question. It leaves two smaller piles. Now find the best question for each of those piles, and for their piles, and so on.

A job that calls itself on a smaller version of itself is recursion, and a tree is what recursion looks like when you draw it.

Knowing when to stop

Stop when a group is pure — every fruit in it has the same label — because no question could improve on an answer that is already right.

Stop as well if no question separates the group at all, which happens when two fruits have identical features and different labels. Then answer with whatever is commonest and accept the mistake.

Python

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

def majority(group):
    names = [f[2] for f in group]
    best = names[0]
    for name in sorted(set(names)):
        if names.count(name) > names.count(best):
            best = name
    return best

limes = [[155, 7, "lime"], [145, 8, "lime"]]
mixed = [[180, 7, "orange"], [155, 7, "lime"]]

print(mistakes(limes) == 0, majority(limes))
print(mistakes(mixed) == 0, majority(mixed))

It prints

True lime
False orange

The whole thing, growing itself

Indent by the depth and the shape appears. Two questions, three leaves, and nobody wrote a single if about fruit.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
]

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

def majority(group):
    names = [f[2] for f in group]
    best = names[0]
    for name in sorted(set(names)):
        if names.count(name) > names.count(best):
            best = name
    return best

def best_split(group):
    best = None
    best_score = len(group) + 1
    for feature in [0, 1]:
        for f in group:
            threshold = f[feature]
            yes = [g for g in group if g[feature] >= threshold]
            no = [g for g in group if g[feature] < threshold]
            if len(yes) == 0 or len(no) == 0:
                continue
            s = mistakes(yes) + mistakes(no)
            if s < best_score:
                best_score = s
                best = [feature, threshold]
    return best

def grow(group, depth):
    pad = "  " * depth
    split = best_split(group)
    if mistakes(group) == 0 or split is None:
        print(pad + majority(group))
        return
    feature, threshold = split
    name = "bumpiness" if feature == 1 else "weight"
    print(pad + "is " + name + " >= " + str(threshold) + "?")
    grow([g for g in group if g[feature] >= threshold], depth + 1)
    grow([g for g in group if g[feature] < threshold], depth + 1)

grow(fruits, 0)

It prints

is bumpiness >= 7?
  is weight >= 165?
    orange
    lime
  apple

Read it out loud

*Is it bumpy? Then is it heavy? Then it is an orange, otherwise a lime. Not bumpy? Apple.*

That is the entire model, in a sentence a person can check, argue with, and correct. Hold on to that — nothing else in this track can do it.

Try it yourself

What stops grow from calling itself for ever?

  • A counter
  • Each group is smaller than the last, and pure groups stop
  • Python stops it automatically
  • It only runs twice

Answer it in the app

🏆 A Tree That Learned Too Much

Somebody typed the label wrong

A ninth fruit joins the pile: 185g, bumpiness 8 — plainly an orange — but whoever wrote the labels put "apple".

One wrong row out of nine. Watch what the tree does about it.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
    [185, 8, "apple"],
]

print(len(fruits), "fruits, and a label that is wrong")

It prints

9 fruits, and a label that is wrong

It builds a tower to reach one fruit

The rule was "keep asking until the group is pure", so it kept asking. Look at the bottom: is weight >= 185? — a question invented for exactly one fruit, that no greengrocer has ever asked.

Zero mistakes on the fruit it was given. It has learned the typo.

Python

fruits = [
    [150, 2, "apple"],
    [170, 3, "apple"],
    [140, 1, "apple"],
    [180, 7, "orange"],
    [165, 8, "orange"],
    [190, 9, "orange"],
    [155, 7, "lime"],
    [145, 8, "lime"],
    [185, 8, "apple"],
]

def mistakes(group):
    names = [f[2] for f in group]
    best = 0
    for name in sorted(set(names)):
        if names.count(name) > best:
            best = names.count(name)
    return len(group) - best

def majority(group):
    names = [f[2] for f in group]
    best = names[0]
    for name in sorted(set(names)):
        if names.count(name) > names.count(best):
            best = name
    return best

def best_split(group):
    best = None
    best_score = len(group) + 1
    for feature in [0, 1]:
        for f in group:
            threshold = f[feature]
            yes = [g for g in group if g[feature] >= threshold]
            no = [g for g in group if g[feature] < threshold]
            if len(yes) == 0 or len(no) == 0:
                continue
            s = mistakes(yes) + mistakes(no)
            if s < best_score:
                best_score = s
                best = [feature, threshold]
    return best

def grow(group, depth, limit):
    pad = "  " * depth
    split = best_split(group)
    if mistakes(group) == 0 or split is None or depth >= limit:
        print(pad + majority(group))
        return
    feature, threshold = split
    name = "bumpiness" if feature == 1 else "weight"
    print(pad + "is " + name + " >= " + str(threshold) + "?")
    grow([g for g in group if g[feature] >= threshold], depth + 1, limit)
    grow([g for g in group if g[feature] < threshold], depth + 1, limit)

grow(fruits, 0, 99)

It prints

is bumpiness >= 7?
  is weight >= 165?
    is weight >= 180?
      is weight >= 190?
        orange
        is weight >= 185?
          apple
          orange
      orange
    lime
  apple

And now a real orange pays for it

A perfectly ordinary 186g bumpy orange walks into that carved-out band and comes out an apple. The tree is not confused — it is doing exactly what it was told, on a rule that was never about fruit.

Python

def deep_tree(weight, bumpiness):
    if bumpiness >= 7:
        if weight >= 165:
            if weight >= 180:
                if weight >= 190:
                    return "orange"
                if weight >= 185:
                    return "apple"
                return "orange"
            return "orange"
        return "lime"
    return "apple"

def small_tree(weight, bumpiness):
    if bumpiness >= 7:
        if weight >= 165:
            return "orange"
        return "lime"
    return "apple"

print("deep tree: ", deep_tree(186, 8))
print("small tree:", small_tree(186, 8))

It prints

deep tree:  apple
small tree: orange

The cure is to stop early

Give the tree a depth limit and it cannot build the tower. At a limit of 2 it gets the typo wrong — 1 mistake on its training data — and gets the real orange right.

A model that is worse on the data it was given and better on everything else is the one you want. Unit 6 will show you the same trade with a network, where you cannot see the rule at all.

Try it yourself

Tree A makes 0 mistakes on the training fruit; tree B makes 1. Which do you ship?

  • A, obviously
  • Whichever does better on fruit neither of them has seen
  • B, because simpler is always right
  • Neither — retrain until both are 0

A bank refuses someone a loan. Why might a tree be a better choice than a network there?

  • Trees are more accurate
  • A tree can show the questions it asked, so a person can check whether they were fair
  • Trees are faster
  • Networks cannot handle money

Answer them in the app