Genetic Algorithms
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.
genetic-algorithm.ts
import { KnapsackProblem } from "../dynamic-programming/knapsack-problem";import { Mulberry32 } from "../random";import type { Tracer } from "../trace";/** The fewest genomes a population holds; two pairs, so selection has something to choose from. */export const GA_MIN_POPULATION = 4;/** The most genomes a population holds, so a generation's grid fits the stage. */export const GA_MAX_POPULATION = 10;/** The most generations one run breeds. */export const GA_MAX_GENERATIONS = 30;/** The tournament draws this many contestants per parent. */export const GA_TOURNAMENT_SIZE = 2;export interface GeneticOptions { /** The knapsack instance the genomes choose from. */ problem: KnapsackProblem; /** Seeds the first population and every later draw. */ seed: number; /** Genomes per generation: even, in [GA_MIN_POPULATION, GA_MAX_POPULATION]. Default 8. */ population?: number; /** Generations bred after the first: in [1, GA_MAX_GENERATIONS]. Default 20. */ generations?: number; /** The chance a pair of parents is crossed rather than copied, in [0, 1]. Default 0.9. */ crossoverRate?: number; /** The chance each bit of a child flips, in [0, 1]. Default 0.1. */ mutationRate?: number; /** Fitness lost per unit of weight over the capacity; default ceil(2 * the best value per weight among the items). */ penalty?: number;}export interface GeneticResult { /** The fittest genome of the last generation, one bit per item. */ readonly best: readonly number[]; /** Its fitness: value less the penalty for any weight over the capacity. */ readonly fitness: number; /** Its total weight. */ readonly weight: number; /** Its total value. */ readonly value: number; /** The best fitness of every generation, the first population included: `generations + 1` entries. */ readonly history: readonly number[]; /** How many generations were bred. */ readonly generations: number;}/** A flipped bit: the genome's index in the new generation and the bit's position. */export type GeneticFlip = readonly [genome: number, bit: number];/** * A genetic algorithm over bit-string genomes, one bit per knapsack item: * a population of random choices is scored, and each generation breeds * the next from it. Two parents are picked by tournaments of two (a * random pair, the fitter wins), crossed at one random point with the * crossover rate (otherwise copied), and each bit of each child flips * with the mutation rate. The fittest genome is carried over unchanged * (the elite), so the best fitness never falls. A genome over the * capacity is not repaired: a linear penalty per unit of excess weight * makes it score below any choice that fits, so selection breeds it out * while the grid shows exactly what the operators produced. Fitness can * be negative, which is why selection is a tournament rather than a * roulette wheel. * * Time: O(G P n) for G generations, P genomes, n items. Space: O(P n). */export class GeneticAlgorithm { readonly problem: KnapsackProblem; readonly seed: number; readonly population: number; readonly generations: number; readonly crossoverRate: number; readonly mutationRate: number; readonly penalty: number; constructor(options: GeneticOptions, private readonly tracer?: Tracer) { const { problem, seed, population = 8, generations = 20, crossoverRate = 0.9, mutationRate = 0.1 } = options; if (!Number.isInteger(population) || population % 2 !== 0 || population < GA_MIN_POPULATION || population > GA_MAX_POPULATION) { throw new RangeError(`population must be an even whole number in [${GA_MIN_POPULATION}, ${GA_MAX_POPULATION}]`); } if (!Number.isInteger(generations) || generations < 1 || generations > GA_MAX_GENERATIONS) throw new RangeError(`generations must be a whole number in [1, ${GA_MAX_GENERATIONS}]`); if (!(crossoverRate >= 0 && crossoverRate <= 1)) throw new RangeError(`crossoverRate ${crossoverRate} must lie in [0, 1]`); if (!(mutationRate >= 0 && mutationRate <= 1)) throw new RangeError(`mutationRate ${mutationRate} must lie in [0, 1]`); const penalty = options.penalty ?? GeneticAlgorithm.defaultPenalty(problem); if (!(penalty > 0) || !Number.isFinite(penalty)) throw new RangeError(`penalty ${penalty} must be positive`); this.problem = problem; this.seed = seed; this.population = population; this.generations = generations; this.crossoverRate = crossoverRate; this.mutationRate = mutationRate; this.penalty = penalty; } /** The default penalty: twice the best value per unit of weight among the items, rounded up, so dropping the excess always pays. */ static defaultPenalty(problem: KnapsackProblem): number { return Math.ceil(2 * Math.max(...problem.items.map((item) => item.value / item.weight))); } /** One-point crossover: the first `cut` bits of each parent with the rest of the other; `cut` in [1, n - 1]. */ static crossover(a: readonly number[], b: readonly number[], cut: number): [number[], number[]] { static crossover(firstParent: readonly number[], secondParent: readonly number[], cut: number): [number[], number[]] { if (a.length !== b.length) throw new RangeError("parents must have the same length"); if (firstParent.length !== secondParent.length) throw new RangeError("parents must have the same length"); if (!Number.isInteger(cut) || cut < 1 || cut > a.length - 1) throw new RangeError(`cut ${cut} must be a whole number in [1, ${a.length - 1}]`); if (!Number.isInteger(cut) || cut < 1 || cut > firstParent.length - 1) throw new RangeError(`cut ${cut} must be a whole number in [1, ${a.length - 1}]`); return [ [...a.slice(0, cut), ...b.slice(cut)], [...firstParent.slice(0, cut), ...secondParent.slice(cut)], [...b.slice(0, cut), ...a.slice(cut)], [...secondParent.slice(0, cut), ...firstParent.slice(cut)], ]; } /** The fitness of a choice: its value, less the penalty for every unit of weight over the capacity. */ score(bits: readonly number[]): number { const { weight, value } = this.problem.evaluate(bits); return value - this.penalty * Math.max(0, weight - this.problem.capacity); } /** Breeds the generations from a seeded first population and reports the fittest genome of the last. */ run(): GeneticResult { const rng = new Mulberry32(this.seed); const random = new Mulberry32(this.seed); const n = this.problem.size; const itemCount = this.problem.size; const P = this.population; const populationSize = this.population; let genomes: number[][] = Array.from({ length: P }, () => Array.from({ length: n }, () => rng.index(2))); let genomes: number[][] = Array.from({ length: populationSize }, () => Array.from({ length: itemCount }, () => random.index(2))); // ▸ the first population: one random bit per item for each genome, 1 to take the item and 0 to leave it this.tracer?.emit("init", { capacity: this.problem.capacity, genomes: genomes.map(GeneticAlgorithm.text), items: this.problem.items.map((item) => ({ name: item.name, value: item.value, weight: item.weight })), }); const history: number[] = []; let { best, fitness } = this.evaluate(genomes, 0); history.push(fitness[best]!); for (let generation = 0; generation < this.generations; generation++) { const elite = genomes[best]!; // ▸ the fittest genome is copied into the next generation untouched, so the best fitness can never fall this.tracer?.emit("elite", { fitness: fitness[best]!, generation, genome: GeneticAlgorithm.text(elite), index: best }); const next: number[][] = [elite.slice()]; const flips: GeneticFlip[] = []; for (let pair = 0; next.length < P; pair++) { for (let pair = 0; next.length < populationSize; pair++) { const parents = [this.select(genomes, fitness, rng), this.select(genomes, fitness, rng)] as const; const parents = [this.select(genomes, fitness, random), this.select(genomes, fitness, random)] as const; // ▸ a tournament of two for each parent: two genomes drawn at random, the fitter one wins this.tracer?.emit("select", { generation, pair, parents: [parents[0].winner, parents[1].winner], picks: [...parents[0].picks, ...parents[1].picks] }); const a = genomes[parents[0].winner]!; const firstParent = genomes[parents[0].winner]!; const b = genomes[parents[1].winner]!; const secondParent = genomes[parents[1].winner]!; const crossed = rng.next() < this.crossoverRate; const crossed = random.next() < this.crossoverRate; const cut = crossed ? 1 + rng.index(n - 1) : 0; const cut = crossed ? 1 + random.index(itemCount - 1) : 0; const children = crossed ? GeneticAlgorithm.crossover(a, b, cut) : [a.slice(), b.slice()]; const children = crossed ? GeneticAlgorithm.crossover(firstParent, secondParent, cut) : [firstParent.slice(), secondParent.slice()]; // ▸ with the crossover rate, cut both parents at one random point and swap their tails; otherwise copy them this.tracer?.emit("crossover", { children: children.map(GeneticAlgorithm.text), crossed, cut, generation, pair, parents: [GeneticAlgorithm.text(a), GeneticAlgorithm.text(b)], parents: [GeneticAlgorithm.text(firstParent), GeneticAlgorithm.text(secondParent)], }); // The last pair of an odd count of children yields one too many: the second is dropped before it is mutated. for (const child of children) { if (next.length === P) break; if (next.length === populationSize) break; const index = next.length; for (let bit = 0; bit < n; bit++) { for (let bit = 0; bit < itemCount; bit++) { if (rng.next() < this.mutationRate) { if (random.next() < this.mutationRate) { child[bit] = 1 - child[bit]!; flips.push([index, bit]); } } next.push(child); } } // ▸ every bit of every child flips with the mutation rate; the next generation is complete this.tracer?.emit("mutate", { flips: flips.map((flip) => [...flip]), generation, genomes: next.map(GeneticAlgorithm.text) }); genomes = next; ({ best, fitness } = this.evaluate(genomes, generation + 1)); history.push(fitness[best]!); } const winner = genomes[best]!; const plan = this.problem.evaluate(winner); // ▸ the fittest genome of the last generation, with its fitness and weight this.tracer?.emit("verdict", { fitness: fitness[best]!, generation: this.generations, genome: GeneticAlgorithm.text(winner), weight: plan.weight }); return { best: winner.slice(), fitness: fitness[best]!, weight: plan.weight, value: plan.value, history, generations: this.generations }; } /** Scores every genome of a generation; `best` is the fittest, the lowest index on a tie. */ private evaluate(genomes: readonly (readonly number[])[], generation: number): { best: number; fitness: number[] } { const fitness = genomes.map((genome) => this.score(genome)); const weights = genomes.map((genome) => this.problem.weight(genome)); let best = 0; fitness.forEach((value, index) => { if (value > fitness[best]!) best = index; }); // ▸ score every genome: its value, less the penalty for every unit of weight over the capacity; the fittest is the best this.tracer?.emit("evaluate", { best, fitness: fitness.slice(), generation, weights }); return { best, fitness }; } /** A tournament of two: two random genomes, the fitter wins, a tie goes to the lower index. */ private select(genomes: readonly (readonly number[])[], fitness: readonly number[], rng: Mulberry32): { winner: number; picks: number[] } { private select(genomes: readonly (readonly number[])[], fitness: readonly number[], random: Mulberry32): { winner: number; picks: number[] } { const picks = Array.from({ length: GA_TOURNAMENT_SIZE }, () => rng.index(genomes.length)); const picks = Array.from({ length: GA_TOURNAMENT_SIZE }, () => random.index(genomes.length)); let winner = picks[0]!; for (const pick of picks) { if (fitness[pick]! > fitness[winner]! || (fitness[pick] === fitness[winner] && pick < winner)) winner = pick; } return { winner, picks }; } private static text(bits: readonly number[]): string { return bits.join(""); }}The run ends with 01011011 at fitness 40 and weight 14: exactly the table's optimum. The dynamic programming table is the exact answer the search is measured against.