Unit 10: Groups Nobody Labelled
Learning with no answers at all.
Unit 10 of 13 in AI and machine learning for kids. Its 4 lessons are No Answers Anywhere, Guess, Then Move, Watching It Settle and Where You Start Matters — 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.
🫙 No Answers Anywhere
Where do labels come from?
People. Somebody looked at every fruit and wrote "apple" next to it, and that is slow, expensive and often impossible. Nobody has labelled every photo on the internet, or every song, or what every shopper is like.
So here is the fruit again with the answers torn off. Six pairs of numbers, and nothing else.
Python
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
for f in fruits:
print(f)
It prints
[150, 2] [170, 3] [140, 1] [180, 7] [165, 8] [190, 9]
You can still see the two clumps
Three fruits are light and smooth. Three are heavy and bumpy. No label says so — it is just that things near each other on the grid tend to be the same sort of thing.
Finding those clumps without being told is called clustering, and learning with no labels at all is unsupervised learning.
The middle of a group
Everything below needs one idea: the centre of a group is the average of its points. Add up each column, divide by how many there are, and you have the spot the group sits around — which may be where no actual fruit is.
Python
group = [[150, 2], [170, 3], [140, 1]]
weight = sum(f[0] for f in group) / len(group)
bump = sum(f[1] for f in group) / len(group)
print(round(weight, 1), round(bump, 1))
It prints
153.3 2.0
And scaling still matters
Distances are about to decide everything, so grams must not shout over bumpiness — the trap from unit 2. Every point below is squashed to between 0 and 1 first, using the same scale you wrote there.
Python
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
for f in [[150, 2], [190, 9]]:
print(scale(f))
It prints
[0.2, 0.125] [1.0, 1.0]
Try it yourself
What can clustering never give you?
- Groups of similar things
- The name of each group
- The centre of each group
- A way to put a new thing into a group
Answer it in the app
🎯 Guess, Then Move
Start with two guesses
Decide how many groups you want — call it k — and drop that many centres anywhere at all. Ours start in opposite corners of the grid, at 0, 0 and 1, 1.
They are wrong. That is fine. The whole method is a way of being less wrong each round.
Step one: everybody joins the nearer centre
Measure each fruit to both centres and put it with whichever is closer. That is the distance from unit 2, doing its third job in this track.
Python
import math
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
def distance(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
centres = [[0.0, 0.0], [1.0, 1.0]]
groups = [[], []]
for f in fruits:
point = scale(f)
near = 0
if distance(point, centres[1]) < distance(point, centres[0]):
near = 1
groups[near].append(f)
print("group 1:", groups[0])
print("group 2:", groups[1])
It prints
group 1: [[150, 2], [170, 3], [140, 1]] group 2: [[180, 7], [165, 8], [190, 9]]
Step two: every centre moves to the middle of its group
The corners were only ever a guess. Now that each centre has some fruit, it slides to the average of it — into the middle of the clump it collected.
Python
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
groups = [
[[150, 2], [170, 3], [140, 1]],
[[180, 7], [165, 8], [190, 9]],
]
centres = []
for group in groups:
weight = sum(scale(f)[0] for f in group) / len(group)
bump = sum(scale(f)[1] for f in group) / len(group)
centres.append([round(weight, 2), round(bump, 2)])
print(centres)
It prints
[[0.27, 0.12], [0.77, 0.88]]
Then do it again
Assign, move, assign, move. Each round the centres sit better and the groups change less, until a round comes along where nothing moves at all — and then no later round can change anything either, so you stop.
This is k-means: k centres, each one the mean of its group.
Try it yourself
What does the k in k-means stand for?
- How many rounds to run
- How many groups you asked for
- How many features there are
- The learning rate
Answer it in the app
🌀 Watching It Settle
The whole thing, until it stops
Assign and move in a loop, and stop when the new centres are identical to the old ones. Two rounds is all this fruit needs.
Python
import math
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
def distance(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def assign(centres):
groups = []
for c in centres:
groups.append([])
for f in fruits:
point = scale(f)
near = 0
for i in range(len(centres)):
if distance(point, centres[i]) < distance(point, centres[near]):
near = i
groups[near].append(f)
return groups
def move(groups, centres):
out = []
for i in range(len(groups)):
if len(groups[i]) == 0:
out.append(centres[i])
continue
weight = sum(scale(f)[0] for f in groups[i]) / len(groups[i])
bump = sum(scale(f)[1] for f in groups[i]) / len(groups[i])
out.append([round(weight, 2), round(bump, 2)])
return out
centres = [[0.0, 0.0], [1.0, 1.0]]
for step in range(10):
groups = assign(centres)
new = move(groups, centres)
print("round", step + 1, [len(g) for g in groups], new)
if new == centres:
print("settled")
break
centres = new
It prints
round 1 [3, 3] [[0.27, 0.12], [0.77, 0.88]] round 2 [3, 3] [[0.27, 0.12], [0.77, 0.88]] settled
Look what it found
The apples in one group and the oranges in the other — and the word "apple" appears nowhere in the program, because it was never in the data.
Python
import math
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
def distance(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
centres = [[0.27, 0.12], [0.77, 0.88]]
groups = [[], []]
for f in fruits:
point = scale(f)
near = 0
if distance(point, centres[1]) < distance(point, centres[0]):
near = 1
groups[near].append(f)
for group in groups:
print(group)
It prints
[[150, 2], [170, 3], [140, 1]] [[180, 7], [165, 8], [190, 9]]
It found groups, not meanings
The program has no idea that group 1 is apples. It has "group 1". A person looks at it, sees the fruit is light and smooth, and writes the word — and everything anybody believes about that group after that comes from the person, not the maths.
Hold on to that when somebody tells you an AI "discovered" a category.
Why it always stops
Each round can only shorten the total distance from every point to its own centre — assigning to the nearer centre cannot make it longer, and moving to the middle is the spot with the smallest total. A number that only goes down, and cannot go below zero, has to come to rest.
Comparing the centres rounded to two places is what makes "nothing moved" a safe test to write; comparing raw decimals for exact equality is usually a mistake.
Try it yourself
When does k-means stop?
- After a fixed number of rounds
- When a round leaves the centres exactly where they were
- When every group is the same size
- When the loss is zero
How many groups does this print?
Python
centres = [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]
groups = []
for c in centres:
groups.append([])
print(len(groups))
Answer them in the app
🏆 Where You Start Matters
The same fruit, two centres dropped badly
Both starting centres land down in apple country this time. The method runs exactly as before, settles exactly as before — and gives a different answer.
Python
import math
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
def distance(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def assign(centres):
groups = []
for c in centres:
groups.append([])
for f in fruits:
point = scale(f)
near = 0
for i in range(len(centres)):
if distance(point, centres[i]) < distance(point, centres[near]):
near = i
groups[near].append(f)
return groups
def move(groups, centres):
out = []
for i in range(len(groups)):
if len(groups[i]) == 0:
out.append(centres[i])
continue
weight = sum(scale(f)[0] for f in groups[i]) / len(groups[i])
bump = sum(scale(f)[1] for f in groups[i]) / len(groups[i])
out.append([round(weight, 2), round(bump, 2)])
return out
centres = [[0.0, 0.0], [0.2, 0.13]]
for step in range(10):
groups = assign(centres)
new = move(groups, centres)
if new == centres:
break
centres = new
for group in assign(centres):
print(group)
It prints
[[150, 2], [140, 1]] [[170, 3], [180, 7], [165, 8], [190, 9]]
A heavy apple has joined the oranges
It settled, and it settled somewhere worse. Nothing went wrong: from where those centres started, every round really did improve on the last, and it arrived somewhere it cannot improve on by moving a little.
That is the local minimum from unit 6, in a different costume. "It converged" never means "it is right".
What people do about it is unglamorous: run it several times from different starting points and keep the tidiest answer.
And k is your guess, not its discovery
Ask for three groups from fruit that comes in two kinds, and you get three groups. It has no way to object.
Python
import math
fruits = [[150, 2], [170, 3], [140, 1], [180, 7], [165, 8], [190, 9]]
def scale(f):
return [(f[0] - 140) / 50, (f[1] - 1) / 8]
def distance(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def assign(centres):
groups = []
for c in centres:
groups.append([])
for f in fruits:
point = scale(f)
near = 0
for i in range(len(centres)):
if distance(point, centres[i]) < distance(point, centres[near]):
near = i
groups[near].append(f)
return groups
def move(groups, centres):
out = []
for i in range(len(groups)):
if len(groups[i]) == 0:
out.append(centres[i])
continue
weight = sum(scale(f)[0] for f in groups[i]) / len(groups[i])
bump = sum(scale(f)[1] for f in groups[i]) / len(groups[i])
out.append([round(weight, 2), round(bump, 2)])
return out
centres = [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]
for step in range(10):
groups = assign(centres)
new = move(groups, centres)
if new == centres:
break
centres = new
for group in assign(centres):
print(group)
It prints
[[150, 2], [140, 1]] [[170, 3], [165, 8]] [[180, 7], [190, 9]]
That middle group is nonsense
A light smooth fruit and a light bumpy one, filed together because a human asked for three piles and three piles is what the arithmetic produced.
k is a hyperparameter, like the learning rate in unit 4 — chosen by a person, before any learning happens, and quietly deciding what the answer can be.
What this gets used for
Grouping shoppers by what they buy, songs by how they sound, or a night sky by which dots move together. It is genuinely useful, and it is genuinely unsupervised — nobody labelled any of it.
And then somebody names the groups. "People like this usually pay late." The maths found a clump; the sentence about people was written by a human, and unit 8 will have more to say about that.
Try it yourself
Two runs of k-means on the same data give different groups. Why?
- One of them has a bug
- They started from different centres and settled in different places
- The data changed
- k-means is random
You ask for k = 5 on data that really has 2 kinds of thing. What happens?
- It returns 2 groups and ignores you
- It returns 5 groups, splitting real ones up
- It fails with an error
- It picks the best k itself
Answer them in the app