/**
* 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, ">");
}