The canvas below shows a simplified 2D embedding space. Each color represents a class (circles, triangles, squares). Points start at random positions. Press Train one step to apply a contrastive update: pairs of same-class points are pulled together, different-class pairs are pushed apart. Press Auto-train to watch the structure emerge.
<div class="controls">
<button id="step" type="button">Train one step</button>
<button id="auto" type="button">Auto-train</button>
<button id="reset" type="button" class="ghost">Reset</button>
<span class="step-counter">Step: <b id="stepCount">0</b></span>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<div class="legend">
<span class="dot" style="background:#e63946"></span> Circles
<span class="dot" style="background:#2a9d8f"></span> Triangles
<span class="dot" style="background:#f4a261"></span> Squares
</div>
<p class="hint">Each color is a class. Same-class pairs attract; different-class pairs repel. Watch clusters form.</p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.step-counter { font-size: .9rem; color: #555; margin-left: .2rem; }
canvas { display: block; border: 1px solid #dde3ea; border-radius: 10px;
background: #f7f9fb; max-width: 100%; }
.legend { display: flex; align-items: center; gap: .2rem; font-size: .85rem;
margin-top: .5rem; color: #444; flex-wrap: wrap; }
.dot { display: inline-block; width: 11px; height: 11px; border-radius: 50%; }
.hint { font-size: .85rem; color: #555; margin: .4rem 0 0; line-height: 1.4; }
// Contrastive learning demo — 2D embedding space.
// Three classes (0=circles, 1=triangles, 2=squares), 5 points each.
// Each training step samples a positive pair (same class) and a negative
// pair (different class) for every anchor, then applies a simplified contrastive gradient: pull positives closer, push negatives farther.
const W = 480, H = 340;
const CLASSES = 3, PPC = 5; // points per class
const COLORS = ['#e63946', '#2a9d8f', '#f4a261'];
const SHAPES = ['circle', 'triangle', 'square'];
const TEMP = 0.15; // temperature τ
const LR = 0.012; // learning rate
const MARGIN = 0.35; // push negatives at least this far
let pts, stepCount, autoTimer;
function rand() { return Math.random(); }
function init() {
pts = [];
for (let c = 0; c < CLASSES; c++)
for (let i = 0; i < PPC; i++)
pts.push({ x: rand(), y: rand(), cls: c });
stepCount = 0;
document.getElementById('stepCount').textContent = 0;
draw();
}
// Euclidean distance in [0,1]^2
function dist(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx*dx + dy*dy);
}
function trainStep() {
// For each point as anchor, find one positive and one negative,
// apply a gradient-like nudge.
const n = pts.length;
const deltas = pts.map(() => ({ dx: 0, dy: 0 }));
for (let i = 0; i < n; i++) {
const anchor = pts[i];
// Positive: closest same-class point (exclude self)
let posBest = Infinity, posIdx = -1;
// Negative: closest different-class point
let negBest = Infinity, negIdx = -1;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const d = dist(anchor, pts[j]);
if (pts[j].cls === anchor.cls && d < posBest) { posBest = d; posIdx = j; }
if (pts[j].cls !== anchor.cls && d < negBest) { negBest = d; negIdx = j; }
}
if (posIdx === -1 || negIdx === -1) continue;
// Pull anchor toward positive
const pos = pts[posIdx];
const neg = pts[negIdx];
const pullStr = LR * posBest; // proportional to distance
deltas[i].dx += (pos.x - anchor.x) * pullStr / (posBest + 1e-6);
deltas[i].dy += (pos.y - anchor.y) * pullStr / (posBest + 1e-6);
// Push anchor away from negative (only if closer than margin)
if (negBest < MARGIN) {
const pushStr = LR * (MARGIN - negBest);
deltas[i].dx -= (neg.x - anchor.x) * pushStr / (negBest + 1e-6);
deltas[i].dy -= (neg.y - anchor.y) * pushStr / (negBest + 1e-6);
}
}
// Apply and clamp to [0,1]
for (let i = 0; i < n; i++) {
pts[i].x = Math.min(1, Math.max(0, pts[i].x + deltas[i].dx));
pts[i].y = Math.min(1, Math.max(0, pts[i].y + deltas[i].dy));
}
stepCount++;
document.getElementById('stepCount').textContent = stepCount;
draw();
}
function drawShape(ctx, shape, x, y, r, color) {
ctx.beginPath();
ctx.fillStyle = color;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
if (shape === 'circle') {
ctx.arc(x, y, r, 0, Math.PI * 2);
} else if (shape === 'triangle') {
ctx.moveTo(x, y - r);
ctx.lineTo(x + r * 0.87, y + r * 0.5);
ctx.lineTo(x - r * 0.87, y + r * 0.5);
ctx.closePath();
} else {
ctx.rect(x - r, y - r, r * 2, r * 2);
}
ctx.fill();
ctx.stroke();
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const pad = 28;
const uw = canvas.width - 2 * pad;
const uh = canvas.height - 2 * pad;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Axes
ctx.strokeStyle = '#dde3ea';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad, pad); ctx.lineTo(pad, pad + uh);
ctx.lineTo(pad + uw, pad + uh);
ctx.stroke();
// Soft lines between same-class pairs
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
if (pts[i].cls !== pts[j].cls) continue;
const d = dist(pts[i], pts[j]);
if (d > 0.5) continue; // only draw if somewhat close
ctx.beginPath();
ctx.strokeStyle = COLORS[pts[i].cls] + '44';
ctx.lineWidth = 1;
ctx.moveTo(pad + pts[i].x * uw, pad + (1 - pts[i].y) * uh);
ctx.lineTo(pad + pts[j].x * uw, pad + (1 - pts[j].y) * uh);
ctx.stroke();
}
}
// Points
const R = 9;
for (const p of pts) {
const px = pad + p.x * uw;
const py = pad + (1 - p.y) * uh;
drawShape(ctx, SHAPES[p.cls], px, py, R, COLORS[p.cls]);
}
}
let autoRunning = false;
function toggleAuto() {
autoRunning = !autoRunning;
const btn = document.getElementById('auto');
const stepBtn = document.getElementById('step');
if (autoRunning) {
btn.textContent = 'Stop';
stepBtn.disabled = true;
autoTimer = setInterval(() => {
trainStep();
// Auto-stop when clusters are tight enough
const avgIntra = avgIntraClassDist();
if (avgIntra < 0.05 || stepCount >= 500) toggleAuto();
}, 80);
} else {
btn.textContent = 'Auto-train';
stepBtn.disabled = false;
clearInterval(autoTimer);
}
}
function avgIntraClassDist() {
let total = 0, count = 0;
for (let i = 0; i < pts.length; i++)
for (let j = i + 1; j < pts.length; j++)
if (pts[i].cls === pts[j].cls) { total += dist(pts[i], pts[j]); count++; }
return count ? total / count : 1;
}
document.getElementById('step').addEventListener('click', trainStep);
document.getElementById('auto').addEventListener('click', toggleAuto);
document.getElementById('reset').addEventListener('click', () => {
if (autoRunning) toggleAuto();
init();
});
init();
// Verify (node): after enough steps same-class avg dist < cross-class avg dist
// console.assert(avgIntraClassDist() <= 1, 'init distances within [0,1]');
<div class="controls">
<button id="step" type="button">Train one step</button>
<button id="auto" type="button">Auto-train</button>
<button id="reset" type="button" class="ghost">Reset</button>
<span class="step-counter">Step: <b id="stepCount">0</b></span>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<div class="legend">
<span class="dot" style="background:#e63946"></span> Circles
<span class="dot" style="background:#2a9d8f"></span> Triangles
<span class="dot" style="background:#f4a261"></span> Squares
</div>
<p class="hint">Each color is a class. Same-class pairs attract; different-class pairs repel. Watch clusters form.</p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.step-counter { font-size: .9rem; color: #555; margin-left: .2rem; }
canvas { display: block; border: 1px solid #dde3ea; border-radius: 10px;
background: #f7f9fb; max-width: 100%; }
.legend { display: flex; align-items: center; gap: .2rem; font-size: .85rem;
margin-top: .5rem; color: #444; flex-wrap: wrap; }
.dot { display: inline-block; width: 11px; height: 11px; border-radius: 50%; }
.hint { font-size: .85rem; color: #555; margin: .4rem 0 0; line-height: 1.4; }
// Contrastive learning demo — 2D embedding space.
// Three classes (0=circles, 1=triangles, 2=squares), 5 points each.
// Each training step samples a positive pair (same class) and a negative
// pair (different class) for every anchor, then applies a simplified contrastive gradient: pull positives closer, push negatives farther.
const W = 480, H = 340;
const CLASSES = 3, PPC = 5; // points per class
const COLORS = ['#e63946', '#2a9d8f', '#f4a261'];
const SHAPES = ['circle', 'triangle', 'square'];
const TEMP = 0.15; // temperature τ
const LR = 0.012; // learning rate
const MARGIN = 0.35; // push negatives at least this far
let pts, stepCount, autoTimer;
function rand() { return Math.random(); }
function init() {
pts = [];
for (let c = 0; c < CLASSES; c++)
for (let i = 0; i < PPC; i++)
pts.push({ x: rand(), y: rand(), cls: c });
stepCount = 0;
document.getElementById('stepCount').textContent = 0;
draw();
}
// Euclidean distance in [0,1]^2
function dist(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx*dx + dy*dy);
}
function trainStep() {
// For each point as anchor, find one positive and one negative,
// apply a gradient-like nudge.
const n = pts.length;
const deltas = pts.map(() => ({ dx: 0, dy: 0 }));
for (let i = 0; i < n; i++) {
const anchor = pts[i];
// Positive: closest same-class point (exclude self)
let posBest = Infinity, posIdx = -1;
// Negative: closest different-class point
let negBest = Infinity, negIdx = -1;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const d = dist(anchor, pts[j]);
if (pts[j].cls === anchor.cls && d < posBest) { posBest = d; posIdx = j; }
if (pts[j].cls !== anchor.cls && d < negBest) { negBest = d; negIdx = j; }
}
if (posIdx === -1 || negIdx === -1) continue;
// Pull anchor toward positive
const pos = pts[posIdx];
const neg = pts[negIdx];
const pullStr = LR * posBest; // proportional to distance
deltas[i].dx += (pos.x - anchor.x) * pullStr / (posBest + 1e-6);
deltas[i].dy += (pos.y - anchor.y) * pullStr / (posBest + 1e-6);
// Push anchor away from negative (only if closer than margin)
if (negBest < MARGIN) {
const pushStr = LR * (MARGIN - negBest);
deltas[i].dx -= (neg.x - anchor.x) * pushStr / (negBest + 1e-6);
deltas[i].dy -= (neg.y - anchor.y) * pushStr / (negBest + 1e-6);
}
}
// Apply and clamp to [0,1]
for (let i = 0; i < n; i++) {
pts[i].x = Math.min(1, Math.max(0, pts[i].x + deltas[i].dx));
pts[i].y = Math.min(1, Math.max(0, pts[i].y + deltas[i].dy));
}
stepCount++;
document.getElementById('stepCount').textContent = stepCount;
draw();
}
function drawShape(ctx, shape, x, y, r, color) {
ctx.beginPath();
ctx.fillStyle = color;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
if (shape === 'circle') {
ctx.arc(x, y, r, 0, Math.PI * 2);
} else if (shape === 'triangle') {
ctx.moveTo(x, y - r);
ctx.lineTo(x + r * 0.87, y + r * 0.5);
ctx.lineTo(x - r * 0.87, y + r * 0.5);
ctx.closePath();
} else {
ctx.rect(x - r, y - r, r * 2, r * 2);
}
ctx.fill();
ctx.stroke();
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const pad = 28;
const uw = canvas.width - 2 * pad;
const uh = canvas.height - 2 * pad;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Axes
ctx.strokeStyle = '#dde3ea';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad, pad); ctx.lineTo(pad, pad + uh);
ctx.lineTo(pad + uw, pad + uh);
ctx.stroke();
// Soft lines between same-class pairs
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
if (pts[i].cls !== pts[j].cls) continue;
const d = dist(pts[i], pts[j]);
if (d > 0.5) continue; // only draw if somewhat close
ctx.beginPath();
ctx.strokeStyle = COLORS[pts[i].cls] + '44';
ctx.lineWidth = 1;
ctx.moveTo(pad + pts[i].x * uw, pad + (1 - pts[i].y) * uh);
ctx.lineTo(pad + pts[j].x * uw, pad + (1 - pts[j].y) * uh);
ctx.stroke();
}
}
// Points
const R = 9;
for (const p of pts) {
const px = pad + p.x * uw;
const py = pad + (1 - p.y) * uh;
drawShape(ctx, SHAPES[p.cls], px, py, R, COLORS[p.cls]);
}
}
let autoRunning = false;
function toggleAuto() {
autoRunning = !autoRunning;
const btn = document.getElementById('auto');
const stepBtn = document.getElementById('step');
if (autoRunning) {
btn.textContent = 'Stop';
stepBtn.disabled = true;
autoTimer = setInterval(() => {
trainStep();
// Auto-stop when clusters are tight enough
const avgIntra = avgIntraClassDist();
if (avgIntra < 0.05 || stepCount >= 500) toggleAuto();
}, 80);
} else {
btn.textContent = 'Auto-train';
stepBtn.disabled = false;
clearInterval(autoTimer);
}
}
function avgIntraClassDist() {
let total = 0, count = 0;
for (let i = 0; i < pts.length; i++)
for (let j = i + 1; j < pts.length; j++)
if (pts[i].cls === pts[j].cls) { total += dist(pts[i], pts[j]); count++; }
return count ? total / count : 1;
}
document.getElementById('step').addEventListener('click', trainStep);
document.getElementById('auto').addEventListener('click', toggleAuto);
document.getElementById('reset').addEventListener('click', () => {
if (autoRunning) toggleAuto();
init();
});
init();
// Verify (node): after enough steps same-class avg dist < cross-class avg dist
// console.assert(avgIntraClassDist() <= 1, 'init distances within [0,1]');
Notice how points of the same color cluster together while points of different colors spread apart — even though the model was never told the class labels explicitly. It learned the geometry purely from which points were treated as similar pairs. That is the heart of contrastive learning.
Comments
Loading comments...