<p class="hint">200 points in 10D (4 clusters of 50). Press <b>Project</b> to run gradient descent — close pairs attract, distant pairs repel — and watch the clusters separate in 2D.</p>
<canvas id="canvas" width="480" height="320"></canvas>
<div class="btns">
<button id="btn-project" type="button">Project</button>
<button id="btn-regen" type="button" class="ghost">Regenerate</button>
</div>
<div class="info" id="info">Click Project to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
canvas { display: block; width: 100%; max-width: 480px; height: auto;
border-radius: 10px; background: #f4f6f9; border: 1px solid #d0d8e4; }
.btns { display: flex; gap: .5rem; margin-top: .7rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.info { font-size: .88rem; color: #555; margin-top: .45rem; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Neighbourhood-preserving 2D projection demo (t-SNE / UMAP concept):
// 1. Generate 200 points in 10D with 4 Gaussian clusters.
// 2. Precompute pairwise 10D distances (spring rest lengths).
// 3. Initialise 2D positions randomly.
// 4. Spring-force gradient descent: each pair has a rest length proportional
// to its 10D distance. Pairs close in 10D get short springs (attract);
// pairs far in 10D get long springs (repel). After convergence the 4 clusters of 50 points pull apart visibly — this is the key insight behind t-SNE/UMAP.
const N = 200, DIM = 10, K = 4, MAX_ITER = 500, LR = 0.01, BATCH = 10;
const COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d'];
let pts10 = [], labels = [], pts2 = [], dist10 = null, median10 = 1;
let animId = null, iter = 0;
// Seeded PRNG (LCG)
let seed = Date.now();
function rand() {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return ((seed >>> 0) / 0x100000000);
}
function randn() {
let u = 0, v = 0;
while (u === 0) u = rand();
while (v === 0) v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generate() {
seed = (Date.now() ^ (Math.random() * 0x100000000)) | 0;
pts10 = []; labels = [];
// 4 random cluster centres in 10D, well separated
const centres = Array.from({ length: K }, () =>
Array.from({ length: DIM }, () => randn() * 3)
);
for (let k = 0; k < K; k++) {
for (let i = 0; i < N / K; i++) {
pts10.push(centres[k].map(c => c + randn() * 0.6));
labels.push(k);
}
}
// Precompute pairwise 10D Euclidean distances
dist10 = new Float32Array(N * N);
const allDists = [];
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
let d = 0;
for (let d_ = 0; d_ < DIM; d_++) d += (pts10[i][d_] - pts10[j][d_]) ** 2;
d = Math.sqrt(d);
dist10[i * N + j] = d;
dist10[j * N + i] = d;
allDists.push(d);
}
}
// Median 10D distance used to scale 2D spring rest lengths
allDists.sort((a, b) => a - b);
median10 = allDists[allDists.length >> 1];
// Random 2D init
pts2 = Array.from({ length: N }, () => [randn() * 0.5, randn() * 0.5]);
}
function step(steps) {
// Spring-force gradient: for a random sample of pairs,
// rest length in 2D = (10D distance / median10) * scale.
// The spring pulls/pushes the 2D positions to match 10D proximity.
const scale = 0.8;
for (let s = 0; s < steps; s++) {
const grad = Array.from({ length: N }, () => [0, 0]);
for (let p = 0; p < N * 4; p++) {
const i = (rand() * N) | 0;
const j = (rand() * N) | 0;
if (i === j) continue;
const target = dist10[i * N + j] / median10 * scale;
const dx = pts2[i][0] - pts2[j][0];
const dy = pts2[i][1] - pts2[j][1];
const d2 = Math.sqrt(dx * dx + dy * dy) + 1e-6;
const stretch = d2 - target;
const fx = stretch * dx / d2;
const fy = stretch * dy / d2;
grad[i][0] += fx; grad[i][1] += fy;
grad[j][0] -= fx; grad[j][1] -= fy;
}
for (let i = 0; i < N; i++) {
pts2[i][0] -= LR * grad[i][0];
pts2[i][1] -= LR * grad[i][1];
}
}
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Compute bounding box with padding
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const [x, y] of pts2) {
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
const pad = 28, W = canvas.width - 2 * pad, H = canvas.height - 2 * pad;
const rangeX = maxX - minX + 1e-9, rangeY = maxY - minY + 1e-9;
const scale = Math.min(W / rangeX, H / rangeY);
const offX = pad + (W - rangeX * scale) / 2;
const offY = pad + (H - rangeY * scale) / 2;
// Draw points
for (let i = 0; i < N; i++) {
const cx = offX + (pts2[i][0] - minX) * scale;
const cy = offY + (pts2[i][1] - minY) * scale;
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = COLORS[labels[i]];
ctx.globalAlpha = 0.85;
ctx.fill();
}
ctx.globalAlpha = 1;
// Legend (top-left)
ctx.font = '11px system-ui, sans-serif';
for (let k = 0; k < K; k++) {
ctx.fillStyle = COLORS[k];
ctx.beginPath(); ctx.arc(16, 16 + k * 18, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#333';
ctx.fillText(fmt('Cluster %k%%v%', { k: k, v: 1 }), 26, 20 + k * 18);
}
}
function project() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
iter = 0;
const infoEl = document.getElementById('info');
function frame() {
step(BATCH);
iter += BATCH;
draw();
infoEl.textContent = fmt('Step %iter% / %MAX_ITER%%v%', { iter: iter, MAX_ITER: MAX_ITER, v: iter >= MAX_ITER ? ' — done. Clusters are now separated in 2D.' : ' — optimising…' });
if (iter < MAX_ITER) animId = requestAnimationFrame(frame);
else animId = null;
}
frame();
}
document.getElementById('btn-project').addEventListener('click', project);
document.getElementById('btn-regen').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
generate();
draw();
document.getElementById('info').textContent = 'New dataset generated. Click Project to run.';
iter = 0;
});
// Initialise
generate();
draw();
document.getElementById('info').textContent = 'Dataset ready (10D, 4 clusters). Click Project to run the projection.';
<p class="hint">200 points in 10D (4 clusters of 50). Press <b>Project</b> to run gradient descent — close pairs attract, distant pairs repel — and watch the clusters separate in 2D.</p>
<canvas id="canvas" width="480" height="320"></canvas>
<div class="btns">
<button id="btn-project" type="button">Project</button>
<button id="btn-regen" type="button" class="ghost">Regenerate</button>
</div>
<div class="info" id="info">Click Project to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
canvas { display: block; width: 100%; max-width: 480px; height: auto;
border-radius: 10px; background: #f4f6f9; border: 1px solid #d0d8e4; }
.btns { display: flex; gap: .5rem; margin-top: .7rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.info { font-size: .88rem; color: #555; margin-top: .45rem; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Neighbourhood-preserving 2D projection demo (t-SNE / UMAP concept):
// 1. Generate 200 points in 10D with 4 Gaussian clusters.
// 2. Precompute pairwise 10D distances (spring rest lengths).
// 3. Initialise 2D positions randomly.
// 4. Spring-force gradient descent: each pair has a rest length proportional
// to its 10D distance. Pairs close in 10D get short springs (attract);
// pairs far in 10D get long springs (repel). After convergence the 4 clusters of 50 points pull apart visibly — this is the key insight behind t-SNE/UMAP.
const N = 200, DIM = 10, K = 4, MAX_ITER = 500, LR = 0.01, BATCH = 10;
const COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d'];
let pts10 = [], labels = [], pts2 = [], dist10 = null, median10 = 1;
let animId = null, iter = 0;
// Seeded PRNG (LCG)
let seed = Date.now();
function rand() {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return ((seed >>> 0) / 0x100000000);
}
function randn() {
let u = 0, v = 0;
while (u === 0) u = rand();
while (v === 0) v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generate() {
seed = (Date.now() ^ (Math.random() * 0x100000000)) | 0;
pts10 = []; labels = [];
// 4 random cluster centres in 10D, well separated
const centres = Array.from({ length: K }, () =>
Array.from({ length: DIM }, () => randn() * 3)
);
for (let k = 0; k < K; k++) {
for (let i = 0; i < N / K; i++) {
pts10.push(centres[k].map(c => c + randn() * 0.6));
labels.push(k);
}
}
// Precompute pairwise 10D Euclidean distances
dist10 = new Float32Array(N * N);
const allDists = [];
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
let d = 0;
for (let d_ = 0; d_ < DIM; d_++) d += (pts10[i][d_] - pts10[j][d_]) ** 2;
d = Math.sqrt(d);
dist10[i * N + j] = d;
dist10[j * N + i] = d;
allDists.push(d);
}
}
// Median 10D distance used to scale 2D spring rest lengths
allDists.sort((a, b) => a - b);
median10 = allDists[allDists.length >> 1];
// Random 2D init
pts2 = Array.from({ length: N }, () => [randn() * 0.5, randn() * 0.5]);
}
function step(steps) {
// Spring-force gradient: for a random sample of pairs,
// rest length in 2D = (10D distance / median10) * scale.
// The spring pulls/pushes the 2D positions to match 10D proximity.
const scale = 0.8;
for (let s = 0; s < steps; s++) {
const grad = Array.from({ length: N }, () => [0, 0]);
for (let p = 0; p < N * 4; p++) {
const i = (rand() * N) | 0;
const j = (rand() * N) | 0;
if (i === j) continue;
const target = dist10[i * N + j] / median10 * scale;
const dx = pts2[i][0] - pts2[j][0];
const dy = pts2[i][1] - pts2[j][1];
const d2 = Math.sqrt(dx * dx + dy * dy) + 1e-6;
const stretch = d2 - target;
const fx = stretch * dx / d2;
const fy = stretch * dy / d2;
grad[i][0] += fx; grad[i][1] += fy;
grad[j][0] -= fx; grad[j][1] -= fy;
}
for (let i = 0; i < N; i++) {
pts2[i][0] -= LR * grad[i][0];
pts2[i][1] -= LR * grad[i][1];
}
}
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Compute bounding box with padding
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const [x, y] of pts2) {
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
const pad = 28, W = canvas.width - 2 * pad, H = canvas.height - 2 * pad;
const rangeX = maxX - minX + 1e-9, rangeY = maxY - minY + 1e-9;
const scale = Math.min(W / rangeX, H / rangeY);
const offX = pad + (W - rangeX * scale) / 2;
const offY = pad + (H - rangeY * scale) / 2;
// Draw points
for (let i = 0; i < N; i++) {
const cx = offX + (pts2[i][0] - minX) * scale;
const cy = offY + (pts2[i][1] - minY) * scale;
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = COLORS[labels[i]];
ctx.globalAlpha = 0.85;
ctx.fill();
}
ctx.globalAlpha = 1;
// Legend (top-left)
ctx.font = '11px system-ui, sans-serif';
for (let k = 0; k < K; k++) {
ctx.fillStyle = COLORS[k];
ctx.beginPath(); ctx.arc(16, 16 + k * 18, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#333';
ctx.fillText(fmt('Cluster %k%%v%', { k: k, v: 1 }), 26, 20 + k * 18);
}
}
function project() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
iter = 0;
const infoEl = document.getElementById('info');
function frame() {
step(BATCH);
iter += BATCH;
draw();
infoEl.textContent = fmt('Step %iter% / %MAX_ITER%%v%', { iter: iter, MAX_ITER: MAX_ITER, v: iter >= MAX_ITER ? ' — done. Clusters are now separated in 2D.' : ' — optimising…' });
if (iter < MAX_ITER) animId = requestAnimationFrame(frame);
else animId = null;
}
frame();
}
document.getElementById('btn-project').addEventListener('click', project);
document.getElementById('btn-regen').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
generate();
draw();
document.getElementById('info').textContent = 'New dataset generated. Click Project to run.';
iter = 0;
});
// Initialise
generate();
draw();
document.getElementById('info').textContent = 'Dataset ready (10D, 4 clusters). Click Project to run the projection.';
Comments
Loading comments...