Machine learning

Embedding space

Models think in hundreds of dimensions. Screens have two. Every embedding plot you have seen is the result of squeezing one into the other, and something always breaks in the squeeze.

EXP-025

Move the cursor over the canvas — it is part of the simulation.

Drag your finger across the canvas — it is part of the simulation.

What to look for

The number that matters is the residual distortion at the bottom. Take the true dimensions up to 24 and watch it climb: there is simply not enough room in a plane to keep all those distances honest. This is the caveat under every t-SNE picture in every paper, and it is usually not printed.

The maths

dᵢⱼ = ‖xᵢ − xⱼ‖

The target: the distance between every pair of points in the original high-dimensional space. This matrix is the only thing carried over; the coordinates themselves are thrown away.

stress = Σᵢⱼ (‖yᵢ − yⱼ‖ − dᵢⱼ)²

What we are minimising: the total disagreement between distances on screen and distances in the real space. Classical multidimensional scaling, going back to Torgerson in 1952.

yᵢ += ((d − dᵢⱼ)/d)·(yⱼ − yᵢ)·η

The relaxation step, applied to random pairs. If two points are closer on screen than they should be, push them apart; if further, pull them together. Repeat forever. It is a spring network where every pair has its own rest length.

How it is built

Points are generated in genuinely high-dimensional space around random cluster centres, then their full pairwise distance matrix is computed once and used as the target. The layout starts as a blob and relaxes. Nothing here knows which cluster a point belongs to: the grouping you see on screen is recovered purely from distances.

The code, step by step

Cut straight from lab.js. These are the real lines that run above, not a simplified version.

01

Build the points in real high dimensions

const centres = Array.from({ length: clus }, () =>
  Array.from({ length: dim }, () => (Math.random() * 2 - 1) * 2.2));
for (let c = 0; c < clus; c++) {
  for (let i = 0; i < 26; i++) {
    hi.push(centres[c].map(v => v + (Math.random() * 2 - 1) * 0.45));
    lab.push(c);
    lo.push([w / 2 + (Math.random() - 0.5) * 40, h / 2 + (Math.random() - 0.5) * 40]);

The clusters genuinely live in as many dimensions as the slider says — this is not a 2D picture pretending. The screen positions start as a small random blob in the middle, so whatever structure appears was found, not planted.

02

The distance matrix is the only input

target = new Float32Array(n * n);
let mx = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
  let s = 0;
  for (let k = 0; k < dim; k++) { const d = hi[i][k] - hi[j][k]; s += d * d; }
  const v = Math.sqrt(s); target[i * n + j] = v; if (v > mx) mx = v;
}
const scale = (Math.min(w, h) * 0.40) / mx;
for (let i = 0; i < n * n; i++) target[i] *= scale;

Every pairwise distance is computed once in the original space and scaled to fit the canvas. From here on the high-dimensional coordinates are never touched again: the layout has access to distances and nothing else, which is exactly the constraint that classical MDS operates under.

03

Relax it like a spring network

for (let s = 0; s < 3; s++) {
  for (let i = 0; i < n; i++) {
    const j = (Math.random() * n) | 0;
    if (i === j) continue;
    const dx = lo[j][0] - lo[i][0], dy = lo[j][1] - lo[i][1];
    const d = Math.hypot(dx, dy) + 1e-6;
    const want = target[i * n + j];
    const push = ((d - want) / d) * rate * 0.5;
    lo[i][0] += dx * push; lo[i][1] += dy * push;
    lo[j][0] -= dx * push; lo[j][1] -= dy * push;

Take a random pair, compare their distance on screen against what it should be, and move both toward agreement. Random pairs instead of all pairs keeps the cost linear per iteration, and over many frames every pair gets its turn. The residual error printed at the bottom is what never goes away, because the plane cannot hold that much structure.

Why it matters

Because these plots get read as if they were maps. Distances between clusters in a t-SNE or UMAP figure are frequently meaningless, and cluster sizes almost always are. Seeing the distortion percentage move while you change the dimension is the fastest way to stop trusting them more than they deserve.

The whole thing

Everything above, in one piece. This is the entire experiment as it lives in lab.js.

FULL

lab.js · embedding

{ id: 'embedding',
      name: L('Embedding space', 'Espacio de embeddings'),
      note: L('High-dimensional structure squeezed into a plane, live.',
              'Estructura de muchas dimensiones exprimida a un plano, en vivo.'),
      params: [
        { key: 'dim',  label: L('True dimensions', 'Dimensiones reales'), min: 3, max: 24, step: 1, def: 10 },
        { key: 'clus', label: L('Clusters', 'Grupos'), min: 2, max: 8, step: 1, def: 5 },
        { key: 'rate', label: L('Relaxation rate', 'Tasa de relajación'), min: 0.01, max: 0.4, step: 0.01, def: 0.12 }
      ],
      make(w, h) {
        let hi = [], lo = [], lab = [], D = 0, C = 0, target = null;
        const build = (dim, clus, w, h) => {
          D = dim; C = clus; hi = []; lo = []; lab = [];
          const centres = Array.from({ length: clus }, () =>
            Array.from({ length: dim }, () => (Math.random() * 2 - 1) * 2.2));
          for (let c = 0; c < clus; c++) {
            for (let i = 0; i < 26; i++) {
              hi.push(centres[c].map(v => v + (Math.random() * 2 - 1) * 0.45));
              lab.push(c);
              lo.push([w / 2 + (Math.random() - 0.5) * 40, h / 2 + (Math.random() - 0.5) * 40]);
            }
          }
          // Matriz de distancias reales en alta dimensión: es el objetivo
          const n = hi.length;
          target = new Float32Array(n * n);
          let mx = 0;
          for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
            let s = 0;
            for (let k = 0; k < dim; k++) { const d = hi[i][k] - hi[j][k]; s += d * d; }
            const v = Math.sqrt(s); target[i * n + j] = v; if (v > mx) mx = v;
          }
          const scale = (Math.min(w, h) * 0.40) / mx;
          for (let i = 0; i < n * n; i++) target[i] *= scale;
        };
        build(10, 5, w, h);
        return {
          resize(w, h) { build(D, C, w, h); },
          reset() { build(D, C, w, h); },
          step(ctx, w, h, t, acc, P, M) {
            if ((P.dim | 0) !== D || (P.clus | 0) !== C) build(P.dim | 0, P.clus | 0, w, h);
            const n = lo.length, rate = P.rate;
            // Escalado multidimensional por relajación: acerca o aleja cada par
            // hasta que la distancia en el plano se parezca a la real.
            for (let s = 0; s < 3; s++) {
              for (let i = 0; i < n; i++) {
                const j = (Math.random() * n) | 0;
                if (i === j) continue;
                const dx = lo[j][0] - lo[i][0], dy = lo[j][1] - lo[i][1];
                const d = Math.hypot(dx, dy) + 1e-6;
                const want = target[i * n + j];
                const push = ((d - want) / d) * rate * 0.5;
                lo[i][0] += dx * push; lo[i][1] += dy * push;
                lo[j][0] -= dx * push; lo[j][1] -= dy * push;
              }
            }
            // Error residual: cuánto no cabe en dos dimensiones
            let err = 0, cnt = 0;
            for (let q = 0; q < 400; q++) {
              const i = (Math.random() * n) | 0, j = (Math.random() * n) | 0;
              if (i === j) continue;
              const d = Math.hypot(lo[j][0] - lo[i][0], lo[j][1] - lo[i][1]);
              const want = target[i * n + j];
              err += Math.abs(d - want) / (want + 1); cnt++;
            }
            err = cnt ? err / cnt : 0;

            for (let i = 0; i < n; i++) {
              const c = lab[i], a = 0.35 + (c / Math.max(1, C - 1)) * 0.6;
              ctx.fillStyle = c % 2 ? `rgba(${acc},${a.toFixed(2)})` : `rgba(235,238,245,${(a * 0.7).toFixed(2)})`;
              ctx.beginPath(); ctx.arc(lo[i][0], lo[i][1], 2.6, 0, TAU); ctx.fill();
            }
            ctx.font = '11px monospace';
            ctx.fillStyle = `rgba(${acc},0.55)`;
            ctx.fillText('pairwise distances in ' + D + 'D, forced onto 2D by relaxation', 12, h - 28);
            ctx.fillStyle = `rgba(${acc},0.95)`;
            ctx.fillText(D + ' dimensions → 2   ' + C + ' clusters   residual distortion ' +
              (err * 100).toFixed(1) + '%', 12, h - 12);
          }
        };
      }
    },