/** * Teachable LLM - Interactive Deep Learning Visualizer * Inspired by bbycroft.net/llm, Transformer Explainer, and GAN Lab. */ // 1. Principal Component Analysis (2D PCA) for High-Dimensional Vectors export class PCA2D { constructor() { this.mean = null; this.components = null; this.std = null; } fitTransform(vectors) { if (!vectors || vectors.length === 0) return []; const n = vectors.length; const d = vectors[0].length; if (n === 1) { return [{ x: 0, y: 0 }]; } // 1. Calculate column means this.mean = new Float64Array(d); for (let i = 0; i < n; i++) { for (let j = 0; j < d; j++) { this.mean[j] += vectors[i][j]; } } for (let j = 0; j < d; j++) { this.mean[j] /= n; } // 2. Mean-center matrix Y (n x d) const Y = vectors.map(v => { const centered = new Float64Array(d); for (let j = 0; j < d; j++) centered[j] = v[j] - this.mean[j]; return centered; }); // 3. Compute Gram Matrix G = Y * Y^T (n x n) const G = Array.from({ length: n }, () => new Float64Array(n)); for (let i = 0; i < n; i++) { for (let j = i; j < n; j++) { let dot = 0; for (let k = 0; k < d; k++) dot += Y[i][k] * Y[j][k]; G[i][j] = dot; G[j][i] = dot; } } // 4. Power Iteration to find top 2 Gram eigenvectors const e1 = this.powerIteration(G, n); const G2 = this.deflateMatrix(G, e1, n); const e2 = this.powerIteration(G2, n); // Project vectors onto principal components let coords = []; for (let i = 0; i < n; i++) { coords.push({ x: e1[i], y: e2[i] }); } // 5. Normalize coordinates to [-0.85, 0.85] range for canvas plotting return this.normalizeCoords(coords); } powerIteration(G, n, numIter = 40) { let v = new Float64Array(n); for (let i = 0; i < n; i++) v[i] = Math.sin(i + 1); // Deterministic init for (let iter = 0; iter < numIter; iter++) { let nextV = new Float64Array(n); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { nextV[i] += G[i][j] * v[j]; } } // Normalize let norm = 0; for (let i = 0; i < n; i++) norm += nextV[i] * nextV[i]; norm = Math.sqrt(norm) || 1e-8; for (let i = 0; i < n; i++) v[i] = nextV[i] / norm; } return v; } deflateMatrix(G, e, n) { // G_deflated = G - lambda * e * e^T let lambda = 0; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { lambda += e[i] * G[i][j] * e[j]; } } const G2 = Array.from({ length: n }, () => new Float64Array(n)); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { G2[i][j] = G[i][j] - lambda * e[i] * e[j]; } } return G2; } normalizeCoords(coords) { if (coords.length === 0) return []; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; coords.forEach(c => { if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x; if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y; }); const rangeX = (maxX - minX) || 1; const rangeY = (maxY - minY) || 1; return coords.map(c => ({ x: ((c.x - minX) / rangeX) * 1.6 - 0.8, y: ((c.y - minY) / rangeY) * 1.6 - 0.8 })); } } // 2. Tokenizer & Subword Visualizer Component export class TokenizerViz { constructor(containerId) { this.container = document.getElementById(containerId); } render(text, tokens = null) { if (!this.container) return; this.container.innerHTML = ''; if (!text) { this.container.innerHTML = '
Type a sentence to visualize tokenization...
'; return; } // Generate synthetic tokens if raw subword tokenizer array not supplied if (!tokens) { const rawWords = text.trim().split(/\s+/); tokens = ['[CLS]']; rawWords.forEach(w => { if (w.length > 6) { tokens.push(w.slice(0, 4)); tokens.push('##' + w.slice(4)); } else { tokens.push(w); } }); tokens.push('[SEP]'); } const header = document.createElement('div'); header.className = 'viz-sub-header'; header.innerHTML = `Input Token Sequence (${tokens.length} tokens):`; this.container.appendChild(header); const tokenList = document.createElement('div'); tokenList.className = 'token-chip-container'; tokens.forEach((token, idx) => { const chip = document.createElement('div'); chip.className = 'token-chip'; if (token.startsWith('[')) chip.classList.add('special-token'); else if (token.startsWith('##')) chip.classList.add('subword-token'); chip.innerHTML = ` ${escapeHtml(token)} #${idx} `; // Hover tooltip for token details chip.addEventListener('mouseenter', (e) => { showTooltip(e, `Token #${idx}: ${escapeHtml(token)}
Subword Type: ${token.startsWith('##') ? 'Continuation (##)' : token.startsWith('[') ? 'Special Control' : 'Word Root'}`); }); chip.addEventListener('mouseleave', hideTooltip); tokenList.appendChild(chip); }); this.container.appendChild(tokenList); } } // 3. Multi-Head Self-Attention Matrix Visualizer (Transformer Explainer style) export class AttentionMatrixViz { constructor(containerId, svgArcsId) { this.container = document.getElementById(containerId); this.svgArcsContainer = document.getElementById(svgArcsId); this.activeHead = 0; this.tokens = []; this.matrix = []; } render(text, tokens = null) { if (!this.container) return; this.container.innerHTML = ''; if (!text) { this.container.innerHTML = '
Awaiting input sentence...
'; return; } if (!tokens) { const rawWords = text.trim().split(/\s+/); tokens = ['[CLS]', ...rawWords, '[SEP]']; } this.tokens = tokens; const n = tokens.length; // Generate physically intuitive token-token self-attention score matrix this.matrix = Array.from({ length: n }, (_, i) => { const row = new Float32Array(n); let sum = 0; for (let j = 0; j < n; j++) { // Diagonal self-attention baseline + semantic proximity term let score = (i === j) ? 2.5 : Math.max(0.1, 1.5 - Math.abs(i - j) * 0.4); // Boost CLS token connections if (i === 0 || j === 0) score += 0.8; row[j] = Math.exp(score); sum += row[j]; } // Softmax normalization for (let j = 0; j < n; j++) row[j] /= sum; return row; }); // Controls bar for Attention Head selection const controls = document.createElement('div'); controls.className = 'attn-controls'; controls.innerHTML = `
Hover matrix cells or tokens to view attention arcs `; this.container.appendChild(controls); // Bind head pills controls.querySelectorAll('.head-pill').forEach(btn => { btn.addEventListener('click', (e) => { controls.querySelectorAll('.head-pill').forEach(b => b.classList.remove('active')); btn.classList.add('active'); this.renderMatrixGrid(gridContainer, tokens); }); }); const gridContainer = document.createElement('div'); gridContainer.className = 'attn-grid-wrapper'; this.container.appendChild(gridContainer); this.renderMatrixGrid(gridContainer, tokens); } renderMatrixGrid(wrapper, tokens) { wrapper.innerHTML = ''; const n = tokens.length; const table = document.createElement('div'); table.className = 'attn-matrix-table'; table.style.gridTemplateColumns = `max-content repeat(${n}, minmax(32px, 1fr))`; // Top Header Row (Target Tokens) const emptyCorner = document.createElement('div'); emptyCorner.className = 'attn-cell corner-cell'; emptyCorner.textContent = 'Q \\ K'; table.appendChild(emptyCorner); tokens.forEach((t, j) => { const th = document.createElement('div'); th.className = 'attn-cell col-header'; th.textContent = t.length > 7 ? t.slice(0, 5) + '..' : t; th.title = t; table.appendChild(th); }); // Matrix Rows tokens.forEach((rowToken, i) => { // Row Header (Query Token) const rh = document.createElement('div'); rh.className = 'attn-cell row-header'; rh.textContent = rowToken.length > 7 ? rowToken.slice(0, 5) + '..' : rowToken; rh.title = rowToken; table.appendChild(rh); tokens.forEach((colToken, j) => { const weight = this.matrix[i][j]; const cell = document.createElement('div'); cell.className = 'attn-cell weight-cell'; // Color intensity using vibrant Google Blue/Teal gradient const alpha = Math.min(1, Math.max(0.08, weight * 2.2)); cell.style.backgroundColor = `rgba(26, 115, 232, ${alpha})`; cell.style.color = alpha > 0.45 ? '#ffffff' : '#202124'; cell.textContent = weight.toFixed(2); // Cell hover highlights token pairs and draws interactive SVG arcs cell.addEventListener('mouseenter', (e) => { showTooltip(e, `Attention score (${rowToken} → ${colToken}): ${(weight * 100).toFixed(1)}%
Softmax score: ${weight.toFixed(4)}`); this.highlightArcs(i, j, weight); }); cell.addEventListener('mouseleave', () => { hideTooltip(); this.clearArcs(); }); table.appendChild(cell); }); }); wrapper.appendChild(table); } highlightArcs(srcIdx, tgtIdx, weight) { if (!this.svgArcsContainer) return; this.clearArcs(); // SVG Arc drawing logic const arcSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); arcSvg.setAttribute('width', '100%'); arcSvg.setAttribute('height', '60'); arcSvg.style.position = 'absolute'; arcSvg.style.top = '0'; arcSvg.style.left = '0'; arcSvg.style.pointerEvents = 'none'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const x1 = 50 + srcIdx * 60; const x2 = 50 + tgtIdx * 60; const mx = (x1 + x2) / 2; const my = 10; path.setAttribute('d', `M ${x1} 50 Q ${mx} ${my} ${x2} 50`); path.setAttribute('fill', 'none'); path.setAttribute('stroke', '#1a73e8'); path.setAttribute('stroke-width', Math.max(2, weight * 8)); path.setAttribute('stroke-linecap', 'round'); arcSvg.appendChild(path); this.svgArcsContainer.appendChild(arcSvg); } clearArcs() { if (this.svgArcsContainer) this.svgArcsContainer.innerHTML = ''; } } // 4. Mean Pooling & Vector Fingerprint Barcode Visualizer export class PoolingVectorViz { constructor(containerId) { this.container = document.getElementById(containerId); } render(embedding) { if (!this.container) return; this.container.innerHTML = ''; if (!embedding || embedding.length === 0) { this.container.innerHTML = '
No vector embedding generated yet...
'; return; } const dim = embedding.length; const header = document.createElement('div'); header.className = 'viz-sub-header'; header.innerHTML = ` Dense Sentence Embedding Barcode (${dim} Dimensions): L2 Normalized (||v|| = 1.0) `; this.container.appendChild(header); // Vector Barcode Color Map const barcodeWrapper = document.createElement('div'); barcodeWrapper.className = 'barcode-wrapper'; const canvas = document.createElement('canvas'); canvas.className = 'barcode-canvas'; canvas.width = Math.min(800, dim * 2); canvas.height = 36; const ctx = canvas.getContext('2d'); const sliceWidth = canvas.width / dim; embedding.forEach((val, i) => { // Warm red for positive values, cool blue/teal for negative values let color; if (val >= 0) { const intensity = Math.min(255, Math.floor(val * 1800)); color = `rgb(${255 - intensity}, ${intensity + 50}, ${120})`; // Emerald / Teal } else { const intensity = Math.min(255, Math.floor(Math.abs(val) * 1800)); color = `rgb(${intensity + 80}, ${100}, ${255 - intensity})`; // Royal Indigo / Blue } ctx.fillStyle = color; ctx.fillRect(i * sliceWidth, 0, sliceWidth + 0.5, canvas.height); }); barcodeWrapper.appendChild(canvas); // Canvas hover event for inspecting vector values canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const index = Math.floor((mouseX / rect.width) * dim); if (index >= 0 && index < dim) { const val = embedding[index]; showTooltip(e, `Dimension [${index} / ${dim}]: ${val >= 0 ? '+' : ''}${val.toFixed(6)}`); } }); canvas.addEventListener('mouseleave', hideTooltip); this.container.appendChild(barcodeWrapper); } } // 5. Interactive 2D Embedding Space & k-NN Decision Boundary Canvas (GAN Lab style) export class EmbeddingSpace2DViz { constructor(canvasId, legendId) { this.canvas = document.getElementById(canvasId); this.legend = document.getElementById(legendId); this.ctx = this.canvas ? this.canvas.getContext('2d') : null; this.pca = new PCA2D(); this.dataset = []; // [{ text, label, embedding, x2d, y2d, color }] this.queryPoint = null; // { text, embedding, x2d, y2d } this.neighbors = []; this.onCanvasClickCallback = null; if (this.canvas) { this.setupCanvasListeners(); } } setClickCallback(fn) { this.onCanvasClickCallback = fn; } updateData(dataset, queryResult = null) { if (!this.canvas || !this.ctx) return; this.dataset = dataset || []; this.queryResult = queryResult; // Collect all vectors to fit PCA simultaneously const allVectors = this.dataset.map(item => item.embedding); if (queryResult && queryResult.inputEmbedding) { allVectors.push(queryResult.inputEmbedding); } if (allVectors.length === 0) { this.drawEmptyState(); return; } // Fit PCA to compute 2D coordinates const coords2d = this.pca.fitTransform(allVectors); // Assign 2D coordinates to dataset items this.dataset.forEach((item, i) => { item.x2d = coords2d[i].x; item.y2d = coords2d[i].y; item.color = this.getLabelColor(item.label); }); // Assign 2D coordinates to query point if (queryResult && queryResult.inputEmbedding) { const qCoord = coords2d[coords2d.length - 1]; this.queryPoint = { text: queryResult.text || "Test Input", predictedLabel: queryResult.predictedLabel, embedding: queryResult.inputEmbedding, x2d: qCoord.x, y2d: qCoord.y }; this.neighbors = queryResult.nearestNeighbors || []; } else { this.queryPoint = null; this.neighbors = []; } this.renderCanvas(); this.renderLegend(); } getLabelColor(label) { const lower = (label || '').toLowerCase(); if (lower === 'positive') return '#34a853'; // Google Green if (lower === 'negative') return '#ea4335'; // Google Red if (lower === 'neutral') return '#fbbc04'; // Google Yellow return '#1a73e8'; // Google Blue } renderCanvas() { const w = this.canvas.width; const h = this.canvas.height; const ctx = this.ctx; ctx.clearRect(0, 0, w, h); // 1. Draw background grid ctx.strokeStyle = '#e8eaed'; ctx.lineWidth = 1; const step = 40; for (let x = 0; x < w; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = 0; y < h; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // 2. Render k-NN Decision Region Soft Heatmap Overlay (GAN Lab style) if (this.dataset.length > 0) { this.renderDecisionBoundaries(w, h); } // 3. Draw Euclidean Distance Vectors from Query Point to Nearest Neighbors if (this.queryPoint && this.neighbors.length > 0) { const qCanvas = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); this.neighbors.forEach((neighbor, rank) => { const matchedItem = this.dataset.find(d => d.text === neighbor.text); if (matchedItem) { const nCanvas = this.toCanvasCoords(matchedItem.x2d, matchedItem.y2d, w, h); // Draw connecting distance dashed vector line ctx.beginPath(); ctx.setLineDash([4, 4]); ctx.strokeStyle = '#1a73e8'; ctx.lineWidth = 2; ctx.moveTo(qCanvas.x, qCanvas.y); ctx.lineTo(nCanvas.x, nCanvas.y); ctx.stroke(); ctx.setLineDash([]); // Distance label tag badge at midpoint const mx = (qCanvas.x + nCanvas.x) / 2; const my = (qCanvas.y + nCanvas.y) / 2; ctx.fillStyle = '#ffffff'; ctx.fillRect(mx - 22, my - 10, 44, 18); ctx.strokeStyle = '#aecbfa'; ctx.lineWidth = 1; ctx.strokeRect(mx - 22, my - 10, 44, 18); ctx.fillStyle = '#1a73e8'; ctx.font = '10px "Roboto Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`d:${neighbor.distance.toFixed(2)}`, mx, my); } }); } // 4. Render Dataset Training Points this.dataset.forEach(item => { const pt = this.toCanvasCoords(item.x2d, item.y2d, w, h); // Point outer glow ctx.beginPath(); ctx.arc(pt.x, pt.y, 10, 0, Math.PI * 2); ctx.fillStyle = item.color + '33'; // 20% opacity ctx.fill(); // Point core circle ctx.beginPath(); ctx.arc(pt.x, pt.y, 6, 0, Math.PI * 2); ctx.fillStyle = item.color; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke(); }); // 5. Render Query Test Point (Glowing Star / Pulse Node) if (this.queryPoint) { const qPt = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); // Outer pulse circle ctx.beginPath(); ctx.arc(qPt.x, qPt.y, 16, 0, Math.PI * 2); ctx.fillStyle = 'rgba(26, 115, 232, 0.2)'; ctx.fill(); ctx.beginPath(); ctx.arc(qPt.x, qPt.y, 9, 0, Math.PI * 2); ctx.fillStyle = '#1a73e8'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 3; ctx.stroke(); // 'Q' text mark inside query node ctx.fillStyle = '#ffffff'; ctx.font = 'bold 10px "Google Sans", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Q', qPt.x, qPt.y); } } renderDecisionBoundaries(w, h) { // Fast low-res canvas raster for smooth class decision background const res = 15; // Grid cell size in pixels const cols = Math.ceil(w / res); const rows = Math.ceil(h / res); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const cx = (c + 0.5) * res; const cy = (r + 0.5) * res; const norm = this.fromCanvasCoords(cx, cy, w, h); // Find 2D nearest neighbor in dataset let minDist = Infinity; let closestColor = '#ffffff'; this.dataset.forEach(item => { const dx = norm.x - item.x2d; const dy = norm.y - item.y2d; const dist = dx * dx + dy * dy; if (dist < minDist) { minDist = dist; closestColor = item.color; } }); this.ctx.fillStyle = closestColor + '12'; // Ultra soft 7% tint this.ctx.fillRect(c * res, r * res, res, res); } } } toCanvasCoords(x, y, w, h) { // Map [-1, 1] to [margin, w - margin] const margin = 40; return { x: ((x + 1) / 2) * (w - 2 * margin) + margin, y: ((1 - y) / 2) * (h - 2 * margin) + margin }; } fromCanvasCoords(cx, cy, w, h) { const margin = 40; return { x: ((cx - margin) / (w - 2 * margin)) * 2 - 1, y: (1 - (cy - margin) / (h - 2 * margin)) * 2 }; } setupCanvasListeners() { this.canvas.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; const norm = this.fromCanvasCoords(cx, cy, this.canvas.width, this.canvas.height); // Check if hovering near a dataset point or query point let hoverItem = null; const w = this.canvas.width; const h = this.canvas.height; this.dataset.forEach(item => { const pt = this.toCanvasCoords(item.x2d, item.y2d, w, h); const dist = Math.hypot(pt.x - cx, pt.y - cy); if (dist < 12) hoverItem = item; }); if (!hoverItem && this.queryPoint) { const qPt = this.toCanvasCoords(this.queryPoint.x2d, this.queryPoint.y2d, w, h); if (Math.hypot(qPt.x - cx, qPt.y - cy) < 14) { hoverItem = { ...this.queryPoint, isQuery: true }; } } if (hoverItem) { this.canvas.style.cursor = 'pointer'; const labelText = hoverItem.isQuery ? `[Active Query] "${escapeHtml(hoverItem.text)}"` : `Label: ${escapeHtml(hoverItem.label)}
"${escapeHtml(hoverItem.text)}"`; showTooltip(e, `${labelText}
2D PCA Pos: (${hoverItem.x2d.toFixed(2)}, ${hoverItem.y2d.toFixed(2)})`); } else { this.canvas.style.cursor = 'crosshair'; hideTooltip(); } }); this.canvas.addEventListener('mouseleave', () => { this.canvas.style.cursor = 'default'; hideTooltip(); }); this.canvas.addEventListener('click', (e) => { const rect = this.canvas.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; if (this.onCanvasClickCallback) { this.onCanvasClickCallback(cx, cy); } }); } renderLegend() { if (!this.legend) return; this.legend.innerHTML = `
positive
negative
neutral
query (Q)
`; } drawEmptyState() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.fillStyle = '#5f6368'; this.ctx.font = '14px "Google Sans Text", sans-serif'; this.ctx.textAlign = 'center'; this.ctx.fillText('Add dataset examples to populate 2D PCA vector space...', this.canvas.width / 2, this.canvas.height / 2); } } // 6. Global Tooltip Helpers function showTooltip(evt, htmlContent) { let tooltip = document.getElementById('viz-global-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.id = 'viz-global-tooltip'; tooltip.className = 'viz-tooltip'; document.body.appendChild(tooltip); } tooltip.innerHTML = htmlContent; tooltip.style.display = 'block'; tooltip.style.left = `${evt.pageX + 12}px`; tooltip.style.top = `${evt.pageY + 12}px`; } function hideTooltip() { const tooltip = document.getElementById('viz-global-tooltip'); if (tooltip) tooltip.style.display = 'none'; } function escapeHtml(str) { return (str || '').replace(/&/g, "&").replace(//g, ">"); }