Embeddings
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
embedding-table.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";import { Sigmoid } from "./activation";/** One training triple: a centre word, a word seen beside it, and a word that was not. */export interface ContextTriple { center: number; context: number; negative: number;}/** The smallest probability the loss will take a logarithm of. */const FLOOR = 1e-12;/** * Word embeddings learned by skip-gram with one negative sample: each * word owns a vector, and a training triple pulls the centre and its * true neighbour together while pushing the centre and a random * stranger apart. Similarity is the cosine of the angle between two * vectors, so a dot product is the whole story. Vectors start uniform * in [-1, 1] from the seed alone. * * Time: O(dim) per triple. Space: O(vocab · dim). */export class EmbeddingTable { private readonly vectors: number[][]; private readonly sigmoid = new Sigmoid(); constructor( readonly vocab: readonly string[], readonly dim: number, readonly learningRate: number, seed = 1, private readonly tracer?: Tracer, ) { if (vocab.length < 2) throw new RangeError("a vocabulary needs at least two words"); if (!Number.isInteger(dim) || dim < 1) throw new RangeError("the dimension must be a whole number of at least one"); if (!(learningRate > 0)) throw new RangeError("the learning rate must be positive"); const random = new Mulberry32(seed); this.vectors = vocab.map(() => Array.from({ length: dim }, () => random.between(-1, 1))); } /** A copy of one word's vector. */ vector(index: number): number[] { const v = this.vectors[index]; if (!v) throw new RangeError(`no word at index ${index}`); return [...v]; } /** Cosine similarity of two words' vectors; zero when either has no length. */ similarity(a: number, b: number): number { const va = this.vector(a); const vb = this.vector(b); const dot = va.reduce((s, x, i) => s + x * vb[i]!, 0); const na = Math.hypot(...va); const nb = Math.hypot(...vb); return na === 0 || nb === 0 ? 0 : dot / (na * nb); } /** The word most similar to another, and how similar. */ nearest(index: number): { index: number; cosine: number } { let best = { index: -1, cosine: -Infinity }; for (let i = 0; i < this.vocab.length; i++) { if (i === index) continue; const cosine = this.similarity(index, i); if (cosine > best.cosine) best = { index: i, cosine }; } return best; } /** Trains on each triple in turn and returns the loss per triple. */ train(triples: readonly ContextTriple[]): number[] { const losses: number[] = []; for (let step = 0; step < triples.length; step++) { const { center, context, negative } = triples[step]!; const c = this.vector(center); const centerVector = this.vector(center); const o = this.vector(context); const contextVector = this.vector(context); const n = this.vector(negative); const negativeVector = this.vector(negative); // ▸ three words become three vectors: the centre, its neighbour, a stranger this.tracer?.emit("lookup", { step, center, context, negative, centerWord: this.vocab[center], contextWord: this.vocab[context], negativeWord: this.vocab[negative] }); const scores: number[] = []; for (const [kind, partner] of [["context", o], ["negative", n]] as const) { for (const [kind, partner] of [["context", contextVector], ["negative", negativeVector]] as const) { const dot = c.reduce((s, x, i) => s + x * partner[i]!, 0); const dot = centerVector.reduce((s, x, i) => s + x * partner[i]!, 0); const probability = this.sigmoid.apply(dot); scores.push(probability); // ▸ a dot product squashed to a probability: how alike are these two this.tracer?.emit("score", { step, kind, dot, probability }); } const p = scores[0]!; const q = scores[1]!; const loss = -Math.log(Math.max(p, FLOOR)) - Math.log(Math.max(1 - q, FLOOR)); losses.push(loss); // ▸ surprise that the neighbour scored low or the stranger high this.tracer?.emit("loss", { step, loss }); const gradients: [number, number[]][] = [ [center, c.map((_, i) => (p - 1) * o[i]! + q * n[i]!)], [center, centerVector.map((_, i) => (p - 1) * contextVector[i]! + q * negativeVector[i]!)], [context, c.map((x) => (p - 1) * x)], [context, centerVector.map((x) => (p - 1) * x)], [negative, c.map((x) => q * x)], [negative, centerVector.map((x) => q * x)], ]; for (const [index, gradient] of gradients) { const before = this.vector(index); const after = before.map((x, i) => x - this.learningRate * gradient[i]!); this.vectors[index] = after; // ▸ each vector steps against its share of the surprise this.tracer?.emit("move", { step, word: this.vocab[index], index, gradient, before, after }); } } return losses; }}12 steps done with the loss at 1.691; the focused word's nearest word is now prince at a cosine of 0.70.