Ant Colony Optimization
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.
ant-system.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";import type { TourProblem } from "./tour-problem";/** The most ants in one colony, so a round of tours stays traceable. */export const ACO_MAX_ANTS = 8;/** The most iterations one run takes. */export const ACO_MAX_ITERATIONS = 15;export interface AntSystemOptions { /** The cities to tour. */ problem: TourProblem; /** Seeds every start city and every roll, so a run repeats. */ seed: number; /** Ants per iteration, 1 to ACO_MAX_ANTS. Default 4. */ ants?: number; /** Iterations, 1 to ACO_MAX_ITERATIONS. Default 10. */ iterations?: number; /** The weight of the trail, >= 0. Default 1. */ alpha?: number; /** The weight of closeness, >= 0. Default 2. */ beta?: number; /** The share of every trail that evaporates each iteration, in (0, 1]. Default 0.5. */ rho?: number; /** The pheromone one ant spreads over its tour, divided by the tour's length, > 0. Default 10. */ q?: number; /** The starting pheromone on every edge, > 0. Default 1. */ tau0?: number; /** The ant whose choices are traced, 0 to ants - 1. Default 0. */ traced?: number;}export interface AntSystemResult { /** The shortest tour any ant walked, as a city order. */ readonly best: readonly number[]; /** Its length. */ readonly length: number; /** The pheromone matrix after the last deposit, symmetric with a zero diagonal. */ readonly pheromone: readonly (readonly number[])[]; /** The global best length after each iteration. */ readonly history: readonly number[]; /** How many iterations ran. */ readonly 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;/** A fall in the best length smaller than this is float noise, the same tour walked from another city, not an improvement. */const ACO_IMPROVE_TOLERANCE = 1e-9;const roundMatrix = (matrix: readonly (readonly number[])[]): number[][] => matrix.map((row) => row.map(round));/** * Ant System for the travelling salesman, as Dorigo, Maniezzo and * Colorni described it in 1996. Every edge carries a pheromone trail, * `tau0` to begin with. Each iteration every ant starts at a random city * and walks to an unvisited city with chance proportional to * `tau^alpha * (1 / d)^beta`: strong trails and short hops both attract. * When all the ants are home, every trail evaporates by the share `rho` * and each ant lays `q / length` on every edge of its tour, so the edges * of short tours gain more than the edges of long ones and the colony * drifts towards the best tours it has seen. Alpha 0 ignores the trails * (a greedy random walk), beta 0 ignores distance (the trails alone). * * The random draws are the same whether or not an ant is traced, so * `traced` changes the trace and nothing else. * * Time: O(I m n^2) for I iterations, m ants and n cities. Space: O(n^2) * for the pheromone matrix. */export class AntSystem { readonly problem: TourProblem; readonly seed: number; readonly ants: number; readonly iterations: number; readonly alpha: number; readonly beta: number; readonly rho: number; readonly q: number; readonly tau0: number; readonly traced: number; constructor(options: AntSystemOptions, private readonly tracer?: Tracer) { const { problem, seed, ants = 4, iterations = 10, alpha = 1, beta = 2, rho = 0.5, q = 10, tau0 = 1, traced = 0 } = options; if (!Number.isInteger(ants) || ants < 1 || ants > ACO_MAX_ANTS) throw new RangeError(`ants must be a whole number in [1, ${ACO_MAX_ANTS}]`); if (!Number.isInteger(iterations) || iterations < 1 || iterations > ACO_MAX_ITERATIONS) throw new RangeError(`iterations must be a whole number in [1, ${ACO_MAX_ITERATIONS}]`); if (!(alpha >= 0)) throw new RangeError(`alpha ${alpha} must be at least 0`); if (!(beta >= 0)) throw new RangeError(`beta ${beta} must be at least 0`); if (!(rho > 0 && rho <= 1)) throw new RangeError(`rho ${rho} must lie in (0, 1]`); if (!(q > 0)) throw new RangeError(`q ${q} must be positive`); if (!(tau0 > 0)) throw new RangeError(`tau0 ${tau0} must be positive`); if (!Number.isInteger(traced) || traced < 0 || traced >= ants) throw new RangeError(`traced must be a whole number in [0, ${ants})`); this.problem = problem; this.seed = seed; this.ants = ants; this.iterations = iterations; this.alpha = alpha; this.beta = beta; this.rho = rho; this.q = q; this.tau0 = tau0; this.traced = traced; } /** * The chance of each allowed city as the next stop from `from`: * `tau^alpha * (1 / d)^beta` over the allowed cities, scaled to sum to * one. Parallel to `allowed`. */ static chances( from: number, allowed: readonly number[], tau: readonly (readonly number[])[], trail: readonly (readonly number[])[], distance: (a: number, b: number) => number, alpha: number, beta: number, ): number[] { const weights = allowed.map((city) => Math.pow(tau[from]![city]!, alpha) * Math.pow(1 / distance(from, city), beta)); const weights = allowed.map((city) => Math.pow(trail[from]![city]!, alpha) * Math.pow(1 / distance(from, city), beta)); const total = weights.reduce((sum, weight) => sum + weight, 0); // Only when every weight underflows: then no city is preferred. if (!(total > 0)) return allowed.map(() => 1 / allowed.length); return weights.map((weight) => weight / total); } /** * The pheromone matrix after one iteration's update: every entry * evaporates to `(1 - rho) tau`, then every ant lays `q / length` on * both directions of every edge of its tour. The input is left alone. */ static updated( tau: readonly (readonly number[])[], trail: readonly (readonly number[])[], tours: readonly (readonly number[])[], lengths: readonly number[], rho: number, q: number, ): number[][] { return AntSystem.deposited(AntSystem.evaporated(tau, rho), tours, lengths, q); return AntSystem.deposited(AntSystem.evaporated(trail, rho), tours, lengths, q); } /** A copy of the matrix with `q / length` laid on both directions of every edge of every tour. */ private static deposited( tau: readonly (readonly number[])[], trail: readonly (readonly number[])[], tours: readonly (readonly number[])[], lengths: readonly number[], q: number, ): number[][] { const next = tau.map((row) => row.slice()); const next = trail.map((row) => row.slice()); tours.forEach((tour, ant) => { const deposit = q / lengths[ant]!; for (let i = 0; i < tour.length; i++) { const a = tour[i]!; const b = tour[(i + 1) % tour.length]!; next[a]![b] = next[a]![b]! + deposit; next[b]![a] = next[b]![a]! + deposit; } }); return next; } /** Runs the colony from scratch and reports the best tour it found. */ run(): AntSystemResult { const n = this.problem.size; const rng = new Mulberry32(this.seed); const rolls = new Mulberry32(this.seed); let tau: number[][] = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 0 : this.tau0))); let trail: number[][] = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 0 : this.tau0))); let best: number[] = []; let bestLength = Infinity; const history: number[] = []; // ▸ every edge starts with the same trail, tau0, and no tour is known this.tracer?.emit("init", { ants: this.ants, cities: n, pheromone: roundMatrix(tau) }); this.tracer?.emit("init", { ants: this.ants, cities: n, pheromone: roundMatrix(trail) }); for (let iteration = 1; iteration <= this.iterations; iteration++) { const tours: number[][] = []; const lengths: number[] = []; let roundBest = -1; let bestThisRound = -1; for (let ant = 0; ant < this.ants; ant++) { const order = this.walk(ant, iteration, tau, rng); const order = this.walk(ant, iteration, trail, rolls); const length = this.problem.length(order); tours.push(order); lengths.push(length); if (roundBest < 0 || length < lengths[roundBest]!) roundBest = ant; if (bestThisRound < 0 || length < lengths[bestThisRound]!) bestThisRound = ant; // ▸ an ant is home: its closed tour is measured this.tracer?.emit("tour", { ant, iteration, length: round(length), order: order.slice() }); } if (lengths[roundBest]! < bestLength - ACO_IMPROVE_TOLERANCE) { if (lengths[bestThisRound]! < bestLength - ACO_IMPROVE_TOLERANCE) { bestLength = lengths[roundBest]!; bestLength = lengths[bestThisRound]!; best = tours[roundBest]!.slice(); best = tours[bestThisRound]!.slice(); // ▸ the shortest tour yet: remember it this.tracer?.emit("improve", { ant: roundBest, iteration, length: round(bestLength), order: best.slice() }); this.tracer?.emit("improve", { ant: bestThisRound, iteration, length: round(bestLength), order: best.slice() }); } history.push(bestLength); tau = AntSystem.evaporated(tau, this.rho); trail = AntSystem.evaporated(trail, this.rho); // ▸ every trail loses the share rho, so old choices fade this.tracer?.emit("evaporate", { iteration, pheromone: roundMatrix(tau), rho: this.rho }); this.tracer?.emit("evaporate", { iteration, pheromone: roundMatrix(trail), rho: this.rho }); tau = AntSystem.deposited(tau, tours, lengths, this.q); trail = AntSystem.deposited(trail, tours, lengths, this.q); // ▸ each ant lays q over its length on every edge it walked this.tracer?.emit("deposit", { iteration, pheromone: roundMatrix(tau), strongest: AntSystem.strongest(tau) }); this.tracer?.emit("deposit", { iteration, pheromone: roundMatrix(trail), strongest: AntSystem.strongest(trail) }); } // ▸ the shortest tour any ant walked, after every iteration this.tracer?.emit("verdict", { iterations: this.iterations, length: round(bestLength), order: best.slice() }); return { best, length: bestLength, pheromone: tau, history, iterations: this.iterations }; return { best, length: bestLength, pheromone: trail, history, iterations: this.iterations }; } /** One ant's closed tour: a random start, then a roll per step through the cumulative chances. */ private walk(ant: number, iteration: number, tau: readonly (readonly number[])[], rng: Mulberry32): number[] { private walk(ant: number, iteration: number, trail: readonly (readonly number[])[], rolls: Mulberry32): number[] { const n = this.problem.size; const order = [rng.index(n)]; const order = [rolls.index(n)]; const visited = new Set<number>(order); while (order.length < n) { const at = order[order.length - 1]!; const allowed: number[] = []; for (let city = 0; city < n; city++) if (!visited.has(city)) allowed.push(city); const chances = AntSystem.chances(at, allowed, tau, (a, b) => this.problem.distance(a, b), this.alpha, this.beta); const chances = AntSystem.chances(at, allowed, trail, (a, b) => this.problem.distance(a, b), this.alpha, this.beta); const roll = rng.next(); const roll = rolls.next(); // The last allowed city takes any rounding remainder. let next = allowed[allowed.length - 1]!; let cumulative = 0; for (let k = 0; k < allowed.length; k++) { cumulative += chances[k]!; if (roll < cumulative) { next = allowed[k]!; break; } } if (ant === this.traced) { // ▸ the next city is a roll weighted by trail to the alpha and closeness to the beta this.tracer?.emit("choose", { allowed: allowed.slice(), ant, at, chances: chances.map(round), iteration, next, roll: round(roll) }); } order.push(next); visited.add(next); } return order; } /** A copy of the matrix with every entry scaled by `1 - rho`. */ private static evaporated(tau: readonly (readonly number[])[], rho: number): number[][] { private static evaporated(trail: readonly (readonly number[])[], rho: number): number[][] { return tau.map((row) => row.map((value) => value * (1 - rho))); return trail.map((row) => row.map((value) => value * (1 - rho))); } /** The largest entry above the diagonal as `[i, j, tau]`, the lowest i then j on a tie. */ private static strongest(tau: readonly (readonly number[])[]): [number, number, number] { private static strongest(trail: readonly (readonly number[])[]): [number, number, number] { let edge: [number, number, number] = [0, 1, tau[0]![1]!]; let edge: [number, number, number] = [0, 1, trail[0]![1]!]; for (let i = 0; i < tau.length; i++) { for (let i = 0; i < trail.length; i++) { for (let j = i + 1; j < tau.length; j++) { for (let j = i + 1; j < trail.length; j++) { if (tau[i]![j]! > edge[2]) edge = [i, j, tau[i]![j]!]; if (trail[i]![j]! > edge[2]) edge = [i, j, trail[i]![j]!]; } } return [edge[0], edge[1], round(edge[2])]; }}10 iterations: the best tour c4 c3 c1 c2 c0 c7 c6 c5 is 24.1 long, and the strongest trail is c1-c3 at 2.76.