---
title: "CIFAR-10 + CLIP"
subtitle: "10,000 natural images, 512d CLIP embeddings — explore DYF's partition hierarchy interactively."
order: 3
image: cifar10-clip_files/figure-html/fig-dyf-output-1.png
categories: [images, medium, embeddings, interactive]
true-k: 10
format:
html:
code-fold: true
code-tools: true
execute:
warning: false
message: false
---
```{python}
#| label: setup
#| code-fold: true
#| output: false
import os, sys, time
import numpy as np
sys.path.insert(0, ".")
from datasets import load_dataset
from _gallery import (
run_dyf_cached, run_kmeans, hierarchy_slider,
merge_walk, merge_walk_table, metrics_table, plot_single,
)
# 1. Load CIFAR-10 (test split, 10K images, 10 classes).
ds = load_dataset("cifar10", split="test")
CLASS_NAMES = ds.features["label"].names
# 2. Embed via CLIP ViT-B/32 (or load cache).
CACHE = "/tmp/gallery_cifar10_clip.npz"
if os.path.exists(CACHE):
z = np.load(CACHE)
X, y = z["X"], z["y"]
else:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("clip-ViT-B-32")
imgs = [d["img"] for d in ds]
y = np.asarray([d["label"] for d in ds])
X = np.ascontiguousarray(
model.encode(imgs, show_progress_bar=False, batch_size=64),
dtype=np.float32,
)
np.savez(CACHE, X=X, y=y)
# 3. Fit DYF + k-means (cached).
result = run_dyf_cached(X, y, cache_path="/tmp/gallery_cifar10_dyf.npz")
kmeans = run_kmeans(X, y)
```
## Explore the hierarchy
Drag the slider. Each position is a different cut of DYF's partition tree — same data, same fit, different resolution. The **left panel** shows DYF's partition at the current `k`; the **right panel** shows the ground-truth class labels on the same UMAP layout. Hover a point to see its true class. Watch the NMI / ARI numbers in the title move as you change resolution.
```{python}
#| label: hierarchy-slider
#| fig-cap: "DYF's partition (left, slider-controlled) vs ground-truth classes (right, fixed). Hover for class names."
fig = hierarchy_slider(
result, X, y,
k_values=[23, 20, 17, 15, 12, 10, 8, 6, 4, 3, 2],
class_names=CLASS_NAMES,
show_ground_truth=True,
height=500,
)
fig
```
Three things to try:
- **Start at k=2** (default). The partition collapses to two clusters — roughly *vehicles* (airplane/car/ship/truck) vs *animals* (bird/cat/deer/dog/frog/horse). The same superclass split humans use, emerging from raw CLIP similarity alone.
- **Stop at k=10.** This is the ARI peak: DYF's partition at its best alignment with the flat 10-class label. The metric in the title beats the oracle-tuned k-means below, which knew `k=10` going in.
- **Slide right to k=23.** Each CIFAR class fragments into sub-styles — car probably splits into sedans vs trucks, dog into breeds / poses, etc. This is the finest resolution DYF's tree supports.
K-means would need **ten separate runs** at ten different `k` to produce this range — and those runs are not guaranteed to nest. DYF's tree nests by construction: every coarser partition is a strict merge of a finer one.
## How we got here
The setup code is collapsed above. The short version:
1. Load CIFAR-10's test split (10,000 images, 10 classes).
2. Embed every image with off-the-shelf **CLIP ViT-B/32** — no fine-tuning — producing 512-dim vectors that put semantically similar images into tight blobs.
3. Run DYF with its standard defaults on those embeddings.
4. Run oracle-k-means (`k=10`) for comparison.
Why CLIP and not raw pixels? Because [Olivetti](olivetti-faces.qmd) showed what happens when raw pixels go straight into DYF: at 400 samples × 4,096d, DYF collapsed to 5 clusters against a 40-class ground truth and posted NMI=0.296. The diagnosis was that raw pixel space has bad geometry for density clustering — identity manifolds are curved and narrow, not the tight blobs density methods need. Swapping in a pretrained visual encoder fixes the geometry *and* gives DYF adequate sample density per latent class.
This notebook is the counter-narrative to Olivetti: **images aren't hostile to DYF; raw pixels are.**
## Metrics
```{python}
#| label: metrics
#| output: asis
print(metrics_table(result, kmeans, None))
```
## Walking down the hierarchy (the table view)
The slider is interactive; here's the same data as a static table for easy comparison across resolutions:
```{python}
#| label: merge-walk
#| output: asis
walk = merge_walk(result, X, y, targets=[15, 10, 5, 2])
print(merge_walk_table(walk, result))
```
The `merge → 10` row — **NMI 0.709, ARI 0.622** — beats oracle-k k-means (0.686 / 0.515) at its own resolution. DYF found the 10-class structure without being told the number.
## Static figures (for the record)
Same UMAP layout, three fixed views — ground truth, raw DYF, and k-means:
::: {.gallery-fluid}
```{python}
#| label: fig-truth
#| fig-cap: "Ground truth — 10 CIFAR classes (airplane, car, bird, cat, deer, dog, frog, horse, ship, truck)."
plot_single(result.umap_2d, y, title=f"Ground truth (k={result.true_k})")
```
```{python}
#| label: fig-dyf
#| fig-cap: "DYF — parameter-free, over-partitions into sub-categories."
plot_single(result.umap_2d, result.labels, title=f"DYF (recovered k={result.recovered_k})")
```
```{python}
#| label: fig-kmeans
#| fig-cap: "K-means given the oracle k=10."
plot_single(result.umap_2d, kmeans["labels"], title=f"k-means (told k={result.true_k})")
```
:::
## The counter-narrative: images aren't the problem, pixels are
Stack this alongside Olivetti:
| Dataset | Input | n × d | DYF k | DYF NMI | KMeans NMI | DYF NMI − KMeans |
|---------|-------|-------|------:|--------:|-----------:|-----------------:|
| Olivetti | Raw pixels | 400 × 4096 | 5 | 0.296 | 0.629 | **−0.333** |
| **CIFAR-10** | **CLIP embeddings** | **10,000 × 512** | **23** | **0.660** | **0.686** | **−0.026** |
The DYF-vs-oracle-k-means gap shrunk by an order of magnitude once the features changed. Same algorithm family, same defaults, transformative result. Two things moved together:
**1. The geometry changed.**
Raw face pixels don't form tight identity clusters — lighting, pose, and expression spread each person across a long, curved manifold in 4,096-dimensional pixel space. CLIP embeddings, by contrast, were trained to put visually-distinct classes into tight blobs in 512-dimensional representation space. Density-based clustering needs "tight blobs separated by empty space" to work; CLIP gives you exactly that.
**2. Density per cluster improved dramatically.**
Olivetti had 10 samples per identity — below DYF's leaf-size floor. CIFAR-10 test has 1,000 samples per class, comfortably above it. Both changes mattered; separating their contributions would need a third experiment (e.g., CLIP on a 400-image subset), which we're skipping for the gallery.
The honest conclusion: **raw-pixel image clustering is a solved problem and the solution is "use a pretrained embedder."** DYF plays this game well once the inputs are the right shape; it doesn't try to solve pixel-space geometry from scratch, and you shouldn't expect it to.
## The recipe
Looking across the gallery, a practical recipe is emerging:
1. **If your data has meaningful density structure in its raw feature space** (motion capture joint angles, text sentence embeddings, tabular sensor streams) → DYF works out of the box.
2. **If your raw features are bad geometry for density clustering** (raw pixels, one-hot categoricals, unnormalized mixed-scale features) → pre-embed with a domain-appropriate model first, then run DYF.
3. **If your data is nested / topological** (concentric rings, donut shapes) → neither DYF nor k-means helps; reach for spectral clustering.
This is the same logic behind "put an embedding model in front of your retrieval / clustering / ranking pipeline" — the now-canonical move in RAG, recommender systems, and anomaly detection. DYF is a natural downstream consumer of pretrained embeddings, not a replacement for them.
## Caveats
- **Test split only** (10,000 images). The full CIFAR-10 train split (50,000) would give DYF more density per class and probably push NMI closer to k-means's; we use the test split to keep the notebook render fast.
- **CLIP was trained on internet-scale image-text pairs**, which almost certainly included CIFAR-like images. It's the "home court" of pretrained vision encoders. A domain-specific corpus (medical imaging, satellite, microscopy) would need a domain-specific encoder to tell the same story.
- **No fine-tuning.** The CLIP encoder is used off-the-shelf. Fine-tuning on CIFAR would tighten the clusters further and probably erase the DYF-vs-oracle gap entirely. That's also the recipe for "why does this method work so well on this benchmark" in a lot of published clustering papers.