The Transformer Block
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.
transformer-block.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";import { Relu } from "./activation";import { Kernels } from "./kernels";import { SelfAttention } from "./self-attention";export interface BlockResult { /** The residual stream after the block, one row per token. */ stream: number[][]; /** attention[head][query][key]. */ attention: number[][][]; /** One probability row over the vocabulary per token. */ probabilities: number[][];}/** * One transformer block with a vocabulary head at every position: embed * each token and add its position code, let every head attend over its * slice of the stream, add the result back and normalize, push each row * through a small relu network, add and normalize again, and read a * distribution over the vocabulary off every row. Unmasked, so with the * position code off two identical tokens produce identical rows. The * weights are seeded, not trained: the lesson is the data flow. * * Time: O(n² · d + n · d · hidden) for n tokens. Space: O(n · d). */export class TransformerBlock { readonly embeddings: number[][]; private readonly heads_: SelfAttention[]; private readonly wo: number[][]; private readonly w1: number[][]; private readonly w2: number[][]; private readonly wout: number[][]; private readonly relu = new Relu(); readonly headDim: number; constructor( readonly dim: number, readonly heads: number, readonly hidden: number, readonly vocab: readonly string[], seed = 1, private readonly tracer?: Tracer, ) { if (!Number.isInteger(heads) || heads < 1 || dim % heads !== 0) throw new RangeError("the dimension must split evenly across the heads"); if (!Number.isInteger(hidden) || hidden < 1) throw new RangeError("the hidden width must be a whole number of at least one"); if (vocab.length < 2) throw new RangeError("a vocabulary needs at least two words"); this.headDim = dim / heads; const random = new Mulberry32(seed); const matrix = (rows: number, cols: number) => Array.from({ length: rows }, () => Array.from({ length: cols }, () => random.between(-1, 1))); this.embeddings = matrix(vocab.length, dim); this.heads_ = Array.from({ length: heads }, (_, head) => new SelfAttention(this.headDim, "seeded", seed * 101 + head + 1)); this.wo = matrix(dim, dim); this.w1 = matrix(hidden, dim); this.w2 = matrix(dim, hidden); this.wout = matrix(vocab.length, dim); } private apply(m: readonly (readonly number[])[], v: readonly number[]): number[] { return m.map((row) => row.reduce((s, weight, i) => s + weight * (v[i] ?? 0), 0)); } /** The residual connection: the sub-layer's delta added back onto the row. */ private residual(stage: string, index: number, before: readonly number[], delta: readonly number[]): number[] { const after = before.map((value, i) => value + delta[i]!); // ▸ the sub-layer's result is added onto the row, not written over it this.tracer?.emit("add", { stage, index, before: [...before], delta: [...delta], after: [...after] }); return after; } /** Layer norm: the row recentred and rescaled, with no learned gain or shift. */ private normalize(stage: string, index: number, row: readonly number[]): number[] { const { out, mean, variance } = Kernels.layerNorm(row); // ▸ the row is recentred to mean zero and rescaled to unit variance this.tracer?.emit("norm", { stage, index, mean, variance, after: [...out] }); return out; } forward(tokens: readonly number[], positional: boolean, temperature = 1): BlockResult { if (tokens.some((t) => !Number.isInteger(t) || t < 0 || t >= this.vocab.length)) throw new RangeError("every token must index the vocabulary"); let x: number[][] = []; let residualStream: number[][] = []; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]!; const embedding = this.embeddings[token]!; const position = positional ? Kernels.positionalEncoding(index, this.dim) : Array.from({ length: this.dim }, () => 0); const sum = embedding.map((value, i) => value + position[i]!); x.push(sum); residualStream.push(sum); // ▸ a token's vector plus a code for where it sits this.tracer?.emit("embed", { index, token: this.vocab[token], embedding: [...embedding], position: [...position], sum: [...sum] }); } const attention: number[][][] = []; const h: number[][] = x.map(() => []); const headOutputs: number[][] = residualStream.map(() => []); for (let head = 0; head < this.heads; head++) { const slice = x.map((row) => row.slice(head * this.headDim, (head + 1) * this.headDim)); const slice = residualStream.map((row) => row.slice(head * this.headDim, (head + 1) * this.headDim)); const { weights, outputs } = this.heads_[head]!.attend(slice); attention.push(weights); for (let index = 0; index < tokens.length; index++) { h[index]!.push(...outputs[index]!); headOutputs[index]!.push(...outputs[index]!); // ▸ one head looks around the sentence from this token this.tracer?.emit("attend", { head, index, weights: [...weights[index]!], output: [...outputs[index]!] }); } } x = x.map((row, index) => this.residual("attention", index, row, this.apply(this.wo, h[index]!))); residualStream = residualStream.map((row, index) => this.residual("attention", index, row, this.apply(this.wo, headOutputs[index]!))); x = x.map((row, index) => this.normalize("attention", index, row)); residualStream = residualStream.map((row, index) => this.normalize("attention", index, row)); const f: number[][] = []; const feedForward: number[][] = []; for (let index = 0; index < tokens.length; index++) { const hiddenRow = this.apply(this.w1, x[index]!).map((value) => this.relu.apply(value)); const hiddenRow = this.apply(this.w1, residualStream[index]!).map((value) => this.relu.apply(value)); const output = this.apply(this.w2, hiddenRow); f.push(output); feedForward.push(output); // ▸ a small relu network works on each row alone this.tracer?.emit("ffn", { index, hidden: [...hiddenRow], output: [...output] }); } x = x.map((row, index) => this.residual("ffn", index, row, f[index]!)); residualStream = residualStream.map((row, index) => this.residual("ffn", index, row, feedForward[index]!)); x = x.map((row, index) => this.normalize("ffn", index, row)); residualStream = residualStream.map((row, index) => this.normalize("ffn", index, row)); const probabilities: number[][] = []; for (let index = 0; index < tokens.length; index++) { const logits = this.apply(this.wout, x[index]!); const logits = this.apply(this.wout, residualStream[index]!); const p = Kernels.softmax(logits, temperature); const top = p.indexOf(Math.max(...p)); probabilities.push(p); // ▸ the row becomes a distribution over the vocabulary this.tracer?.emit("predict", { index, logits: [...logits], probabilities: [...p], top: this.vocab[top], probability: p[top] }); } return { stream: x, attention, probabilities }; return { stream: residualStream, attention, probabilities }; }}Every row is done: the inspected token predicts ran at 39 percent, and the attention rows average 2.11 bits of entropy.