This is a real, if tiny, CLIP. The "images" are nine coloured shapes described by seven hand-made numbers (colour and shape features); the "captions" are short phrases encoded as a bag of words. Two linear encoders project both into an 8-dimensional space, and every click runs genuine gradient steps of the symmetric contrastive loss, with a learned temperature, on a batch of eight pairs.
<p class="hint">Nine toy “photos” (coloured shapes) and their captions. Eight pairs are the training batch; the <b>green triangle</b> is held out. Two tiny linear encoders map images and captions into the same 8-D space, and the symmetric contrastive loss pulls each image toward its own caption and away from the other seven.</p>
<div class="controls">
<button id="step1" type="button">Train 1 step</button>
<button id="step25" type="button">Train 25 steps</button>
<button id="auto" type="button">Auto-train</button>
<button id="reset" type="button" class="ghost">New random init</button>
</div>
<div class="stats" id="stats"></div>
<div class="panels">
<figure class="panel">
<figcaption>Batch similarity matrix (rows = images, columns = captions)</figcaption>
<canvas id="heat" width="300" height="386" aria-label="Heatmap of cosine similarities between the eight training images and the eight captions"></canvas>
<div class="scale"><span class="sw neg"></span> −1 <span class="sw mid"></span> 0 <span class="sw pos"></span> +1 · cosine similarity; the outlined diagonal holds the true pairs</div>
</figure>
<figure class="panel">
<figcaption>Contrastive loss</figcaption>
<canvas id="loss" width="300" height="140" aria-label="Line chart of the training loss"></canvas>
</figure>
</div>
<h4 class="zs-title">Zero-shot classification</h4>
<p class="hint">Pick an image — try the dashed one the model never saw — and write candidate captions, one per line. The model embeds each caption and turns the similarities into probabilities with a softmax, exactly like CLIP's “a photo of a {label}” trick.</p>
<div id="picker" class="picker"></div>
<label for="cands" class="lab">Candidate captions</label>
<textarea id="cands" rows="5" spellcheck="false"></textarea>
<div id="bars" class="bars"></div>
<div id="tokens" class="tokens"></div>
* { box-sizing: border-box; }
:root { --fg: #1f2933; --muted: #52606d; --line: #d9e2ec; --panel: #f7f9fb; --accent: #1d3557; --bar: #3a6fd8; }
body { font-family: system-ui, sans-serif; margin: 0; color: var(--fg); }
.hint { font-size: .86rem; color: var(--muted); margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .8rem; border: 1px solid var(--accent);
background: var(--accent); color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button.on { outline: 2px solid var(--bar); outline-offset: 2px; }
.stats { font: 600 13px ui-monospace, monospace; color: var(--fg); margin: .2rem 0 .6rem; min-height: 1.3em; }
.panels { display: flex; flex-wrap: wrap; gap: .8rem; }
.panel { margin: 0; padding: .5rem; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
figcaption { font-size: .82rem; font-weight: 600; color: var(--muted); margin-bottom: .3rem; }
canvas { display: block; width: 100%; max-width: 300px; height: auto; }
.scale { font-size: .75rem; color: var(--muted); margin-top: .3rem; display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; }
.sw.neg { background: #e76f51; } .sw.mid { background: #e9edf2; border: 1px solid var(--line); } .sw.pos { background: #2b6cd4; }
.zs-title { margin: 1rem 0 .3rem; font-size: 1rem; }
.picker { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
.pick { position: relative; padding: 2px; border: 2px solid var(--line); border-radius: 8px; background: var(--panel); cursor: pointer; }
.pick.sel { border-color: var(--bar); }
.pick.unseen { border-style: dashed; }
.pick canvas { width: 36px; height: 36px; }
.badge { position: absolute; top: -8px; right: -6px; font: 700 9px system-ui; background: #e9c46a; color: #222; border-radius: 6px; padding: 1px 4px; }
.lab { display: block; font-size: .82rem; font-weight: 600; color: var(--muted); margin: .3rem 0 .2rem; }
textarea { width: 100%; max-width: 420px; font: 13px system-ui, sans-serif; padding: .4rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel); color: var(--fg); }
.bars { margin-top: .5rem; max-width: 420px; }
.row { display: grid; grid-template-columns: minmax(0, 1fr) 90px 44px; gap: .4rem; align-items: center; font-size: .82rem; margin: .18rem 0; }
.row .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row.best .t { font-weight: 700; }
.track { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; }
.fill { height: 100%; background: var(--bar); }
.row .v { font: 12px ui-monospace, monospace; text-align: right; }
.tokens { font-size: .78rem; color: var(--muted); margin-top: .35rem; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e4e9ef; --muted: #a3b1bf; --line: #34404d; --panel: #1b222b; --accent: #9fb2c8; --bar: #6ea0ff; }
body { background: #12171d; }
button { color: #12171d; }
button.ghost { background: transparent; color: var(--accent); }
.sw.mid { background: #28303a; }
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy CLIP: an image encoder and a text encoder (both linear maps to 8-D, then L2-normalised)
// are trained together with the symmetric InfoNCE loss over the N×N similarity matrix of a batch.
// The temperature is learned too (as log of the logit scale), initialised at 0.07 and clipped at 1/100, as in the paper.
const COLORS = [
{ key: 'red', word: "red", rgb: [0.95, 0.2, 0.2], css: '#e63946' },
{ key: 'green', word: "green", rgb: [0.2, 0.8, 0.3], css: '#2a9d4f' },
{ key: 'blue', word: "blue", rgb: [0.2, 0.35, 0.95], css: '#3a6fd8' }
];
// Hand-made image features: RGB colour plus three shape descriptors (corners, roundness, pointiness) and a bias 1. Real CLIP sees raw pixels.
const SHAPES = [
{ key: 'circle', word: "circle", feat: [0, 1, 0] },
{ key: 'square', word: "square", feat: [1, 0, 0] },
{ key: 'triangle', word: "triangle", feat: [0.6, 0, 1] }
];
const TEMPLATE = "a %color% %shape%";
const caption = (c, s) => i18nFmt(TEMPLATE, { color: c.word, shape: s.word });
// Bag-of-words text features: a vocabulary word counts if it appears in the caption (case- and accent-insensitive).
const norm = (t) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const VOCAB = [...COLORS.map(c => c.word), ...SHAPES.map(s => s.word)];
const VOCAB_N = VOCAB.map(norm);
function textFeat(str) {
const t = norm(str);
const f = VOCAB_N.map(w => (t.includes(w) ? 1 : 0));
f.push(1); // constant feature, plays the role of filler words such as “a photo of”
return f;
}
// 3 colours × 3 shapes = 9 image–caption pairs; the green triangle is held out for zero-shot testing.
const ALL = [];
for (const c of COLORS) for (const s of SHAPES) {
ALL.push({ c, s, img: [...c.rgb, ...s.feat, 1], cap: caption(c, s),
unseen: c.key === 'green' && s.key === 'triangle' });
}
ALL.forEach(p => { p.txt = textFeat(p.cap); });
const TRAIN = ALL.filter(p => !p.unseen);
const N = TRAIN.length, DI = 7, DT = 7, D = 8;
const MAX_LOGS = Math.log(100); // the logit scale may not exceed 100 (temperature ≥ 0.01)
let Wi, Wt, logS, adam, stepCount, lossHist, autoTimer = null, lastF = null;
function randn() { let u = 0, v = 0; while (!u) u = Math.random(); while (!v) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }
const mat = (r, c, sd) => Array.from({ length: r }, () => Array.from({ length: c }, () => randn() * sd));
const zeros = (r, c) => Array.from({ length: r }, () => new Array(c).fill(0));
const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);
function encode(W, x) {
const a = W.map(row => dot(row, x));
const n = Math.sqrt(dot(a, a)) + 1e-9;
return { a, n, u: a.map(v => v / n) };
}
// Forward pass: embed, normalise, cosine matrix × scale, softmax over rows (image→text) and over columns (text→image).
function forward() {
const I = TRAIN.map(p => encode(Wi, p.img));
const T = TRAIN.map(p => encode(Wt, p.txt));
const s = Math.exp(logS);
const cos = I.map(ii => T.map(tt => dot(ii.u, tt.u)));
const L = cos.map(r => r.map(v => s * v));
const rowP = L.map(r => { const m = Math.max(...r); const e = r.map(v => Math.exp(v - m)); const z = e.reduce((a, b) => a + b, 0); return e.map(v => v / z); });
const colP = zeros(N, N);
for (let j = 0; j < N; j++) {
let m = -Infinity; for (let i = 0; i < N; i++) m = Math.max(m, L[i][j]);
let z = 0; for (let i = 0; i < N; i++) z += Math.exp(L[i][j] - m);
for (let i = 0; i < N; i++) colP[i][j] = Math.exp(L[i][j] - m) / z;
}
let loss = 0;
for (let i = 0; i < N; i++) loss -= Math.log(rowP[i][i] + 1e-12) + Math.log(colP[i][i] + 1e-12);
loss /= 2 * N;
return { I, T, s, cos, L, rowP, colP, loss };
}
// Backward pass: dLoss/dlogits = (P_rows + P_cols − 2·I) / 2N, then chain rule through the scale, the normalisation and the linear maps.
function gradients(F) {
const G = zeros(N, N);
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++)
G[i][j] = (F.rowP[i][j] + F.colP[i][j] - 2 * (i === j ? 1 : 0)) / (2 * N);
const gWi = zeros(D, DI), gWt = zeros(D, DT);
let gLogS = 0;
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) gLogS += G[i][j] * F.L[i][j];
const back = (enc, gu, x, gW) => {
const proj = dot(gu, enc.u);
for (let r = 0; r < gu.length; r++) {
const ga = (gu[r] - proj * enc.u[r]) / enc.n;
for (let c = 0; c < x.length; c++) gW[r][c] += ga * x[c];
}
};
for (let i = 0; i < N; i++) {
const gu = new Array(D).fill(0);
for (let j = 0; j < N; j++) for (let k = 0; k < D; k++) gu[k] += F.s * G[i][j] * F.T[j].u[k];
back(F.I[i], gu, TRAIN[i].img, gWi);
}
for (let j = 0; j < N; j++) {
const gv = new Array(D).fill(0);
for (let i = 0; i < N; i++) for (let k = 0; k < D; k++) gv[k] += F.s * G[i][j] * F.I[i].u[k];
back(F.T[j], gv, TRAIN[j].txt, gWt);
}
return { gWi, gWt, gLogS };
}
// Plain Adam optimiser on both weight matrices and on the log-scale.
const LR = 0.03, B1 = 0.9, B2 = 0.999;
function adamStep(key, W, g, t) {
const st = adam[key];
for (let r = 0; r < W.length; r++) for (let c = 0; c < W[r].length; c++) {
st.m[r][c] = B1 * st.m[r][c] + (1 - B1) * g[r][c];
st.v[r][c] = B2 * st.v[r][c] + (1 - B2) * g[r][c] * g[r][c];
const mh = st.m[r][c] / (1 - Math.pow(B1, t)), vh = st.v[r][c] / (1 - Math.pow(B2, t));
W[r][c] -= LR * mh / (Math.sqrt(vh) + 1e-8);
}
}
function trainStep() {
const F = forward();
const g = gradients(F);
stepCount++;
adamStep('i', Wi, g.gWi, stepCount);
adamStep('t', Wt, g.gWt, stepCount);
const W = [[logS]];
adamStep('s', W, [[g.gLogS]], stepCount);
logS = Math.min(MAX_LOGS, W[0][0]);
lossHist.push(F.loss);
}
function reset() {
Wi = mat(D, DI, 0.6); Wt = mat(D, DT, 0.6);
logS = Math.log(1 / 0.07); // temperature τ = 0.07, i.e. logit scale ≈ 14.3
adam = { i: { m: zeros(D, DI), v: zeros(D, DI) }, t: { m: zeros(D, DT), v: zeros(D, DT) }, s: { m: [[0]], v: [[0]] } };
stepCount = 0; lossHist = [];
refresh();
}
// Zero-shot: embed the image and every candidate caption, softmax over scaled cosines.
function zeroShot(img, captions) {
const u = encode(Wi, img).u;
const s = Math.exp(logS);
const sc = captions.map(c => s * dot(u, encode(Wt, textFeat(c)).u));
const m = Math.max(...sc);
const e = sc.map(v => Math.exp(v - m));
const z = e.reduce((a, b) => a + b, 0);
return e.map(v => v / z);
}
const dark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
const THEME = dark ? { fg: '#e4e9ef', muted: '#a3b1bf', line: '#34404d', mid: [40, 48, 58], diag: '#e9c46a' }
: { fg: '#1f2933', muted: '#52606d', line: '#d9e2ec', mid: [233, 237, 242], diag: '#111111' };
function heatColor(v) {
const t = Math.max(-1, Math.min(1, v));
const to = t >= 0 ? [43, 108, 212] : [231, 111, 81];
const a = Math.abs(t);
const c = THEME.mid.map((m, k) => Math.round(m + (to[k] - m) * a));
return 'rgb(' + c.join(',') + ')';
}
function drawShape(ctx, shape, color, cx, cy, r) {
ctx.beginPath(); ctx.fillStyle = color;
if (shape === 'circle') ctx.arc(cx, cy, r, 0, Math.PI * 2);
else if (shape === 'square') ctx.rect(cx - r * 0.85, cy - r * 0.85, r * 1.7, r * 1.7);
else { ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r * 0.95, cy + r * 0.75); ctx.lineTo(cx - r * 0.95, cy + r * 0.75); ctx.closePath(); }
ctx.fill();
}
function drawHeat(F) {
const cv = document.getElementById('heat'), ctx = cv.getContext('2d');
const left = 34, top = 120, cell = (cv.width - left - 4) / N;
ctx.clearRect(0, 0, cv.width, cv.height);
// column labels are the captions, written vertically
for (let j = 0; j < N; j++) {
ctx.save();
ctx.translate(left + (j + 0.5) * cell + 4, top - 6);
ctx.rotate(-Math.PI / 2);
let fs = 11; ctx.font = fs + 'px system-ui, sans-serif';
while (ctx.measureText(TRAIN[j].cap).width > top - 10 && fs > 7) { fs -= 0.5; ctx.font = fs + 'px system-ui, sans-serif'; }
ctx.fillStyle = THEME.fg; ctx.textAlign = 'left';
ctx.fillText(TRAIN[j].cap, 0, 0);
ctx.restore();
}
for (let i = 0; i < N; i++) {
drawShape(ctx, TRAIN[i].s.key, TRAIN[i].c.css, left / 2, top + (i + 0.5) * cell, cell * 0.32);
for (let j = 0; j < N; j++) {
const x = left + j * cell, y = top + i * cell, v = F.cos[i][j];
ctx.fillStyle = heatColor(v);
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
if (i === j) { ctx.strokeStyle = THEME.diag; ctx.lineWidth = 1.5; ctx.strokeRect(x + 1.5, y + 1.5, cell - 3, cell - 3); }
ctx.fillStyle = Math.abs(v) > 0.55 ? '#ffffff' : THEME.fg;
ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(v.toFixed(1), x + cell / 2, y + cell / 2);
}
}
ctx.textBaseline = 'alphabetic';
}
function drawLoss() {
const cv = document.getElementById('loss'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, p = 26;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = THEME.line; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p, 6); ctx.lineTo(p, H - p); ctx.lineTo(W - 6, H - p); ctx.stroke();
const ymax = Math.max(Math.log(N) * 1.2, ...lossHist.slice(0, 5));
ctx.fillStyle = THEME.muted; ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'right';
ctx.fillText(ymax.toFixed(1), p - 3, 14); ctx.fillText('0', p - 3, H - p);
ctx.textAlign = 'left';
ctx.fillText('ln ' + N + ' = ' + Math.log(N).toFixed(2), p + 4, H - p - 4 - (H - p - 10) * Math.log(N) / ymax);
ctx.setLineDash([3, 3]); ctx.beginPath();
const yl = H - p - (H - p - 10) * Math.log(N) / ymax; ctx.moveTo(p, yl); ctx.lineTo(W - 6, yl); ctx.stroke(); ctx.setLineDash([]);
ctx.textAlign = 'center'; ctx.fillText("training steps", (W + p) / 2, H - 8);
if (lossHist.length < 2) return;
const xmax = Math.max(100, lossHist.length);
ctx.strokeStyle = '#2b6cd4'; ctx.lineWidth = 2; ctx.beginPath();
lossHist.forEach((l, k) => {
const x = p + (W - 6 - p) * k / xmax, y = H - p - (H - p - 10) * Math.min(l, ymax) / ymax;
if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
}
let selected = ALL.findIndex(p => p.unseen);
function buildPicker() {
const box = document.getElementById('picker'); box.innerHTML = '';
ALL.forEach((p, k) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'pick' + (k === selected ? ' sel' : '') + (p.unseen ? ' unseen' : '');
b.title = p.unseen ? "Held-out image, never seen during training" : p.cap;
b.setAttribute('aria-label', p.unseen ? "Held-out image, never seen during training" : p.cap);
const cv = document.createElement('canvas'); cv.width = 36; cv.height = 36;
drawShape(cv.getContext('2d'), p.s.key, p.c.css, 18, 18, 13);
b.appendChild(cv);
if (p.unseen) { const t = document.createElement('span'); t.className = 'badge'; t.textContent = "new"; b.appendChild(t); }
b.addEventListener('click', () => { selected = k; buildPicker(); renderZeroShot(); });
box.appendChild(b);
});
}
function renderZeroShot() {
const caps = document.getElementById('cands').value.split(/[,,、;\n]+/).map(t => t.trim()).filter(Boolean).slice(0, 12);
const bars = document.getElementById('bars'); bars.innerHTML = '';
if (!caps.length) return;
const pr = zeroShot(ALL[selected].img, caps);
const best = pr.indexOf(Math.max(...pr));
caps.forEach((c, k) => {
const row = document.createElement('div'); row.className = 'row' + (k === best ? ' best' : '');
const t = document.createElement('span'); t.className = 't'; t.textContent = (k === best ? '▶ ' : '') + c; t.title = c;
const tr = document.createElement('div'); tr.className = 'track';
const f = document.createElement('div'); f.className = 'fill'; f.style.width = (100 * pr[k]).toFixed(1) + '%'; tr.appendChild(f);
const v = document.createElement('span'); v.className = 'v'; v.textContent = (100 * pr[k]).toFixed(0) + '%';
row.append(t, tr, v); bars.appendChild(row);
});
const seen = VOCAB.filter((w, k) => caps.some(c => norm(c).includes(VOCAB_N[k])));
document.getElementById('tokens').textContent = i18nFmt("Words the text encoder recognised: %words%. Its whole vocabulary: %vocab%.", { words: seen.join(' · ') || '—', vocab: VOCAB.join(' · ') });
}
function refresh() {
const F = forward(); lastF = F;
let acc = 0;
for (let i = 0; i < N; i++) if (F.rowP[i].indexOf(Math.max(...F.rowP[i])) === i) acc++;
document.getElementById('stats').textContent = i18nFmt("step %step% · loss %loss% · τ = %tau% · correct matches %acc%/%n%", {
step: stepCount, loss: F.loss.toFixed(3), tau: (1 / F.s).toFixed(3), acc: acc, n: N });
drawHeat(F); drawLoss(); renderZeroShot();
}
function train(k) { for (let r = 0; r < k; r++) trainStep(); refresh(); }
function stopAuto() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } document.getElementById('auto').classList.remove('on'); document.getElementById('auto').textContent = "Auto-train"; }
document.getElementById('step1').addEventListener('click', () => train(1));
document.getElementById('step25').addEventListener('click', () => train(25));
document.getElementById('auto').addEventListener('click', () => {
if (autoTimer) { stopAuto(); return; }
const b = document.getElementById('auto'); b.classList.add('on'); b.textContent = "Pause";
autoTimer = setInterval(() => { train(3); if (stepCount >= 600) stopAuto(); }, 60);
});
document.getElementById('reset').addEventListener('click', () => { stopAuto(); reset(); });
document.getElementById('cands').addEventListener('input', renderZeroShot);
// Default candidates: every shape in green, plus two triangles of other colours.
const green = COLORS[1];
document.getElementById('cands').value = [
...SHAPES.map(s => caption(green, s)), caption(COLORS[0], SHAPES[2]), caption(COLORS[2], SHAPES[2])
].join('\n');
buildPicker();
reset();
<p class="hint">Nine toy “photos” (coloured shapes) and their captions. Eight pairs are the training batch; the <b>green triangle</b> is held out. Two tiny linear encoders map images and captions into the same 8-D space, and the symmetric contrastive loss pulls each image toward its own caption and away from the other seven.</p>
<div class="controls">
<button id="step1" type="button">Train 1 step</button>
<button id="step25" type="button">Train 25 steps</button>
<button id="auto" type="button">Auto-train</button>
<button id="reset" type="button" class="ghost">New random init</button>
</div>
<div class="stats" id="stats"></div>
<div class="panels">
<figure class="panel">
<figcaption>Batch similarity matrix (rows = images, columns = captions)</figcaption>
<canvas id="heat" width="300" height="386" aria-label="Heatmap of cosine similarities between the eight training images and the eight captions"></canvas>
<div class="scale"><span class="sw neg"></span> −1 <span class="sw mid"></span> 0 <span class="sw pos"></span> +1 · cosine similarity; the outlined diagonal holds the true pairs</div>
</figure>
<figure class="panel">
<figcaption>Contrastive loss</figcaption>
<canvas id="loss" width="300" height="140" aria-label="Line chart of the training loss"></canvas>
</figure>
</div>
<h4 class="zs-title">Zero-shot classification</h4>
<p class="hint">Pick an image — try the dashed one the model never saw — and write candidate captions, one per line. The model embeds each caption and turns the similarities into probabilities with a softmax, exactly like CLIP's “a photo of a {label}” trick.</p>
<div id="picker" class="picker"></div>
<label for="cands" class="lab">Candidate captions</label>
<textarea id="cands" rows="5" spellcheck="false"></textarea>
<div id="bars" class="bars"></div>
<div id="tokens" class="tokens"></div>
* { box-sizing: border-box; }
:root { --fg: #1f2933; --muted: #52606d; --line: #d9e2ec; --panel: #f7f9fb; --accent: #1d3557; --bar: #3a6fd8; }
body { font-family: system-ui, sans-serif; margin: 0; color: var(--fg); }
.hint { font-size: .86rem; color: var(--muted); margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .8rem; border: 1px solid var(--accent);
background: var(--accent); color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button.on { outline: 2px solid var(--bar); outline-offset: 2px; }
.stats { font: 600 13px ui-monospace, monospace; color: var(--fg); margin: .2rem 0 .6rem; min-height: 1.3em; }
.panels { display: flex; flex-wrap: wrap; gap: .8rem; }
.panel { margin: 0; padding: .5rem; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
figcaption { font-size: .82rem; font-weight: 600; color: var(--muted); margin-bottom: .3rem; }
canvas { display: block; width: 100%; max-width: 300px; height: auto; }
.scale { font-size: .75rem; color: var(--muted); margin-top: .3rem; display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; }
.sw.neg { background: #e76f51; } .sw.mid { background: #e9edf2; border: 1px solid var(--line); } .sw.pos { background: #2b6cd4; }
.zs-title { margin: 1rem 0 .3rem; font-size: 1rem; }
.picker { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
.pick { position: relative; padding: 2px; border: 2px solid var(--line); border-radius: 8px; background: var(--panel); cursor: pointer; }
.pick.sel { border-color: var(--bar); }
.pick.unseen { border-style: dashed; }
.pick canvas { width: 36px; height: 36px; }
.badge { position: absolute; top: -8px; right: -6px; font: 700 9px system-ui; background: #e9c46a; color: #222; border-radius: 6px; padding: 1px 4px; }
.lab { display: block; font-size: .82rem; font-weight: 600; color: var(--muted); margin: .3rem 0 .2rem; }
textarea { width: 100%; max-width: 420px; font: 13px system-ui, sans-serif; padding: .4rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel); color: var(--fg); }
.bars { margin-top: .5rem; max-width: 420px; }
.row { display: grid; grid-template-columns: minmax(0, 1fr) 90px 44px; gap: .4rem; align-items: center; font-size: .82rem; margin: .18rem 0; }
.row .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row.best .t { font-weight: 700; }
.track { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; }
.fill { height: 100%; background: var(--bar); }
.row .v { font: 12px ui-monospace, monospace; text-align: right; }
.tokens { font-size: .78rem; color: var(--muted); margin-top: .35rem; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e4e9ef; --muted: #a3b1bf; --line: #34404d; --panel: #1b222b; --accent: #9fb2c8; --bar: #6ea0ff; }
body { background: #12171d; }
button { color: #12171d; }
button.ghost { background: transparent; color: var(--accent); }
.sw.mid { background: #28303a; }
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy CLIP: an image encoder and a text encoder (both linear maps to 8-D, then L2-normalised)
// are trained together with the symmetric InfoNCE loss over the N×N similarity matrix of a batch.
// The temperature is learned too (as log of the logit scale), initialised at 0.07 and clipped at 1/100, as in the paper.
const COLORS = [
{ key: 'red', word: "red", rgb: [0.95, 0.2, 0.2], css: '#e63946' },
{ key: 'green', word: "green", rgb: [0.2, 0.8, 0.3], css: '#2a9d4f' },
{ key: 'blue', word: "blue", rgb: [0.2, 0.35, 0.95], css: '#3a6fd8' }
];
// Hand-made image features: RGB colour plus three shape descriptors (corners, roundness, pointiness) and a bias 1. Real CLIP sees raw pixels.
const SHAPES = [
{ key: 'circle', word: "circle", feat: [0, 1, 0] },
{ key: 'square', word: "square", feat: [1, 0, 0] },
{ key: 'triangle', word: "triangle", feat: [0.6, 0, 1] }
];
const TEMPLATE = "a %color% %shape%";
const caption = (c, s) => i18nFmt(TEMPLATE, { color: c.word, shape: s.word });
// Bag-of-words text features: a vocabulary word counts if it appears in the caption (case- and accent-insensitive).
const norm = (t) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const VOCAB = [...COLORS.map(c => c.word), ...SHAPES.map(s => s.word)];
const VOCAB_N = VOCAB.map(norm);
function textFeat(str) {
const t = norm(str);
const f = VOCAB_N.map(w => (t.includes(w) ? 1 : 0));
f.push(1); // constant feature, plays the role of filler words such as “a photo of”
return f;
}
// 3 colours × 3 shapes = 9 image–caption pairs; the green triangle is held out for zero-shot testing.
const ALL = [];
for (const c of COLORS) for (const s of SHAPES) {
ALL.push({ c, s, img: [...c.rgb, ...s.feat, 1], cap: caption(c, s),
unseen: c.key === 'green' && s.key === 'triangle' });
}
ALL.forEach(p => { p.txt = textFeat(p.cap); });
const TRAIN = ALL.filter(p => !p.unseen);
const N = TRAIN.length, DI = 7, DT = 7, D = 8;
const MAX_LOGS = Math.log(100); // the logit scale may not exceed 100 (temperature ≥ 0.01)
let Wi, Wt, logS, adam, stepCount, lossHist, autoTimer = null, lastF = null;
function randn() { let u = 0, v = 0; while (!u) u = Math.random(); while (!v) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }
const mat = (r, c, sd) => Array.from({ length: r }, () => Array.from({ length: c }, () => randn() * sd));
const zeros = (r, c) => Array.from({ length: r }, () => new Array(c).fill(0));
const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);
function encode(W, x) {
const a = W.map(row => dot(row, x));
const n = Math.sqrt(dot(a, a)) + 1e-9;
return { a, n, u: a.map(v => v / n) };
}
// Forward pass: embed, normalise, cosine matrix × scale, softmax over rows (image→text) and over columns (text→image).
function forward() {
const I = TRAIN.map(p => encode(Wi, p.img));
const T = TRAIN.map(p => encode(Wt, p.txt));
const s = Math.exp(logS);
const cos = I.map(ii => T.map(tt => dot(ii.u, tt.u)));
const L = cos.map(r => r.map(v => s * v));
const rowP = L.map(r => { const m = Math.max(...r); const e = r.map(v => Math.exp(v - m)); const z = e.reduce((a, b) => a + b, 0); return e.map(v => v / z); });
const colP = zeros(N, N);
for (let j = 0; j < N; j++) {
let m = -Infinity; for (let i = 0; i < N; i++) m = Math.max(m, L[i][j]);
let z = 0; for (let i = 0; i < N; i++) z += Math.exp(L[i][j] - m);
for (let i = 0; i < N; i++) colP[i][j] = Math.exp(L[i][j] - m) / z;
}
let loss = 0;
for (let i = 0; i < N; i++) loss -= Math.log(rowP[i][i] + 1e-12) + Math.log(colP[i][i] + 1e-12);
loss /= 2 * N;
return { I, T, s, cos, L, rowP, colP, loss };
}
// Backward pass: dLoss/dlogits = (P_rows + P_cols − 2·I) / 2N, then chain rule through the scale, the normalisation and the linear maps.
function gradients(F) {
const G = zeros(N, N);
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++)
G[i][j] = (F.rowP[i][j] + F.colP[i][j] - 2 * (i === j ? 1 : 0)) / (2 * N);
const gWi = zeros(D, DI), gWt = zeros(D, DT);
let gLogS = 0;
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) gLogS += G[i][j] * F.L[i][j];
const back = (enc, gu, x, gW) => {
const proj = dot(gu, enc.u);
for (let r = 0; r < gu.length; r++) {
const ga = (gu[r] - proj * enc.u[r]) / enc.n;
for (let c = 0; c < x.length; c++) gW[r][c] += ga * x[c];
}
};
for (let i = 0; i < N; i++) {
const gu = new Array(D).fill(0);
for (let j = 0; j < N; j++) for (let k = 0; k < D; k++) gu[k] += F.s * G[i][j] * F.T[j].u[k];
back(F.I[i], gu, TRAIN[i].img, gWi);
}
for (let j = 0; j < N; j++) {
const gv = new Array(D).fill(0);
for (let i = 0; i < N; i++) for (let k = 0; k < D; k++) gv[k] += F.s * G[i][j] * F.I[i].u[k];
back(F.T[j], gv, TRAIN[j].txt, gWt);
}
return { gWi, gWt, gLogS };
}
// Plain Adam optimiser on both weight matrices and on the log-scale.
const LR = 0.03, B1 = 0.9, B2 = 0.999;
function adamStep(key, W, g, t) {
const st = adam[key];
for (let r = 0; r < W.length; r++) for (let c = 0; c < W[r].length; c++) {
st.m[r][c] = B1 * st.m[r][c] + (1 - B1) * g[r][c];
st.v[r][c] = B2 * st.v[r][c] + (1 - B2) * g[r][c] * g[r][c];
const mh = st.m[r][c] / (1 - Math.pow(B1, t)), vh = st.v[r][c] / (1 - Math.pow(B2, t));
W[r][c] -= LR * mh / (Math.sqrt(vh) + 1e-8);
}
}
function trainStep() {
const F = forward();
const g = gradients(F);
stepCount++;
adamStep('i', Wi, g.gWi, stepCount);
adamStep('t', Wt, g.gWt, stepCount);
const W = [[logS]];
adamStep('s', W, [[g.gLogS]], stepCount);
logS = Math.min(MAX_LOGS, W[0][0]);
lossHist.push(F.loss);
}
function reset() {
Wi = mat(D, DI, 0.6); Wt = mat(D, DT, 0.6);
logS = Math.log(1 / 0.07); // temperature τ = 0.07, i.e. logit scale ≈ 14.3
adam = { i: { m: zeros(D, DI), v: zeros(D, DI) }, t: { m: zeros(D, DT), v: zeros(D, DT) }, s: { m: [[0]], v: [[0]] } };
stepCount = 0; lossHist = [];
refresh();
}
// Zero-shot: embed the image and every candidate caption, softmax over scaled cosines.
function zeroShot(img, captions) {
const u = encode(Wi, img).u;
const s = Math.exp(logS);
const sc = captions.map(c => s * dot(u, encode(Wt, textFeat(c)).u));
const m = Math.max(...sc);
const e = sc.map(v => Math.exp(v - m));
const z = e.reduce((a, b) => a + b, 0);
return e.map(v => v / z);
}
const dark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
const THEME = dark ? { fg: '#e4e9ef', muted: '#a3b1bf', line: '#34404d', mid: [40, 48, 58], diag: '#e9c46a' }
: { fg: '#1f2933', muted: '#52606d', line: '#d9e2ec', mid: [233, 237, 242], diag: '#111111' };
function heatColor(v) {
const t = Math.max(-1, Math.min(1, v));
const to = t >= 0 ? [43, 108, 212] : [231, 111, 81];
const a = Math.abs(t);
const c = THEME.mid.map((m, k) => Math.round(m + (to[k] - m) * a));
return 'rgb(' + c.join(',') + ')';
}
function drawShape(ctx, shape, color, cx, cy, r) {
ctx.beginPath(); ctx.fillStyle = color;
if (shape === 'circle') ctx.arc(cx, cy, r, 0, Math.PI * 2);
else if (shape === 'square') ctx.rect(cx - r * 0.85, cy - r * 0.85, r * 1.7, r * 1.7);
else { ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r * 0.95, cy + r * 0.75); ctx.lineTo(cx - r * 0.95, cy + r * 0.75); ctx.closePath(); }
ctx.fill();
}
function drawHeat(F) {
const cv = document.getElementById('heat'), ctx = cv.getContext('2d');
const left = 34, top = 120, cell = (cv.width - left - 4) / N;
ctx.clearRect(0, 0, cv.width, cv.height);
// column labels are the captions, written vertically
for (let j = 0; j < N; j++) {
ctx.save();
ctx.translate(left + (j + 0.5) * cell + 4, top - 6);
ctx.rotate(-Math.PI / 2);
let fs = 11; ctx.font = fs + 'px system-ui, sans-serif';
while (ctx.measureText(TRAIN[j].cap).width > top - 10 && fs > 7) { fs -= 0.5; ctx.font = fs + 'px system-ui, sans-serif'; }
ctx.fillStyle = THEME.fg; ctx.textAlign = 'left';
ctx.fillText(TRAIN[j].cap, 0, 0);
ctx.restore();
}
for (let i = 0; i < N; i++) {
drawShape(ctx, TRAIN[i].s.key, TRAIN[i].c.css, left / 2, top + (i + 0.5) * cell, cell * 0.32);
for (let j = 0; j < N; j++) {
const x = left + j * cell, y = top + i * cell, v = F.cos[i][j];
ctx.fillStyle = heatColor(v);
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
if (i === j) { ctx.strokeStyle = THEME.diag; ctx.lineWidth = 1.5; ctx.strokeRect(x + 1.5, y + 1.5, cell - 3, cell - 3); }
ctx.fillStyle = Math.abs(v) > 0.55 ? '#ffffff' : THEME.fg;
ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(v.toFixed(1), x + cell / 2, y + cell / 2);
}
}
ctx.textBaseline = 'alphabetic';
}
function drawLoss() {
const cv = document.getElementById('loss'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, p = 26;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = THEME.line; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p, 6); ctx.lineTo(p, H - p); ctx.lineTo(W - 6, H - p); ctx.stroke();
const ymax = Math.max(Math.log(N) * 1.2, ...lossHist.slice(0, 5));
ctx.fillStyle = THEME.muted; ctx.font = '10px ui-monospace, monospace'; ctx.textAlign = 'right';
ctx.fillText(ymax.toFixed(1), p - 3, 14); ctx.fillText('0', p - 3, H - p);
ctx.textAlign = 'left';
ctx.fillText('ln ' + N + ' = ' + Math.log(N).toFixed(2), p + 4, H - p - 4 - (H - p - 10) * Math.log(N) / ymax);
ctx.setLineDash([3, 3]); ctx.beginPath();
const yl = H - p - (H - p - 10) * Math.log(N) / ymax; ctx.moveTo(p, yl); ctx.lineTo(W - 6, yl); ctx.stroke(); ctx.setLineDash([]);
ctx.textAlign = 'center'; ctx.fillText("training steps", (W + p) / 2, H - 8);
if (lossHist.length < 2) return;
const xmax = Math.max(100, lossHist.length);
ctx.strokeStyle = '#2b6cd4'; ctx.lineWidth = 2; ctx.beginPath();
lossHist.forEach((l, k) => {
const x = p + (W - 6 - p) * k / xmax, y = H - p - (H - p - 10) * Math.min(l, ymax) / ymax;
if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
}
let selected = ALL.findIndex(p => p.unseen);
function buildPicker() {
const box = document.getElementById('picker'); box.innerHTML = '';
ALL.forEach((p, k) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'pick' + (k === selected ? ' sel' : '') + (p.unseen ? ' unseen' : '');
b.title = p.unseen ? "Held-out image, never seen during training" : p.cap;
b.setAttribute('aria-label', p.unseen ? "Held-out image, never seen during training" : p.cap);
const cv = document.createElement('canvas'); cv.width = 36; cv.height = 36;
drawShape(cv.getContext('2d'), p.s.key, p.c.css, 18, 18, 13);
b.appendChild(cv);
if (p.unseen) { const t = document.createElement('span'); t.className = 'badge'; t.textContent = "new"; b.appendChild(t); }
b.addEventListener('click', () => { selected = k; buildPicker(); renderZeroShot(); });
box.appendChild(b);
});
}
function renderZeroShot() {
const caps = document.getElementById('cands').value.split(/[,,、;\n]+/).map(t => t.trim()).filter(Boolean).slice(0, 12);
const bars = document.getElementById('bars'); bars.innerHTML = '';
if (!caps.length) return;
const pr = zeroShot(ALL[selected].img, caps);
const best = pr.indexOf(Math.max(...pr));
caps.forEach((c, k) => {
const row = document.createElement('div'); row.className = 'row' + (k === best ? ' best' : '');
const t = document.createElement('span'); t.className = 't'; t.textContent = (k === best ? '▶ ' : '') + c; t.title = c;
const tr = document.createElement('div'); tr.className = 'track';
const f = document.createElement('div'); f.className = 'fill'; f.style.width = (100 * pr[k]).toFixed(1) + '%'; tr.appendChild(f);
const v = document.createElement('span'); v.className = 'v'; v.textContent = (100 * pr[k]).toFixed(0) + '%';
row.append(t, tr, v); bars.appendChild(row);
});
const seen = VOCAB.filter((w, k) => caps.some(c => norm(c).includes(VOCAB_N[k])));
document.getElementById('tokens').textContent = i18nFmt("Words the text encoder recognised: %words%. Its whole vocabulary: %vocab%.", { words: seen.join(' · ') || '—', vocab: VOCAB.join(' · ') });
}
function refresh() {
const F = forward(); lastF = F;
let acc = 0;
for (let i = 0; i < N; i++) if (F.rowP[i].indexOf(Math.max(...F.rowP[i])) === i) acc++;
document.getElementById('stats').textContent = i18nFmt("step %step% · loss %loss% · τ = %tau% · correct matches %acc%/%n%", {
step: stepCount, loss: F.loss.toFixed(3), tau: (1 / F.s).toFixed(3), acc: acc, n: N });
drawHeat(F); drawLoss(); renderZeroShot();
}
function train(k) { for (let r = 0; r < k; r++) trainStep(); refresh(); }
function stopAuto() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } document.getElementById('auto').classList.remove('on'); document.getElementById('auto').textContent = "Auto-train"; }
document.getElementById('step1').addEventListener('click', () => train(1));
document.getElementById('step25').addEventListener('click', () => train(25));
document.getElementById('auto').addEventListener('click', () => {
if (autoTimer) { stopAuto(); return; }
const b = document.getElementById('auto'); b.classList.add('on'); b.textContent = "Pause";
autoTimer = setInterval(() => { train(3); if (stepCount >= 600) stopAuto(); }, 60);
});
document.getElementById('reset').addEventListener('click', () => { stopAuto(); reset(); });
document.getElementById('cands').addEventListener('input', renderZeroShot);
// Default candidates: every shape in green, plus two triangles of other colours.
const green = COLORS[1];
document.getElementById('cands').value = [
...SHAPES.map(s => caption(green, s)), caption(COLORS[0], SHAPES[2]), caption(COLORS[2], SHAPES[2])
].join('\n');
buildPicker();
reset();
Comments
Loading comments...