Diffusion Models
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.
diffusion.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";import { Relu } from "./activation";import { Kernels } from "./kernels";export type ScheduleKind = "linear" | "cosine";/** The schedule's numbers at one timestep. */export interface ScheduleStep { beta: number; alpha: number; alphaBar: number; /** The fresh noise added after the mean step; zero on the last step. */ sigma: number;}/** One example of the noise-prediction task: a corrupted point, its timestep, and the noise that corrupted it. */export interface NoiseExample { input: readonly number[]; t: number; target: readonly number[];}export interface DiffusionOptions { /** Timesteps of the forward chain, T. */ steps: number; schedule: ScheduleKind; /** Neurons in each of the predictor's two hidden layers. */ hidden: number; learningRate: number; seed?: number;}/** How many sinusoidal features the timestep is fed as. */export const TIME_FEATURES = 8;/** The offset of the cosine schedule, so the first step is not exactly noiseless. */const COSINE_OFFSET = 0.008;/** No step may destroy everything: the cosine schedule's last betas are clipped here. */const MAX_BETA = 0.999;/** * How much noise each timestep adds. `betas` are the per-step variances, * `alphas` what each step keeps, and `alphaBars` the running product: * how much of the clean signal survives to step t. Linear betas rise * evenly and are scaled by the number of steps so the chain ends near * pure noise however short it is; the cosine schedule sets the products * directly, along a cosine squared, and derives the betas. */export class NoiseSchedule { readonly betas: number[]; readonly alphas: number[]; readonly alphaBars: number[]; constructor( readonly kind: ScheduleKind, readonly steps: number, ) { if (!Number.isInteger(steps) || steps < 2) throw new RangeError("a schedule needs at least two steps"); if (kind === "linear") { // Betas from 0.2/T to 6/T: the sum stays about three, so the last product is under 0.05 for T in 10..40. this.betas = Array.from({ length: steps }, (_, t) => Math.min(MAX_BETA, (0.2 + (5.8 * t) / (steps - 1)) / steps)); } else { const f = (u: number) => Math.cos((((u / steps + COSINE_OFFSET) / (1 + COSINE_OFFSET)) * Math.PI) / 2) ** 2; this.betas = Array.from({ length: steps }, (_, t) => Math.min(MAX_BETA, 1 - f(t + 1) / f(t))); } this.alphas = this.betas.map((b) => 1 - b); this.alphaBars = []; let product = 1; for (const a of this.alphas) { product *= a; this.alphaBars.push(product); } } at(t: number): ScheduleStep { if (!Number.isInteger(t) || t < 0 || t >= this.steps) throw new RangeError(`timestep ${t} is outside 0..${this.steps - 1}`); const beta = this.betas[t]!; return { beta, alpha: this.alphas[t]!, alphaBar: this.alphaBars[t]!, sigma: t === 0 ? 0 : Math.sqrt(beta) }; }}interface Forward { features: number[]; hidden: number[][]; outputs: number[];}/** * The network that guesses the noise in a corrupted point: the point's * coordinates and a sinusoidal code of the timestep go through two relu * layers to one linear output per coordinate. Trained by plain gradient * descent on the squared error against the noise that was actually * added, with the backward pass written out. */export class NoisePredictor { private w: number[][][]; private b: number[][]; readonly features: number; private readonly relu = new Relu(); constructor( readonly inputs: number, readonly hidden: number, seed = 1, ) { if (!Number.isInteger(inputs) || inputs < 1) throw new RangeError("a point needs at least one coordinate"); if (!Number.isInteger(hidden) || hidden < 1) throw new RangeError("at least one hidden neuron"); this.features = inputs + TIME_FEATURES; const random = new Mulberry32(seed); const sizes = [this.features, hidden, hidden, inputs]; this.w = []; this.b = []; for (let l = 0; l < sizes.length - 1; l++) { const from = sizes[l]!; const into = sizes[l + 1]!; const spread = Math.sqrt(6 / (from + into)); this.w.push(Array.from({ length: into }, () => Array.from({ length: from }, () => random.between(-spread, spread)))); this.b.push(Array.from({ length: into }, () => 0)); } } /** Takes another predictor's weights, for snapshots along a training run. */ copyFrom(other: NoisePredictor): void { if (other.inputs !== this.inputs || other.hidden !== this.hidden) throw new RangeError("the predictors differ in shape"); this.w = other.w.map((layer) => layer.map((row) => [...row])); this.b = other.b.map((row) => [...row]); } /** The coordinates followed by the timestep's sinusoidal code. */ encode(point: readonly number[], t: number): number[] { return [...point, ...Kernels.positionalEncoding(t, TIME_FEATURES)]; } private forward(point: readonly number[], t: number): Forward { const features = this.encode(point, t); const hidden: number[][] = []; let a = features; let outputs: number[] = []; for (let l = 0; l < this.w.length; l++) { const rows = this.w[l]!; const sums: number[] = new Array<number>(rows.length); for (let j = 0; j < rows.length; j++) { const row = rows[j]!; let sum = this.b[l]![j]!; for (let i = 0; i < row.length; i++) sum += row[i]! * a[i]!; sums[j] = sum; } if (l === this.w.length - 1) { outputs = sums; } else { a = sums.map((v) => this.relu.apply(v)); hidden.push(a); } } return { features, hidden, outputs }; } /** The guessed noise in a point at timestep t. */ predict(point: readonly number[], t: number): number[] { if (point.length !== this.inputs) throw new RangeError(`expected ${this.inputs} coordinates, got ${point.length}`); return this.forward(point, t).outputs; } /** One gradient step on a batch; returns the mean squared error before the update. */ step(batch: readonly NoiseExample[], learningRate: number): number { if (batch.length < 1) throw new RangeError("a step needs at least one example"); if (!(learningRate > 0)) throw new RangeError("the learning rate must be positive"); const gW = this.w.map((layer) => layer.map((row) => row.map(() => 0))); const gB = this.b.map((row) => row.map(() => 0)); let loss = 0; for (const example of batch) { const f = this.forward(example.input, example.t); // Blame at the output: the slope of the squared error, averaged over the batch. let delta = f.outputs.map((v, k) => { const error = v - example.target[k]!; loss += (error * error) / batch.length; return (2 * error) / batch.length; }); for (let l = this.w.length - 1; l >= 0; l--) { const below = l === 0 ? f.features : f.hidden[l - 1]!; const rows = this.w[l]!; const next: number[] = new Array<number>(below.length).fill(0); for (let j = 0; j < rows.length; j++) { const g = delta[j]!; if (g === 0) continue; const row = rows[j]!; const gRow = gW[l]![j]!; gB[l]![j] = gB[l]![j]! + g; for (let i = 0; i < row.length; i++) { gRow[i] = gRow[i]! + g * below[i]!; next[i] = next[i]! + g * row[i]!; } } // Through the relu: blame passes only where the neuron fired. if (l > 0) delta = next.map((g, i) => g * this.relu.derivative(f.hidden[l - 1]![i]!)); } } this.w.forEach((layer, l) => layer.forEach((row, j) => row.forEach((_, i) => (row[i] = row[i]! - learningRate * gW[l]![j]![i]!)))); this.b.forEach((row, l) => row.forEach((_, j) => (row[j] = row[j]! - learningRate * gB[l]![j]!))); return loss; }}/** * A denoising diffusion model on points of the plane. The forward * process corrupts a clean point toward pure noise along the schedule * in one closed-form draw; training asks the predictor to guess the * noise in a corrupted point at a random timestep; sampling starts * from noise and walks the timesteps backward, at each one subtracting * the guessed noise to reach the mean and adding a smaller fresh noise, * none on the last step. `sample` traces one cloud's walk, one event * per timestep per stage; `fit` runs the training untraced. A clone * restarts the random draws from the seed, so a cloud sampled from * every snapshot of a run starts from the same noise. * * Time: O(count · T · hidden²) per sample. Space: O(weights + count). */export class DenoisingDiffusion { readonly schedule: NoiseSchedule; readonly predictor: NoisePredictor; private readonly random: Mulberry32; private spare: number | null = null; constructor( readonly options: DiffusionOptions, private readonly tracer?: Tracer, ) { if (!(options.learningRate > 0)) throw new RangeError("the learning rate must be positive"); this.schedule = new NoiseSchedule(options.schedule, options.steps); this.predictor = new NoisePredictor(2, options.hidden, options.seed ?? 1); this.random = new Mulberry32((options.seed ?? 1) * 7919 + 13); } /** A standard normal draw by the Box-Muller transform, two at a time. */ private gaussian(): number { if (this.spare !== null) { const value = this.spare; this.spare = null; return value; } const u = 1 - this.random.next(); const v = this.random.next(); const radius = Math.sqrt(-2 * Math.log(u)); this.spare = radius * Math.sin(2 * Math.PI * v); return radius * Math.cos(2 * Math.PI * v); } /** A copy with the same weights and the random draws restarted from the seed; a tracer can be attached to the copy. */ clone(tracer: Tracer | undefined = this.tracer): DenoisingDiffusion { const copy = new DenoisingDiffusion(this.options, tracer); copy.predictor.copyFrom(this.predictor); return copy; } /** The forward process in one draw: the clean point faded by root alpha-bar plus noise scaled by root one minus alpha-bar. */ corrupt(x0: readonly number[], t: number): { noisy: number[]; noise: number[] } { const s = this.schedule.at(t); const noise = x0.map(() => this.gaussian()); const noisy = x0.map((v, k) => Math.sqrt(s.alphaBar) * v + Math.sqrt(1 - s.alphaBar) * noise[k]!); return { noisy, noise }; } /** Untraced training: each step corrupts a random batch of targets at random timesteps and moves the predictor; returns the loss per step. */ fit(targets: readonly (readonly number[])[], steps: number, batch: number): number[] { if (targets.length < 1) throw new RangeError("training needs at least one target"); if (!Number.isInteger(steps) || steps < 0) throw new RangeError("steps must be a whole number"); if (!Number.isInteger(batch) || batch < 1) throw new RangeError("a batch needs at least one example"); const history: number[] = []; for (let s = 0; s < steps; s++) { const examples: NoiseExample[] = []; for (let b = 0; b < batch; b++) { const t = this.random.index(this.schedule.steps); const { noisy, noise } = this.corrupt(targets[this.random.index(targets.length)]!, t); examples.push({ input: noisy, t, target: noise }); } history.push(this.predictor.step(examples, this.options.learningRate)); } return history; } /** The traced reverse process: a cloud of `count` points walked from pure noise back to the shape. */ sample(count: number): number[][] { if (!Number.isInteger(count) || count < 1) throw new RangeError("a sample needs at least one point"); let x = Array.from({ length: count }, () => [this.gaussian(), this.gaussian()]); let cloud = Array.from({ length: count }, () => [this.gaussian(), this.gaussian()]); for (let t = this.schedule.steps - 1; t >= 0; t--) { const s = this.schedule.at(t); // ▸ this timestep's numbers: how much signal is kept, how much noise comes back this.tracer?.emit("schedule", { t, beta: s.beta, alpha: s.alpha, alphaBar: s.alphaBar, sigma: s.sigma }); const eps = x.map((p) => this.predictor.predict(p, t)); const guessedNoise = cloud.map((p) => this.predictor.predict(p, t)); const meanNorm = eps.reduce((sum, e) => sum + Math.hypot(e[0]!, e[1]!), 0) / count; const meanNorm = guessedNoise.reduce((sum, e) => sum + Math.hypot(e[0]!, e[1]!), 0) / count; // ▸ the network guesses the noise hiding in every point of the cloud this.tracer?.emit("predict", { t, points: x.map((p) => [...p]), noise: eps.map((e) => [...e]), meanNorm }); this.tracer?.emit("predict", { t, points: cloud.map((p) => [...p]), noise: guessedNoise.map((e) => [...e]), meanNorm }); // The mean of the step back: strip the guessed noise's share and undo this step's fade. const pull = s.beta / Math.sqrt(1 - s.alphaBar); x = x.map((p, i) => p.map((v, k) => (v - pull * eps[i]![k]!) / Math.sqrt(s.alpha))); cloud = cloud.map((p, i) => p.map((v, k) => (v - pull * guessedNoise[i]![k]!) / Math.sqrt(s.alpha))); // ▸ strip the guessed noise's share and undo this step's fade this.tracer?.emit("mean", { t, points: x.map((p) => [...p]) }); this.tracer?.emit("mean", { t, points: cloud.map((p) => [...p]) }); const z = x.map((p) => p.map(() => this.gaussian())); const freshNoise = cloud.map((p) => p.map(() => this.gaussian())); x = x.map((p, i) => p.map((v, k) => v + s.sigma * z[i]![k]!)); cloud = cloud.map((p, i) => p.map((v, k) => v + s.sigma * freshNoise[i]![k]!)); // ▸ add a smaller fresh noise, none at all on the last step this.tracer?.emit("noise", { t, points: x.map((p) => [...p]), sigma: s.sigma }); this.tracer?.emit("noise", { t, points: cloud.map((p) => [...p]), sigma: s.sigma }); } return x; return cloud; }}64 points end a mean distance 0.315 from the shape's nearest target.