Simulated Annealing
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
simulated-annealing.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";import { Landscape } from "./landscape";/** The most iterations one run takes, so a trace stays short enough to step through. */export const SA_MAX_ITERATIONS = 40;/** The starting temperature when none is given. */export const SA_DEFAULT_TEMPERATURE = 2;/** The factor the temperature shrinks by after every iteration when none is given. */export const SA_DEFAULT_COOLING = 0.9;/** The Gaussian radius of a proposed move when none is given. */export const SA_DEFAULT_STEP = 1;/** The iterations one run takes when none is given. */export const SA_DEFAULT_ITERATIONS = 30;export interface AnnealingOptions { /** The landscape the walker descends. */ landscape: Landscape; /** Seeds the start (when none is given), the proposals, and the Metropolis rolls. */ seed: number; /** Where the walker begins; default a uniform random point of the landscape. */ start?: readonly number[]; /** The starting temperature, at least 0; 0 is hill climbing, since no uphill move can pass. Default 2. */ temperature?: number; /** The temperature's factor per iteration, in (0, 1]; 1 never cools. Default 0.9. */ cooling?: number; /** The Gaussian radius of a proposal, above 0; fixed, so the temperature acts only in the acceptance test. Default 1. */ step?: number; /** How many proposals to make, a whole number in [1, SA_MAX_ITERATIONS]. Default 30. */ iterations?: number;}export interface AnnealingResult { /** The lowest position the walker stood on. */ best: number[]; /** The landscape's height at `best`. */ bestValue: number; /** Every position the walker stood on, the start first and one more per accepted move. */ path: number[][]; /** The height at each position of the path. */ values: number[]; /** Downhill or level moves taken. */ accepted: number; /** Uphill moves taken, each on a roll under its chance. */ uphill: number; /** Uphill moves refused. */ rejected: number; /** Proposals made: `accepted + uphill + rejected`. */ iterations: number;}/** Payload numbers are shown to three decimals; the result keeps the raw values. */const round = (value: number): number => Math.round(value * 1000) / 1000;const roundPoint = (point: readonly number[]): number[] => point.map(round);/** * Simulated annealing on a two-dimensional landscape: a walker proposes * a random Gaussian step each iteration, always takes it when the * ground is lower or level, and takes it uphill with chance * `exp(-delta / T)`, the Metropolis test, where the temperature `T` * shrinks by a fixed factor after every proposal. Early, while `T` is * high, uphill moves pass often and the walker escapes small basins; * late, as `T` nears zero, only descents pass and it settles in the * basin it is in. At temperature 0 every uphill move is refused and the * walk is plain hill climbing. Proposals are clamped to the landscape's * domain. The step radius is fixed, so the temperature acts only in the * acceptance test, and the random stream is the same at every * temperature: the roll is drawn whenever a move goes uphill, even when * it cannot pass. * * Time: O(I) landscape evaluations for I iterations. Space: O(I) for the path. */export class SimulatedAnnealing { readonly landscape: Landscape; readonly seed: number; readonly start: readonly number[] | undefined; readonly temperature: number; readonly cooling: number; readonly step: number; readonly iterations: number; constructor(options: AnnealingOptions, private readonly tracer?: Tracer) { const { landscape, seed, start, temperature = SA_DEFAULT_TEMPERATURE, cooling = SA_DEFAULT_COOLING, step = SA_DEFAULT_STEP, iterations = SA_DEFAULT_ITERATIONS } = options; if (!Number.isFinite(seed)) throw new RangeError(`seed ${seed} must be finite`); if (!Number.isFinite(temperature) || temperature < 0) throw new RangeError(`temperature ${temperature} must be a finite number of at least 0`); if (!(cooling > 0 && cooling <= 1)) throw new RangeError(`cooling ${cooling} must lie in (0, 1]`); if (!Number.isFinite(step) || step <= 0) throw new RangeError(`step ${step} must be positive`); if (!Number.isInteger(iterations) || iterations < 1 || iterations > SA_MAX_ITERATIONS) throw new RangeError(`iterations ${iterations} must be a whole number in [1, ${SA_MAX_ITERATIONS}]`); if (start !== undefined && (start.length !== 2 || !landscape.contains(start))) throw new RangeError(`start ${start} must be a point inside the landscape's domain`); this.landscape = landscape; this.seed = seed; this.start = start === undefined ? undefined : [...start]; this.temperature = temperature; this.cooling = cooling; this.step = step; this.iterations = iterations; } /** The chance an uphill move of `delta` passes at temperature `t`: 1 when `delta <= 0`, 0 when `t` is 0, else `exp(-delta / t)`. */ static acceptance(delta: number, t: number): number { static acceptance(heightChange: number, t: number): number { if (delta <= 0) return 1; if (heightChange <= 0) return 1; if (t === 0) return 0; return Math.exp(-delta / t); return Math.exp(-heightChange / t); } /** The temperature after `k` coolings from `t0`: `t0 * cooling^k`. */ static temperatureAt(t0: number, cooling: number, k: number): number { return t0 * cooling ** k; } /** Runs the walk from scratch; the same seed gives the same walk every time. */ run(): AnnealingResult { const rng = new Mulberry32(this.seed); const landscape = this.landscape; let position = this.start === undefined ? landscape.random(rng) : [...this.start]; let value = landscape.value(position); let best = position.slice(); let bestValue = value; let temperature = this.temperature; const path: number[][] = [position.slice()]; const values: number[] = [value]; let accepted = 0; let uphill = 0; let rejected = 0; // ▸ stand at the start and read the ground's height there this.tracer?.emit("init", { landscape: landscape.kind, position: roundPoint(position), temperature: round(temperature), value: round(value) }); for (let iteration = 1; iteration <= this.iterations; iteration++) { const [dx, dy] = this.gaussianPair(rng); const candidate = landscape.clamp([position[0]! + this.step * dx, position[1]! + this.step * dy]); const candidateValue = landscape.value(candidate); const delta = candidateValue - value; const heightChange = candidateValue - value; // ▸ propose a random gaussian step and read the height there this.tracer?.emit("propose", { candidate: roundPoint(candidate), delta: round(delta), iteration, value: round(candidateValue) }); this.tracer?.emit("propose", { candidate: roundPoint(candidate), heightChange: round(heightChange), iteration, value: round(candidateValue) }); let taken: boolean; if (delta <= 0) { if (heightChange <= 0) { taken = true; accepted++; // ▸ downhill or level: always take the move this.tracer?.emit("accept", { iteration, position: roundPoint(candidate), value: round(candidateValue) }); } else { // The roll is drawn whenever the move goes uphill, even at temperature 0, so the stream does not depend on the temperature. const chance = SimulatedAnnealing.acceptance(delta, temperature); const passChance = SimulatedAnnealing.acceptance(heightChange, temperature); const roll = rng.next(); const diceRoll = rng.next(); taken = roll < chance; taken = diceRoll < passChance; if (taken) { uphill++; // ▸ uphill, but the roll came in under the chance: climb this.tracer?.emit("accept-worse", { chance: round(chance), iteration, position: roundPoint(candidate), roll: round(roll), value: round(candidateValue) }); this.tracer?.emit("accept-worse", { passChance: round(passChance), iteration, position: roundPoint(candidate), diceRoll: round(diceRoll), value: round(candidateValue) }); } else { rejected++; // ▸ uphill, and the roll missed the chance: stay put this.tracer?.emit("reject", { chance: round(chance), iteration, roll: round(roll) }); this.tracer?.emit("reject", { passChance: round(passChance), iteration, diceRoll: round(diceRoll) }); } } if (taken) { position = candidate; value = candidateValue; path.push(position.slice()); values.push(value); if (value < bestValue) { best = position.slice(); bestValue = value; } } temperature *= this.cooling; // ▸ shrink the temperature by the cooling factor this.tracer?.emit("cool", { iteration, temperature: round(temperature) }); } // ▸ the lowest ground the walker stood on is the answer this.tracer?.emit("verdict", { best: roundPoint(best), iterations: this.iterations, value: round(bestValue) }); return { best, bestValue, path, values, accepted, uphill, rejected, iterations: this.iterations }; } /** Two standard normal draws by Box-Muller from two uniforms, `u1` then `u2`. */ private gaussianPair(rng: Mulberry32): [number, number] { const u1 = rng.next(); const u2 = rng.next(); const r = Math.sqrt(-2 * Math.log(1 - u1)); return [r * Math.cos(2 * Math.PI * u2), r * Math.sin(2 * Math.PI * u2)]; }}5 downhill moves and 5 uphill moves taken, 20 refused; the best is 11.16 at (2.91, 1.02).