Unsupervised representation learning
A Keras autoencoder that learns to tell handwritten digits apart without ever being shown a single label — by being forced to rebuild each image from just two numbers.
Everything on this page — the map, the pixel grids, the terminal capture, the metrics — was produced by one run of autoencoder.py and embedded verbatim. Nothing here is illustrative.
Loading run provenance…
Give a neural network 1,797 pictures of handwritten digits and one instruction: copy the picture. No labels, no "this is a seven", nothing to annotate.
The catch is that the copy has to pass through a layer only two numbers wide. Sixty-four pixels go in, two numbers come out, and the second half of the network has to rebuild the whole image from that pair. Memorising is impossible at that width, so the network has to work out what actually distinguishes one digit from another — loops, slant, stroke weight — and spend its two numbers describing that.
The payoff is the map in section 03. Plot each image at the two numbers it was given, and the digits sort themselves into groups: all the zeros here, all the fours over there. Nobody told the network what a zero is. The colours are added afterwards, on this page, purely so a human can check that the groups it found line up with the real digits.
Three components, one direction of travel. The Python side trains and exports; the web side only ever reads what was exported.
sklearn.datasets.load_digits, scales to [0,1], trains the encoder/decoder pair on fit(X, X), prints the ASCII map and reconstruction pairs, and writes the run to JSON.<script type="application/json"> block. Draws the scatter and the pixel grids on canvas. No build step, no fetch, no dependencies.Two named keras.Sequential models, composed into a third. The encoder is the half you keep; the decoder exists to make the encoder honest.
train(X) has no y parameter, so no label can reach the loss even by accident.The spec for this exercise started at a 32-unit hidden layer for 60 epochs. At that size the map was a single smear, so — following the spec's own instruction to raise width and epochs until clusters are visible by eye — the built system uses 96 units for 240 epochs. Everything else is unchanged. The diagram above is the network as actually built.
Captured from an actual run on —. The scatter below is the live embedding; the terminal block underneath is the unedited stdout of that same run.
Every dot is one handwritten digit, placed at the two numbers the bottleneck gave it. Toggle the classes off and on — that is how you see the clusters.
Hover a dot (or tap, on touch) to read its digit. Click a legend chip to hide or show that class.
These coordinates are the network's actual learned embedding from the run above — clusters were never labeled during training.
Three captured images and what the decoder rebuilt after everything about them was reduced to a single pair of numbers. Both grids are 64-float arrays from the run, drawn here as 8×8 with an identical value-to-colour mapping.
Blurry is the honest outcome. Two numbers cannot carry which pixels were inked; they carry what kind of digit this is, and the decoder paints the average of that kind.
Verbatim stdout, saved to docs/run-output.txt by python autoencoder.py --json. The ASCII map is the same embedding as the canvas above, rendered with the majority label per character cell.
data: 1797 digits x 64 pixels, range [0.00, 1.00]
training 240 epochs through a 2-unit bottleneck (labels are NOT used)...
final reconstruction MSE: 0.032450
THE LEARNED SPACE — 1797 digits, each drawn at its own 2 numbers.
Characters are the true labels, painted on AFTER training, purely so
you can see that the clusters the network found line up with them.
+--------------------------------------------------------------------+
| 2 |
| 222 |
| 22 |
| 2222222 |
| 11 1 22222 2 2 |
| 111 1 1 22 2222 |
| 11118 1 222222211 1 |
| 11111111 22222111211 |
| 1 11111177 82222222 |
| 1 111111817718 1 82212212 3 3 33 |
|4 4 4 111112111177 88 6282222 3333333333 99 |
| 4 4 4 4 11111 6211748888888228333333333 3 9 3 9 |
| 44 44 444444 111122 8777285588288833333333939999999 9 |
| 4 4444444444411 2 877785888888339839999999999 6 66 66|
| 4 4 444444 499 87788558883888999999993 93 66 66 6 66 |
| 444444444 99777795553888599599 95 6 6666 666666666 |
| 44444444445 77779955555555585 566 666666666 666 66 6 6|
| 44444444447779 55555 5666666 6666666666666666 6 |
| 444 4 9977 7 5 5 6 06 6 6 66 666666 66 |
| 4 4 99977 0 00000000000000000 0 0 |
| 7 00 0000000000000000 00 |
| 00000 0000000000 0 |
+--------------------------------------------------------------------+
THROUGH THE BOTTLENECK — original vs. what 2 numbers can rebuild:
digit 3: original -> reconstruction
==%%##.. -> ++%%##--
==##--%%:: -> --%%++**##..
....#### -> ::--::##**..
..%%**.. -> ..**%%++..
..####.. -> ::####::
..**== -> ..##==
==::--%%++ -> ==--==%%--
==####++ -> ++%%@@**..
digit 6: original -> reconstruction
#### -> ..**##::
--@@== -> ++%%++..
##@@:: -> ..##** ..
%%## -> ::%%##--::..
%%##==.. -> ::%%%%##**..
##@@##@@:: -> ..%%**==##--
==@@**%%== -> ++**++##==
..++%%**:: -> ..**%%**..
digit 8: original -> reconstruction
++%%==.. -> ==##%%++
##%%%%## -> ::%%++**##::
++** %%:: -> ::++::==**..
::@@##%%.. -> ..--**##--..
::@@@@.. -> ..++%%==..
::@@==**##.. -> ....++%%..
..%%..::@@== -> ====**##..
**@@%%**.. -> ==%%##--..
wrote results.json — 1797 real embedded points, 3 reconstruction demos
The 8s stay mixed into the middle of the map in both renderings. That is a real property of a 2-D code — an 8 shares its loops with 3s, 5s and 9s — not a rendering artefact, and it is left uncorrected.
The full log lives in docs/decisions.md. These are the ones that changed the product.
Hidden width 96, not the spec's 32.
At 32 units the ASCII map was one undifferentiated smear; the spec's own rule was "raise epochs/width until clusters are visible by eye."
No activation on the 2-unit bottleneck.
The code has to be free to live anywhere in the plane; a relu there would clamp the map into one quadrant and destroy half the space.
Sigmoid on the output layer.
The targets are the inputs and the inputs are pixels in [0, 1], so the output is squashed into the data's own range instead of spending capacity learning not to emit negatives.
train(X) takes no y parameter at all.
Makes "labels never reach training" a structural property of the code rather than a claim in a comment.
Seed 42 via keras.utils.set_random_seed.
Seeds Python, NumPy and the backend in one call, so a reader who runs the script gets the map that is on the site (verified: two runs produced byte-identical coordinates).
JSON embedded in the page rather than fetched.
Makes index.html work from file:// with no server, no CORS and no loading state.
Canvas scatter, not SVG or a chart library.
1797 nodes in the DOM is wasteful; canvas keeps a 60fps hover with zero dependencies, which the no-CDN rule required anyway.
Scatter bounds computed once over all points.
Hiding classes never rescales the space, so what you compare after a toggle is the same geometry you saw before it.
Touch taps ignore the following pointerleave.
A tap ends with pointerleave, which was wiping the label instantly on mobile; found by testing on a 390px touch viewport, not by reading the code.
CPU only, about ten seconds end to end. No GPU, no dataset download — load_digits ships inside scikit-learn.
1 — environment (Python 3.11; TensorFlow has no wheel for 3.14)
python3.11 -m venv .venv
source .venv/bin/activate
pip install tensorflow scikit-learn
2 — train, and read the ASCII map and reconstruction pairs in your terminal
python autoencoder.py
3 — re-export the run data this page is built from
python autoencoder.py --json # writes results.json
4 — run the test suite
python -m unittest discover -s tests -v
5 — view this page locally (no server required)
open site/index.html
After a re-train, re-embed the new data into this page with the snippet in site/README.md; the test suite fails if the page and results.json ever drift apart.
Five files. The interesting parts of each, and why they are the interesting parts.
The whole idea in fifteen lines. Two named Sequentials composed into a third — and the two-unit layer that everything else is arranged around.
encoder = keras.Sequential([
keras.Input(shape=(64,)),
layers.Dense(HIDDEN_UNITS, activation="relu"),
# THE BOTTLENECK. Two units. No activation, so the code is free to
# live anywhere in the plane.
layers.Dense(LATENT_DIM, name="bottleneck"),
], name="encoder")
decoder = keras.Sequential([
keras.Input(shape=(LATENT_DIM,)),
layers.Dense(HIDDEN_UNITS, activation="relu"),
# sigmoid, because the targets ARE the inputs and the inputs are
# pixels in [0, 1].
layers.Dense(64, activation="sigmoid"),
], name="decoder")
autoencoder = keras.Sequential([encoder, decoder], name="autoencoder")
The honesty guarantee is the function signature: there is no y to pass. fit(X, X) is the entire trick — the target is the input.
def train(X: np.ndarray) -> tuple[keras.Model, keras.Model, keras.Model, float]:
keras.utils.set_random_seed(SEED)
encoder, decoder, autoencoder = build_models()
autoencoder.compile(optimizer="adam", loss="mse")
autoencoder.fit(X, X, epochs=EPOCHS, batch_size=BATCH_SIZE, verbose=0)
final_mse = float(autoencoder.evaluate(X, X, verbose=0))
return encoder, decoder, autoencoder, final_mse
Why the terminal map is readable at all: 1,797 points collide on a 68×22 grid, so each cell prints the class that dominates it rather than whichever point was plotted last.
cells: dict[tuple[int, int], Counter] = {}
for x, row, label in zip(cx, cy, y):
cells.setdefault((x, row), Counter())[int(label)] += 1
grid = [[" "] * MAP_W for _ in range(MAP_H)]
for (x, row), counter in cells.items():
winner = counter.most_common(1)[0][0]
grid[MAP_H - 1 - row][x] = str(winner) # flip y so it reads like a plot
Bounds are computed once over every point, so toggling a class never rescales the space — the geometry you compare after a toggle is the geometry you saw before it.
// Bounds are computed ONCE over every point, so toggling classes never
// rescales the space.
PTS.forEach(function (p) { /* min/max over x and y */ });
function sx(x) { return (x - bx.min) / (bx.max - bx.min) * W; }
function sy(y) { return H - (y - by.min) / (by.max - by.min) * H; } // y up
canvas.addEventListener("pointerleave", function (ev) {
// A tap ends with pointerleave; clearing there would make the label
// flash and vanish on touch.
if (ev.pointerType === "touch") return;
hoverIdx = -1; tip.style.opacity = "0"; draw();
});
The suite tests the claims, not just the functions: that no label can reach fit, that the page's embedded data is the current run, and that the architecture drawn on this page matches the constants in the source.
def test_fit_target_is_the_input(self):
for call in re.findall(r"\.fit\(([^)]*)\)", self.src):
args = [a.strip() for a in call.split(",")]
self.assertEqual(args[0], "X")
self.assertEqual(args[1], "X", "fit() must be fit(X, X) — never fit(X, y)")
def test_page_states_the_real_architecture(self):
hidden = re.search(r"HIDDEN_UNITS = (\d+)", self.src).group(1)
diagram = re.search(r'<div class="arch".*?<div class="archlabels"', self.html, re.S).group(0)
self.assertIn(f'<div class="u">{hidden}</div>', diagram)
"""
autoencoder.py — 64 pixels squeezed through 2 numbers.
A tiny, complete lesson in unsupervised representation learning with Keras.
THE WHOLE IDEA
--------------
An 8x8 handwritten digit is 64 numbers. We ask a network to reproduce that
image *after* forcing it through a layer that is only 2 units wide. Nothing
else may pass. To rebuild a 3 from two numbers, the network has no choice but
to discover what actually varies between digits — stroke thickness, slant,
loop-vs-line — and spend its two coordinates on that. Those two numbers are
the "embedding", and when you plot all 1797 of them, digits of the same class
land near each other.
>>> NOBODY EVER TOLD IT WHAT A "3" IS. <<<
Read the training call below: `autoencoder.fit(X, X)`. The target is the
input. `y` (the digit labels) is loaded in this script and is used for exactly
two things: colouring the ASCII map at the end, and labelling the JSON export.
It is NEVER passed to fit(), never used in the loss, never seen by a gradient.
Every cluster you see in the map is something the network found on its own.
Usage
-----
python autoencoder.py # train + print the ASCII map & recon demos
python autoencoder.py --json # ...and write results.json for the site
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
from collections import Counter
import numpy as np
import keras
from keras import layers
from sklearn.datasets import load_digits
# --------------------------------------------------------------------------
# Hyper-parameters.
#
# The spec's starting point was 60 epochs / width 32. At that setting the
# ASCII map was a single smear — the network had learned *a* 2-D code, but not
# a well-separated one. Following the "raise epochs/width until clusters are
# visible by eye" rule, these are the values that actually produce clusters
# you can read off the terminal. Everything else is unchanged.
# --------------------------------------------------------------------------
SEED = 42
EPOCHS = 240
BATCH_SIZE = 64
HIDDEN_UNITS = 96 # width of the one hidden layer on each side
LATENT_DIM = 2 # the bottleneck. The entire point of this file.
MAP_W, MAP_H = 68, 22 # ASCII scatter-plot grid
RAMP = " .:-=+*#%@" # 10 shades, dark -> bright
EPS = 1e-9 # guards the ptp() normalisation against zero range
# ==========================================================================
# 1. DATA
# ==========================================================================
def load_data() -> tuple[np.ndarray, np.ndarray]:
"""1797 handwritten digits, 8x8, greyscale 0..16 -> flattened floats 0..1."""
digits = load_digits()
X = (digits.data / 16.0).astype("float32") # (1797, 64), pixels in [0, 1]
# ------------------------------------------------------------------
# LABELS ARE FOR DISPLAY ONLY.
# y is loaded so the ASCII map can print a '7' where a 7 landed, and so
# results.json can colour the scatter on the site. Search this file for
# `y` and you will find it in exactly one more place: the map/JSON code.
# It never touches the model, the loss, or fit().
# ------------------------------------------------------------------
y = digits.target.astype(int)
return X, y
# ==========================================================================
# 2. ARCHITECTURE — two named Sequentials, composed
# ==========================================================================
def build_models() -> tuple[keras.Model, keras.Model, keras.Model]:
"""encoder: 64 -> 2. decoder: 2 -> 64. autoencoder = decoder(encoder(x))."""
encoder = keras.Sequential(
[
keras.Input(shape=(64,)),
layers.Dense(HIDDEN_UNITS, activation="relu"),
# THE BOTTLENECK. Two units. No activation, so the code is free to
# live anywhere in the plane — these two numbers become the (x, y)
# of every point on the map. Everything the decoder will ever know
# about this image has to squeeze through here.
layers.Dense(LATENT_DIM, name="bottleneck"),
],
name="encoder",
)
decoder = keras.Sequential(
[
keras.Input(shape=(LATENT_DIM,)),
layers.Dense(HIDDEN_UNITS, activation="relu"),
# sigmoid, because the targets ARE the inputs and the inputs are
# pixels in [0, 1]. Squashing the output into the same range as the
# data means the model never wastes capacity learning "don't emit
# -3.2 for a black pixel".
layers.Dense(64, activation="sigmoid"),
],
name="decoder",
)
autoencoder = keras.Sequential([encoder, decoder], name="autoencoder")
return encoder, decoder, autoencoder
# ==========================================================================
# 3. TRAIN
# ==========================================================================
def train(X: np.ndarray) -> tuple[keras.Model, keras.Model, keras.Model, float]:
keras.utils.set_random_seed(SEED)
encoder, decoder, autoencoder = build_models()
autoencoder.compile(optimizer="adam", loss="mse")
# ------------------------------------------------------------------
# fit(X, X) IS THE ENTIRE TRICK.
# Supervised learning is fit(inputs, answers). Here the answer *is* the
# input: "give me back what I gave you, but you may only remember two
# numbers about it". No labels, no annotation, no human in the loop —
# the supervision signal is the data reconstructing itself.
# ------------------------------------------------------------------
autoencoder.fit(X, X, epochs=EPOCHS, batch_size=BATCH_SIZE, verbose=0)
final_mse = float(autoencoder.evaluate(X, X, verbose=0))
return encoder, decoder, autoencoder, final_mse
# ==========================================================================
# 4. TERMINAL OUTPUT (a): the ASCII map of the learned space
# ==========================================================================
def normalize_coords(Z: np.ndarray) -> np.ndarray:
"""Scale each latent axis into [0, 1]. ptp + eps so a flat axis can't /0."""
lo = Z.min(axis=0)
span = np.ptp(Z, axis=0) + EPS
return (Z - lo) / span
def ascii_map(Z: np.ndarray, y: np.ndarray) -> str:
"""Plot every digit's 2-D code on a MAP_W x MAP_H grid, drawn as its label.
Where several digits land in the same character cell we print the majority
label for that cell — that is what makes the clusters legible instead of
whichever point happened to be plotted last.
"""
N = normalize_coords(Z)
cx = np.clip((N[:, 0] * (MAP_W - 1)).round().astype(int), 0, MAP_W - 1)
cy = np.clip((N[:, 1] * (MAP_H - 1)).round().astype(int), 0, MAP_H - 1)
cells: dict[tuple[int, int], Counter] = {}
for x, row, label in zip(cx, cy, y):
cells.setdefault((x, row), Counter())[int(label)] += 1
grid = [[" "] * MAP_W for _ in range(MAP_H)]
for (x, row), counter in cells.items():
winner = counter.most_common(1)[0][0]
grid[MAP_H - 1 - row][x] = str(winner) # flip y so it reads like a plot
border = "+" + "-" * MAP_W + "+"
body = "\n".join("|" + "".join(row) + "|" for row in grid)
return f"{border}\n{body}\n{border}"
# ==========================================================================
# 5. TERMINAL OUTPUT (b): original vs reconstruction, side by side
# ==========================================================================
def ascii_digit(vec: np.ndarray) -> list[str]:
"""One 64-float image -> 8 rows of 16 chars (each pixel doubled for aspect)."""
rows = []
for r in range(8):
line = ""
for c in range(8):
v = float(vec[r * 8 + c])
idx = int(round(min(1.0, max(0.0, v)) * (len(RAMP) - 1))) # clamped
line += RAMP[idx] * 2 # doubled
rows.append(line)
return rows
def side_by_side(original: np.ndarray, recon: np.ndarray, label: int) -> str:
left, right = ascii_digit(original), ascii_digit(recon)
head = f" digit {label}: original".ljust(22) + " -> " + "reconstruction"
body = "\n".join(f" {a} -> {b}" for a, b in zip(left, right))
return f"{head}\n{body}"
def pick_demo_indices(y: np.ndarray, wanted=(3, 6, 8)) -> list[int]:
"""First occurrence of three visually distinct digits (display choice only)."""
return [int(np.where(y == w)[0][0]) for w in wanted]
# ==========================================================================
# 6. MAIN
# ==========================================================================
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--json",
action="store_true",
help="write results.json (real coordinates from this run) for the site",
)
args = parser.parse_args()
X, y = load_data()
print(f"data: {X.shape[0]} digits x {X.shape[1]} pixels, range "
f"[{X.min():.2f}, {X.max():.2f}]")
print(f"training {EPOCHS} epochs through a {LATENT_DIM}-unit bottleneck "
f"(labels are NOT used)...")
encoder, decoder, autoencoder, final_mse = train(X)
print(f"final reconstruction MSE: {final_mse:.6f}")
# The learned space. This is the only forward pass that matters.
Z = encoder.predict(X, verbose=0)
recon = autoencoder.predict(X, verbose=0)
print("\nTHE LEARNED SPACE — 1797 digits, each drawn at its own 2 numbers.")
print("Characters are the true labels, painted on AFTER training, purely so")
print("you can see that the clusters the network found line up with them.\n")
print(ascii_map(Z, y))
print("\nTHROUGH THE BOTTLENECK — original vs. what 2 numbers can rebuild:\n")
for i in pick_demo_indices(y):
print(side_by_side(X[i], recon[i], int(y[i])))
print()
if args.json:
payload = {
"run_date": _dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"keras_version": keras.__version__,
"seed": SEED,
"epochs": EPOCHS,
"hidden_units": HIDDEN_UNITS,
"final_mse": round(final_mse, 8),
"points": [
{"x": round(float(zx), 5), "y": round(float(zy), 5), "label": int(lbl)}
for (zx, zy), lbl in zip(Z, y)
],
"recon_demos": [
{
"label": int(y[i]),
"original": [round(float(v), 5) for v in X[i]],
"reconstructed": [round(float(v), 5) for v in recon[i]],
}
for i in pick_demo_indices(y)
],
}
with open("results.json", "w") as fh:
json.dump(payload, fh)
print(f"wrote results.json — {len(payload['points'])} real embedded points, "
f"{len(payload['recon_demos'])} reconstruction demos")
if __name__ == "__main__":
main()