Day 03 · Overview
Week 1FNX 101 · 03Finding Hidden Patterns
Unsupervised learning: discover clusters with k-means and compare them with the real species, then map high-dimensional data with PCA, t-SNE, and UMAP.
Today you will
3- Run k-means to discover groups no one told the machine about
- Use the elbow method to guess how many groups exist
- Squash 4-D and 64-D data into 2-D pictures with PCA and t-SNE
Today's items
2 h 30 minLab
FNX 101 · 03Day 3 — The Pattern Finder 🔍
Mission briefing: A crate just landed in the Lab with no label — thousands of measurements and nobody to tell us what they mean. No answer key, no teacher. Your job: find the hidden structure in the data on your own. This is unsupervised learning, and by tonight it'll power your Studio's newest module.
Previously in the Lab: yesterday you trained the 🐧 Prediction Machine — a classifier that learned penguin species from labeled examples. Today, the labels are gone.
Today you will:
- See the difference between supervised (with an answer key) and unsupervised (without) learning
- Run k-means to discover groups no one told the machine about
- Watch the machine rediscover the penguin species with zero labels
- Use the elbow method to guess how many groups exist
- Build a photo color-compressor with k-means
- Squash 4-D and 64-D data into 2-D pictures with PCA and t-SNE
Today you unlock: 🔓 Pattern Finder — upload any photo and it finds the dominant colors, repainting the image with just a handful of them.
A precise reading
K-means minimizes squared distances to centroids and may settle at a local solution. Cluster numbers are arbitrary, and clusters need not equal real species. The elbow is a heuristic. PCA preserves variance, not all information; t-SNE emphasizes local neighborhoods, so distances between islands and island sizes are not reliable measures of the original data.
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ SETUP — run me first! (identical in every Lab notebook) ║
# ╚═══════════════════════════════════════════════════════════════════╝
import os, sys, pathlib
IN_COLAB = "google.colab" in sys.modules
SMOKE = os.environ.get("SMOKE_TEST", "0") == "1" # tiny fast run for automated tests
REPO_URL = "https://github.com/A-Halimi/ai-studio-internship.git" # instructor: set once
if IN_COLAB:
if not pathlib.Path("/content/ai-studio-internship").exists():
!git clone -q {REPO_URL} /content/ai-studio-internship
%cd /content/ai-studio-internship/notebooks/day03
HERE = pathlib.Path.cwd()
REPO = HERE.parents[1] # .../notebooks/dayNN -> repo root
DATA_DIR = pathlib.Path(os.environ.get("COURSE_DATA_DIR", REPO / "data"))
SAMPLES_DIR = REPO / "data_samples" # small datasets shipped with the repo
MODELS_DIR = REPO / "ai_studio" / "models" # where your Studio modules unlock
for p in (REPO / "data", MODELS_DIR):
p.mkdir(parents=True, exist_ok=True)
import random
import numpy as np
SEED = 42
random.seed(SEED); np.random.seed(SEED)
print(f"✅ Setup done | Colab: {IN_COLAB} | Smoke test: {SMOKE}")
print(f" data: {DATA_DIR}")1 · Two ways to learn
Yesterday every penguin came with its species written on the tag. The machine's job was easy to grade: guess the species, peek at the tag, correct itself. That's supervised learning — learning with an answer key.
Today the tags are gone. We have measurements, but nothing to check our guesses against. So we change the question. Instead of "what species is this?" we ask:
"Which of these penguins resemble each other?"
Finding groups and structure without any labels is unsupervised learning. There's no single "right answer" to copy — the machine has to notice patterns by itself.
It's not a toy. Unsupervised learning is how real systems:
- group customers into look-alike audiences no one defined in advance,
- flag anomalies — a fraudulent transaction is the point that fits no group,
- compress images by replacing millions of colors with a handful (you'll do exactly this today).
Let's open the crate.
import pandas as pd
import matplotlib.pyplot as plt
# The crate: penguin measurements, with the species column still attached so
# we can peek LATER — but the machine will never get to see it.
penguins = pd.read_csv(SAMPLES_DIR / "penguins.csv").dropna().reset_index(drop=True)
print(f"{len(penguins)} penguins in the crate")
penguins.head()2 · How k-means thinks — the pizza-shop story
Imagine three pizza shops opening in a new city. Nobody has a map of where customers live, so the shops play a simple game, over and over:
- Assign: every customer orders from the nearest shop.
- Update: each shop moves to the center of the customers it just served.
Do that a few times and the shops drift until they sit right in the middle of three natural neighborhoods. Customers stop switching. Done.
That's k-means. Swap "pizza shop" for cluster center (a centroid) and "customer" for data point and you have the whole algorithm. The k is how many shops (clusters) you decide to place.
Let's watch it happen on two penguin measurements — bill length and flipper length — standardized so neither one dominates just because it's measured in bigger numbers.
# Two measurements, standardized (mean 0, spread 1) so both count equally.
feat2 = penguins[["bill_length_mm", "flipper_length_mm"]].to_numpy(dtype=float)
X2 = (feat2 - feat2.mean(axis=0)) / feat2.std(axis=0)
# This one is INTERACTIVE: press ▶ play (or drag the slider one round at a
# time) and watch the red ✕'s drift to the middle of their groups.
try:
import plotly.graph_objects as go
except ImportError: # not installed? grab it, then retry
%pip install -q plotly
import plotly.graph_objects as go
# Start the 3 "shops" (centroids) at 3 random penguins.
rng = np.random.default_rng(SEED)
centroids = X2[rng.choice(len(X2), size=3, replace=False)].copy()
# Play the assign/update game for 8 rounds, saving a snapshot after each ASSIGN.
snapshots = []
for step in range(8):
# 1) ASSIGN: each point joins its nearest centroid
dists = np.linalg.norm(X2[:, None, :] - centroids[None, :, :], axis=2) # (N, 3)
labels = dists.argmin(axis=1)
snapshots.append((labels.copy(), centroids.copy()))
# 2) UPDATE: each centroid hops to the mean of its assigned points
for j in range(3):
if np.any(labels == j):
centroids[j] = X2[labels == j].mean(axis=0)
CLUSTER_COLORS = ["#440154", "#21918c", "#fde725"] # viridis-style
def frame_traces(labels, cents):
return [
go.Scatter(x=X2[:, 0], y=X2[:, 1], mode="markers",
marker=dict(size=6, color=[CLUSTER_COLORS[l] for l in labels]),
hoverinfo="skip", name="penguins"),
go.Scatter(x=cents[:, 0], y=cents[:, 1], mode="markers",
marker=dict(size=18, color="red", symbol="x",
line=dict(color="black", width=2)),
hoverinfo="skip", name="centroids"),
]
frames = [go.Frame(data=frame_traces(l, c), name=str(i))
for i, (l, c) in enumerate(snapshots)]
fig = go.Figure(data=frame_traces(*snapshots[0]), frames=frames)
fig.update_layout(
title="k-means converging — press ▶ and watch the red ✕'s find their groups",
xaxis=dict(title="bill length (standardized)"),
yaxis=dict(title="flipper length (standardized)"),
updatemenus=[dict(
type="buttons", x=0.05, xanchor="left", y=1.15,
buttons=[dict(label="▶ play", method="animate",
args=[None, {"frame": {"duration": 900, "redraw": True},
"transition": {"duration": 500}}])],
)],
sliders=[dict(
currentvalue=dict(prefix="iteration "),
steps=[dict(label=str(i), method="animate",
args=[[str(i)], {"mode": "immediate",
"frame": {"duration": 0, "redraw": True}}])
for i in range(len(frames))],
)],
showlegend=False, width=700, height=550,
)
fig.show()🧪 Exercise 1 — Let scikit-learn do the clustering
You watched the loop by hand; now use the real thing. KMeans runs those assign/update steps for you until the centroids stop moving.
- Create
KMeans(n_clusters=3, n_init=10, random_state=SEED)and fit it toX2. - Store each point's cluster number in
clusters.
n_init=10 just means "try 10 random starts and keep the best" — k-means can land in a bad spot if it starts unlucky. You should see three clean blobs.
from sklearn.cluster import KMeans
import plotly.graph_objects as go
km = KMeans(n_clusters=3, n_init=10, random_state=SEED)
# ==================== YOUR CODE HERE ====================
### HINT: km.fit_predict(X2) fits the model AND returns a cluster number per point
...
# Interactive result — hover any dot to read that penguin's REAL measurements.
hover_data = np.column_stack([feat2, clusters])
fig = go.Figure()
fig.add_trace(go.Scatter(
x=X2[:, 0], y=X2[:, 1], mode="markers",
marker=dict(size=6, color=[CLUSTER_COLORS[c] for c in clusters]),
customdata=hover_data,
hovertemplate=("bill %{customdata[0]:.1f} mm · "
"flipper %{customdata[1]:.0f} mm<br>"
"cluster %{customdata[2]:.0f}<extra></extra>"),
))
fig.add_trace(go.Scatter(
x=km.cluster_centers_[:, 0], y=km.cluster_centers_[:, 1], mode="markers",
marker=dict(size=18, color="red", symbol="x", line=dict(color="black", width=2)),
hovertemplate="centroid<extra></extra>",
))
fig.update_layout(
title="k-means found 3 groups — with no labels (hover the dots!)",
xaxis=dict(title="bill length (standardized)"),
yaxis=dict(title="flipper length (standardized)"),
showlegend=False, width=650, height=520,
)
fig.show()🧪 Exercise 2 — The reveal: did it find the species?
Here's the moment. The machine never saw a single species label — it only grouped penguins by shape. So did its groups mean anything?
- Build a table comparing the machine's
clustersagainst the true species withpd.crosstab. - Read it: does each cluster line up mostly with one species?
Use pd.crosstab(clusters, species_true, ...).
species_true = penguins["species"].to_numpy()
# ==================== YOUR CODE HERE ====================
### HINT: pd.crosstab(clusters, species_true) counts how often each pairing occurs
...
print(comparison)👀 Where exactly does it go wrong?
The table gives you the counts — now let's see them. Below are the same penguins twice:
- Left: colored by the machine's clusters. Each cluster borrows the color of the species it mostly captured (majority vote, read straight off your crosstab), so both panels speak the same color language.
- Right: colored by the true species.
Every penguin the machine put in the wrong group wears a red ring on both sides. Hover a ringed dot: almost all of them sit right on the border between two overlapping species — the machine wasn't being silly, those penguins really are ambiguous in just these two measurements.
from plotly.subplots import make_subplots
# Name each cluster after the species it mostly contains — majority vote,
# read straight off the crosstab you just built.
majority = comparison.idxmax(axis=1) # cluster number → species name
predicted_species = majority.loc[clusters].to_numpy()
wrong = predicted_species != species_true
print(f"{wrong.sum()} of {len(penguins)} penguins landed in the 'wrong' cluster")
# Same color for a cluster and the species it captured → panels are comparable.
species_color = {sp: CLUSTER_COLORS[cl] for cl, sp in majority.items()}
fig = make_subplots(
rows=1, cols=2, shared_yaxes=True, horizontal_spacing=0.06,
subplot_titles=("what the machine guessed", "the truth (species tags)"))
# LEFT — machine's view, one trace per cluster
for cl in sorted(np.unique(clusters)):
m = clusters == cl
fig.add_trace(go.Scatter(
x=X2[m, 0], y=X2[m, 1], mode="markers",
marker=dict(size=6, color=CLUSTER_COLORS[cl]),
name=f'cluster {cl} → "{majority[cl]}"', legendgroup=f"c{cl}",
customdata=feat2[m],
hovertemplate=("bill %{customdata[0]:.1f} mm · "
"flipper %{customdata[1]:.0f} mm<br>"
f"cluster {cl}<extra></extra>"),
), row=1, col=1)
# RIGHT — the truth, one trace per species
for sp in comparison.columns:
m = species_true == sp
fig.add_trace(go.Scatter(
x=X2[m, 0], y=X2[m, 1], mode="markers",
marker=dict(size=6, color=species_color[sp]),
name=sp, legendgroup=sp,
customdata=feat2[m],
hovertemplate=("bill %{customdata[0]:.1f} mm · "
"flipper %{customdata[1]:.0f} mm<br>"
f"{sp}<extra></extra>"),
), row=1, col=2)
# THE CONFUSED ONES — a red ring on BOTH panels, hover to see the mix-up
wrong_hover = np.column_stack([feat2[wrong],
predicted_species[wrong], species_true[wrong]])
for col in (1, 2):
fig.add_trace(go.Scatter(
x=X2[wrong, 0], y=X2[wrong, 1], mode="markers",
marker=dict(size=13, symbol="circle-open", color="red",
line=dict(width=2)),
name="⭕ machine got it wrong", legendgroup="wrong",
showlegend=(col == 1),
customdata=wrong_hover,
hovertemplate=("bill %{customdata[0]:.1f} mm · "
"flipper %{customdata[1]:.0f} mm<br>"
"machine said <b>%{customdata[2]}</b>, "
"actually <b>%{customdata[3]}</b><extra></extra>"),
), row=1, col=col)
fig.update_layout(
title="Machine's guess vs the truth — red rings mark the penguins it confused",
width=980, height=520,
legend=dict(orientation="h", y=-0.18),
)
fig.update_xaxes(title_text="bill length (standardized)", row=1, col=1)
fig.update_xaxes(title_text="bill length (standardized)", row=1, col=2)
fig.update_yaxes(title_text="flipper length (standardized)", row=1, col=1)
fig.show()Sit with that for a second. Nobody told the machine what a "Gentoo" is. It just noticed that some penguins clump together by bill and flipper size — and those clumps are the species. The structure was hiding in the numbers all along; k-means only had to look.
(The match isn't perfect — a few penguins sit on the border between groups, and two of the species really do overlap in just these two measurements. Later we'll hand k-means all four measurements and the picture sharpens.)
3 · How many groups are there, really?
We told k-means to find 3. But what if we didn't know? Real mystery crates don't come with the number of groups printed on the side.
Here's a trick. Inertia measures how tightly points hug their centroid — smaller is tighter. Add more clusters and inertia always drops (with one cluster per point it hits zero). But watch how fast it drops: there's usually a k where adding another cluster suddenly stops helping much. The curve bends like an elbow, and that bend is nature's hint at the true number of groups.
🧪 Exercise 3 — Find the elbow
- For each
kfrom 1 to 8, fitKMeansand record its.inertia_. - Plot inertia versus
kand look for the bend.
You should see the curve drop steeply, then flatten around k = 3 — exactly the number of penguin species.
ks = range(1, 9)
inertias = []
# ==================== YOUR CODE HERE ====================
### HINT: after fitting, km_k.inertia_ is the tightness number to append
...
import plotly.graph_objects as go
fig = go.Figure(go.Scatter(
x=list(ks), y=inertias, mode="lines+markers", marker=dict(size=9),
hovertemplate="k = %{x}<br>inertia = %{y:.0f}<extra></extra>",
))
fig.add_annotation(x=3, y=inertias[2], text="the elbow 👈", ax=70, ay=-45,
showarrow=True, arrowhead=2)
fig.update_layout(
title="The elbow method — hover the points, find the bend",
xaxis=dict(title="k (number of clusters)"),
yaxis=dict(title="inertia (lower = tighter clusters)"),
width=650, height=430,
)
fig.show()4 · The visual wow: a photo is just a pile of colors
Time for the module you're unlocking. Here's a fresh way to look at an image: a color is just a point in 3-D space — its red, green, and blue amounts. A photo with a million pixels is a cloud of a million points floating in that RGB cube.
So what happens if we run k-means on the pixels of a photo? It finds the k most representative colors — the photo's palette — and we repaint every pixel with its nearest palette color. Fewer colors, almost the same picture. That's color quantization, and it's a genuine form of image compression.
Let's grab a sample photo that ships with scikit-learn (no download needed).
from sklearn.datasets import load_sample_image
china = load_sample_image("china.jpg") / 255.0 # scale RGB from 0..255 to 0..1
print("image shape:", china.shape, " (height, width, RGB)")
pixels = china.reshape(-1, 3) # one row per pixel
print("that's", len(pixels), "pixels, each a point in 3-D color space")
def palette_bar(centers, labels, width=400, height=60):
"""Draw the palette as a horizontal bar, widest (most common) color first."""
counts = np.bincount(labels, minlength=len(centers))
order = np.argsort(-counts)
bar = np.zeros((height, width, 3))
x = 0
for idx in order:
w = int(round(counts[idx] / counts.sum() * width))
bar[:, x:x + w] = centers[idx]
x += w
bar[:, x:] = centers[order[-1]] # fill any rounding gap
return bar
plt.figure(figsize=(6, 4))
plt.imshow(china)
plt.title("the original photo")
plt.axis("off")
plt.show()👀 See it first: every pixel is a dot in a 3-D color cube
Before we run any clustering, let's actually look at what k-means will see.
A pixel is just 3 numbers — how much Red, Green, and Blue it contains. So every pixel is a point in 3-D space: the RGB color cube. The corner (0, 0, 0) is black, (1, 1, 1) is white, and every color lives somewhere in between.
Our photo has 427 × 640 = 273,280 pixels, which means 273,280 dots floating in that cube. This is exactly like the penguin scatter plot — except each "penguin" is now a pixel, and instead of 2 measurements (bill, flipper) it has 3 (R, G, B).
And just like the penguins, there are no labels. Nobody tells the machine "this pixel is sky" or "this pixel is roof." But the dots still form natural clumps — because a real photo doesn't use random colors. A sky produces hundreds of thousands of pixels that are all nearly the same blue, so they pile up in one region of the cube. That hidden clumping is what k-means will discover on its own — the definition of unsupervised learning.
# Peek at the pixel cloud: 4,000 random pixels inside the RGB cube.
# This plot is INTERACTIVE — drag to rotate 🖱️, scroll to zoom, hover a dot to
# read its exact R/G/B values.
try:
import plotly.graph_objects as go
except ImportError: # not installed? grab it, then retry
%pip install -q plotly
import plotly.graph_objects as go
rng_view = np.random.default_rng(SEED)
peek = pixels[rng_view.choice(len(pixels), size=4_000, replace=False)]
peek255 = (peek * 255).astype(int) # 0..255 for hover text
dot_colors = [f"rgb({r},{g},{b})" for r, g, b in peek255]
fig = go.Figure(go.Scatter3d(
x=peek[:, 0], y=peek[:, 1], z=peek[:, 2],
mode="markers",
marker=dict(size=2.5, color=dot_colors),
customdata=peek255,
hovertemplate="R %{customdata[0]} · G %{customdata[1]} · B %{customdata[2]}<extra></extra>",
))
fig.update_layout(
title="the photo's pixels in the RGB color cube — drag me to rotate!",
scene=dict(
xaxis=dict(title="Red", range=[0, 1]),
yaxis=dict(title="Green", range=[0, 1]),
zaxis=dict(title="Blue", range=[0, 1]),
aspectmode="cube",
),
width=750, height=650, margin=dict(l=0, r=0, t=50, b=0),
)
fig.show()
print("Rotate the cube and look for the clumps:")
print(" • a pale blue/white clump near the (1,1,1) corner → the SKY pixels")
print(" • a dark clump near (0,0,0) → SHADOW pixels")
print(" • a greenish clump in the middle → VEGETATION pixels")
print("Nobody labeled them — the clumps exist because photos reuse similar colors.")🧪 Exercise 4 — Compress the photo to 8 colors
Fitting k-means on all ~270,000 pixels is slow, so we use a classic shortcut: fit on a random sample, then predict every pixel. The palette barely changes, but it runs in a snap.
- Fit
KMeans(n_clusters=8, n_init=4, random_state=SEED)on the 20,000-pixelsample. - Use the fitted model to
.predicta cluster for every pixel inpixels(store it inlabels_all).
Then run the given code to repaint the photo. Eight colors — see how close it still looks.
rng = np.random.default_rng(SEED)
sample = pixels[rng.choice(len(pixels), size=20_000, replace=False)]
km_img = KMeans(n_clusters=8, n_init=4, random_state=SEED)
# ==================== YOUR CODE HERE ====================
### HINT: fit on `sample` (fast), then km_img.predict(pixels) to label ALL pixels
...
palette = km_img.cluster_centers_
repainted = palette[labels_all].reshape(china.shape)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
axes[0].imshow(china); axes[0].set_title("original (thousands of colors)")
axes[1].imshow(repainted); axes[1].set_title("repainted with only 8 colors")
for ax in axes:
ax.axis("off")
plt.show()
plt.figure(figsize=(6, 1.2))
plt.imshow(palette_bar(palette, labels_all))
plt.title("the 8-color palette k-means chose")
plt.axis("off")
plt.show()👀 Where did those 8 colors come from? Back to the cube
Remember the pixel cloud from before? Let's look at it again — but now with the 8 centroids k-means just found, drawn as red ✕'s. This is the pizza-shop game, played inside the color cube: each ✕ drifted until it sat in the middle of a clump of similar colors. The centroid of a clump is the average color of its pixels — and those 8 averages are exactly the palette.
Try this: rotate the cube to a nice angle, then click AFTER. Every pixel snaps to its nearest ✕ (the gray threads show a few of them flying home) and the whole cloud collapses onto just 8 points. That snap is quantization — and it's why the repainted photo has only 8 colors. Flip BEFORE/AFTER a few times and watch which colors get merged together.
# One interactive cube, two views — use the BEFORE / AFTER buttons on top!
# BEFORE: every pixel in its true color, plus the 8 centroids (red ✕).
# AFTER: every pixel snapped to its nearest centroid — only 8 colors left.
# Thin gray threads show a few pixels "flying" to their centroid.
# The camera keeps its angle when you switch, so rotate first, then toggle.
import plotly.graph_objects as go
peek_labels = km_img.predict(peek) # palette color of each peeked pixel
snapped = palette[peek_labels]
snapped_colors = [f"rgb({r},{g},{b})" for r, g, b in (snapped * 255).astype(int)]
# a few "snap threads" from pixel → its centroid (drawing all 4,000 would be a hairball)
thread_idx = rng_view.choice(len(peek), size=120, replace=False)
xs, ys, zs = [], [], []
for i in thread_idx:
c = palette[peek_labels[i]]
xs += [peek[i, 0], c[0], None]
ys += [peek[i, 1], c[1], None]
zs += [peek[i, 2], c[2], None]
trace_true = go.Scatter3d( # 0 — BEFORE cloud
x=peek[:, 0], y=peek[:, 1], z=peek[:, 2], mode="markers",
marker=dict(size=2.5, color=dot_colors), name="true colors",
hovertemplate="R %{customdata[0]} · G %{customdata[1]} · B %{customdata[2]}<extra></extra>",
customdata=peek255,
)
trace_quant = go.Scatter3d( # 1 — AFTER cloud (hidden at start)
x=peek[:, 0], y=peek[:, 1], z=peek[:, 2], mode="markers",
marker=dict(size=2.5, color=snapped_colors), name="quantized",
hoverinfo="skip", visible=False,
)
trace_threads = go.Scatter3d( # 2 — snap threads (hidden at start)
x=xs, y=ys, z=zs, mode="lines",
line=dict(color="rgba(120,120,120,0.4)", width=1),
name="snap", hoverinfo="skip", visible=False,
)
trace_centroids = go.Scatter3d( # 3 — the 8 palette centroids
x=palette[:, 0], y=palette[:, 1], z=palette[:, 2], mode="markers",
marker=dict(size=8, color="red", symbol="x", line=dict(color="black", width=2)),
name="palette (centroids)",
hovertemplate="palette color %{pointNumber}<extra></extra>",
)
fig = go.Figure([trace_true, trace_quant, trace_threads, trace_centroids])
fig.update_layout(
title="quantization, live: rotate the cube, then press AFTER and watch the cloud collapse",
updatemenus=[dict(
type="buttons", direction="right", x=0.5, xanchor="center", y=1.10,
buttons=[
dict(label="BEFORE — true colors",
method="update", args=[{"visible": [True, False, False, True]}]),
dict(label="AFTER — snapped to 8 colors",
method="update", args=[{"visible": [False, True, True, True]}]),
],
)],
scene=dict(
xaxis=dict(title="Red", range=[0, 1]),
yaxis=dict(title="Green", range=[0, 1]),
zaxis=dict(title="Blue", range=[0, 1]),
aspectmode="cube",
),
uirevision="keep-camera",
showlegend=False,
width=750, height=650, margin=dict(l=0, r=0, t=80, b=0),
)
fig.show()💾 Why is this "compression"? Let's count the bits
The repainted photo looks almost the same — but what did we actually save? Count what needs to be stored:
Before (the original): every pixel stores its exact color = 3 numbers (R, G, B), each needing 8 bits. That's 24 bits per pixel.
After (quantized to k = 8): every pixel only needs to say which palette color it uses — a number from 0 to 7. Since 2³ = 8, that number fits in just 3 bits per pixel. On top we store the palette itself (8 colors × 24 bits), which is tiny — a few dozen bytes for the whole photo.
From 24 bits down to 3 bits per pixel ≈ 8× smaller, and the picture is still perfectly recognizable. This isn't a toy trick: it's genuinely how GIF images and old 256-color computer displays worked (256 colors = 2⁸ → 8 bits per pixel instead of 24).
Run the cell below to see the real numbers for our photo.
# Let's count the bits ourselves — no hand-waving.
n_pixels = len(pixels)
k = 8
# BEFORE: every pixel stores 3 channels × 8 bits = 24 bits
original_bits = n_pixels * 3 * 8
# AFTER: every pixel stores only a palette number 0..7 → 3 bits (2**3 = 8)
label_bits = int(np.ceil(np.log2(k))) # bits needed per pixel label
palette_bits = k * 3 * 8 # the 8 palette colors themselves
compressed_bits = n_pixels * label_bits + palette_bits
print(f"pixels in the photo: {n_pixels:,}")
print()
print(f"BEFORE — {n_pixels:,} px × 24 bits = {original_bits:>9,} bits ≈ {original_bits/8/1024:,.0f} KB")
print(f"AFTER — {n_pixels:,} px × {label_bits} bits + palette = {compressed_bits:>9,} bits ≈ {compressed_bits/8/1024:,.0f} KB")
print(f" (the whole palette costs only {palette_bits} bits ≈ {palette_bits//8} bytes!)")
print()
print(f"compression ratio: {original_bits / compressed_bits:.1f}× smaller 🎉")
print()
print("Try it: how many bits per pixel would k = 16 need? k = 256?")
print("(hint: 2**4 = 16, 2**8 = 256 — this is exactly how GIF images work)")🧪 Exercise 5 — How few colors can you get away with?
Let's make k a dial. The quantize helper below repaints the photo with any number of colors. You fill in the one line that displays each version.
- Inside the loop, call
quantize(k)and show it withax.imshow(...). - Compare k = 3, 8, and 16.
Then answer in the text cell below: what do you lose at k = 3? What barely changes between k = 8 and k = 16?
def quantize(k, n_sample=20_000):
"""Repaint `china` using only k colors. Returns an (H, W, 3) image."""
samp = pixels[rng.choice(len(pixels), size=n_sample, replace=False)]
model = KMeans(n_clusters=k, n_init=4, random_state=SEED).fit(samp)
return model.cluster_centers_[model.predict(pixels)].reshape(china.shape)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, k in zip(axes, [3, 8, 16]):
# ==================== YOUR CODE HERE ====================
### HINT: quantize(k) returns the repainted image; hand it to ax.imshow(...)
...
ax.set_title(f"k = {k} colors")
ax.axis("off")
plt.show()Your turn to describe (double-click to edit):
- At k = 3, the photo loses ...
- Between k = 8 and k = 16, ...
💡 You just built a photo compressor — and the engine behind your Studio's 🎨 Pattern Finder module. Same k-means, running live on whatever photo you upload.
5 · Seeing in 4-D: PCA casts a shadow
Our penguins actually have four measurements, not two. But a screen is flat — how do you plot a 4-D animal?
Think of shadows. A 3-D object casts a 2-D shadow on the wall, and if you rotate it to just the right angle, the shadow keeps almost everything that makes the shape recognizable. PCA (Principal Component Analysis) finds that best angle automatically: it squashes many dimensions down to a few while throwing away as little information as possible.
🧪 Exercise 6 — Flatten 4-D penguins onto a 2-D page
- Standardize all four measurements, then fit
PCA(n_components=2)and transform them intocoords. - We'll color the picture by the true species — but only to check the result. PCA never sees the labels; it arranges the penguins from the measurements alone.
- Print
explained_variance_ratio_and its sum: the fraction of the original information the 2-D shadow kept.
You should keep well over 80% of the variance in just two numbers per penguin.
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
import plotly.graph_objects as go
feat4 = penguins[["bill_length_mm", "bill_depth_mm",
"flipper_length_mm", "body_mass_g"]].to_numpy(dtype=float)
X4 = StandardScaler().fit_transform(feat4)
# ==================== YOUR CODE HERE ====================
### HINT: pca.fit_transform(X4) returns the 2-D coordinates
...
# Interactive: hover a dot to see the 4 ORIGINAL measurements hiding behind
# each 2-D point; click a species in the legend to hide/show it.
species_codes, species_labels = pd.factorize(penguins["species"])
fig = go.Figure()
for c, name in enumerate(species_labels):
m = species_codes == c
fig.add_trace(go.Scatter(
x=coords[m, 0], y=coords[m, 1], mode="markers", name=name,
marker=dict(size=6),
customdata=feat4[m],
hovertemplate=(name + "<br>bill %{customdata[0]:.1f} mm · "
"depth %{customdata[1]:.1f} mm<br>"
"flipper %{customdata[2]:.0f} mm · "
"mass %{customdata[3]:.0f} g<extra></extra>"),
))
fig.update_layout(
title=("4 measurements squashed into 2 — colored by true species"
"<br><sup>hover a dot for its original 4 numbers · click the legend to hide a species</sup>"),
xaxis=dict(title="principal component 1"),
yaxis=dict(title="principal component 2"),
width=750, height=550,
)
fig.show()
print("information kept per axis:", np.round(pca.explained_variance_ratio_, 3))
print("total information kept: ", round(float(pca.explained_variance_ratio_.sum()), 3))6 · A map of handwriting with t-SNE
PCA is fast and honest, but it can only rotate and flatten — straight-line moves. Some data is tangled in ways that need a curvier map. t-SNE builds one: it pulls similar points close together and shoves different ones apart, untangling structure PCA can miss. It's the go-to tool for seeing high-dimensional data.
Let's aim it at something with 64 dimensions: tiny 8×8 images of handwritten digits (built right into scikit-learn — no download).
from sklearn.datasets import load_digits
digits = load_digits()
print(digits.images.shape, "→ 1797 images, each 8×8 pixels (64 numbers)")
fig, axes = plt.subplots(2, 8, figsize=(10, 2.8))
for ax, img, label in zip(axes.flat, digits.images, digits.target):
ax.imshow(img, cmap="gray_r")
ax.set_title(str(label))
ax.axis("off")
fig.suptitle("a handful of the digits")
plt.show()from sklearn.manifold import TSNE
# t-SNE is slower than PCA. Under a smoke test we use only the first 400 images.
n_show = 400 if SMOKE else len(digits.data)
X_digits = digits.data[:n_show]
y_digits = digits.target[:n_show]
print(f"Running t-SNE on {n_show} images… this takes ~20-40s on CPU. Hang tight ☕")
tsne = TSNE(n_components=2, random_state=SEED, perplexity=30)
embedding = tsne.fit_transform(X_digits)
print("Done — here comes the map.")🧪 Exercise 7 — Draw the map
embedding holds a 2-D point for each digit image. Color each point by which digit it actually is and see whether t-SNE sorted them.
- For each digit
dfrom 0 to 9: make a maskm = y_digits == d, then add ago.Scattertrace ofembedding[m, 0]vsembedding[m, 1]withname=str(d). - One trace per digit gives you a clickable legend: click a digit to hide it, double-click to isolate its island.
You should see roughly ten islands — one per digit — that nobody told t-SNE about.
import plotly.graph_objects as go
fig = go.Figure()
# ==================== YOUR CODE HERE ====================
### HINT: loop d in range(10); mask m = y_digits == d; add a go.Scatter of embedding[m] with name=str(d)
...
fig.update_layout(
title=("1797 handwritten digits, sorted into islands with zero labels"
"<br><sup>hover to identify an island · click a legend digit to hide it · double-click to isolate it</sup>"),
xaxis=dict(title="t-SNE axis 1"),
yaxis=dict(title="t-SNE axis 2"),
legend_title="digit", width=800, height=620,
)
fig.show()1797 images, 64 pixels each — and the map sorts them into islands, one per digit, without ever being told what a digit is. The pixels alone carry the structure; t-SNE just makes it visible.
Soon you'll go one step further and train a network to actually read these digits.
7 · Optional: UMAP, t-SNE's faster cousin
UMAP does a similar untangling job, often faster and better at keeping the big-picture layout. It's an extra library, so this cell skips itself politely if UMAP isn't installed — no worries either way.
try:
import umap # optional — present in the Lab's Docker image, maybe not on Colab
import plotly.graph_objects as go
reducer = umap.UMAP(n_components=2, random_state=SEED)
umap_emb = reducer.fit_transform(X_digits)
fig = go.Figure()
for d in range(10):
m = y_digits == d
fig.add_trace(go.Scatter(x=umap_emb[m, 0], y=umap_emb[m, 1],
mode="markers", name=str(d), marker=dict(size=5),
hovertemplate=f"digit {d}<extra></extra>"))
fig.update_layout(
title="the same digits, mapped by UMAP — same legend tricks apply",
xaxis=dict(title="UMAP axis 1"),
yaxis=dict(title="UMAP axis 2"),
legend_title="digit", width=800, height=620,
)
fig.show()
except ImportError:
print("UMAP isn't installed here — that's totally fine, skip this one. 🙂")🔓 Unlock your Studio module
You've earned it. Save your settings and the 🎨 Pattern Finder switches on in your AI Studio. Put your name on your work and pick the number of colors it should reach for by default.
import json
# ✏️ Make it yours:
MADE_BY = "your name here" # ← type your name between the quotes
DEFAULT_K = 8 # ← default number of color clusters (2–12)
config_path = MODELS_DIR / "pattern_finder.json"
with open(config_path, "w", encoding="utf-8") as f:
json.dump({"default_k": DEFAULT_K, "made_by": MADE_BY}, f, indent=2)
# make sure the Studio will find it
assert config_path.exists(), "pattern_finder.json did not save!"
print("🔓 UNLOCKED: Pattern Finder!")
print(f" saved {config_path.name} (default_k={DEFAULT_K}, made_by={MADE_BY})")
print(" Run your Studio to try it live on any photo:")
print(" python ai_studio/app.py (from the repo root)")🏁 Checkpoint
Look what you can do now — with no answer key at all:
- k-means to discover groups (and you watched it rediscover the penguin species)
- the elbow method to guess how many groups exist
- color quantization to compress a photo — now live in your 🎨 Pattern Finder
- PCA and t-SNE to turn 4-D and 64-D data into pictures you can actually see
So far you've borrowed brains (Day 1) and learned the classic tricks (Days 2-3). Tomorrow the Chief Scientist raises the stakes: build and train a neural network from nothing but NumPy — no libraries to hide behind. By the end, you'll never see a neural net as a black box again.
Next: Day 4 — Inside the Brain 🧠