What it actually does
Keep a crowd of candidate answers and let the better ones have more children. Score every candidate with the fitness function, pick parents with a bias toward the high scorers, cut two parents together to make a child that inherits pieces of each, flip a few bits at random so the crowd keeps trying things nobody had, and repeat. The whole method is one dial: how hard to lean on what already works (selection pressure, crossover) against how hard to keep looking (mutation, population size). Lean too hard and the crowd collapses into near-copies of one decent answer, which is premature convergence. Lean too softly and it is random search with extra steps. The reason to accept that cost is what the fitness function is allowed to be. It can be a simulator, a lab assay, a compiled binary, a human rating, a backtest. It can be discontinuous, noisy, or defined over schedules and subsets and graphs where there is no gradient to take. The price is sample efficiency: a gradient method on a differentiable objective will beat it by orders of magnitude, so the honest question is never "is a genetic algorithm good" but "have I run out of anything cheaper that applies".
- Fitness is cheap enough to afford on the order of N times G evaluations, which is thousands to millions; if each takes minutes, a sample-efficient method such as Bayesian optimization is the better choice
- Fitness has some locality: genomes that are close under the chosen distance (Hamming for bits, Euclidean for reals) tend to score similarly. Without it the landscape is random and so is the search
- For crossover to help, good partial solutions must recombine: short groups of genes contribute fitness fairly independently, which is the building-block assumption and is a property of the problem, not of the algorithm
- Constraints can be expressed through the encoding, a repair step, or the fitness, in a way that does not distort what is being optimized
- The fitness function is stationary within a run, or its noise is handled explicitly, since elitism and selection assume a score means the same thing in generation 5 as in generation 500
- A good answer, not a proven-optimal one, is acceptable, because the method returns the best candidate found and no certificate
Fitness function
Find the candidate with the highest score, when the only access to the score is by trying a candidate. Everything distinctive follows from that sentence. There is no loss surface to descend, so there is no learning rate, no backpropagation and no notion of a derivative; there is only a ranking, and rank-based selection such as a tournament does not even use the magnitude of f, so any monotone rescaling of the fitness leaves the run unchanged. That is also why the objective can be defined on a space with no geometry at all, such as the set of all schedules. The cost of that generality is a hard limit on what can be claimed: the output is the best candidate seen so far and a trace of how the search behaved, never a bound on how far it is from the optimum. If the problem is linear with linear constraints, a solver returns the optimum and a proof of it, and this method returns neither. Constraints are the second thing the formula hides. Here they are folded into f, by a penalty, a repair step, or a feasibility-first comparison, and the way that is done changes what the search converges to far more than the crossover operator does.
- the fitness: a black box returning one number per candidate. It may be noisy, discontinuous, expensive, or the output of a simulator
- the search space, which is the representation choice: bit strings, real vectors, or permutations (S_n) for ordering problems
- genome length for bit strings, dimension for real vectors, and number of items for permutations
- the best candidate found. Not a certified optimum: nothing in the procedure proves that no better one exists
- the gradient, which is exactly what this method does not need and exactly what you should use if it exists
How it is minimized
Population-based stochastic search: select parents by fitness, recombine, mutate, replace, and carry the best few forward unchanged (elitism)
- the population at generation t: N candidate genomes
- pick parents with a bias toward high fitness. Tournament selection draws s candidates at random and keeps the best; roulette selection picks in proportion to fitness
- with probability p_c, splice two parents at a random cut point (one-point crossover); otherwise copy a parent
- flip each bit independently with probability p_m, conventionally 1/L, so that about one bit changes per child
- the k best candidates copied to the next generation unmodified, so the best score found can never go down
- a schema, meaning a pattern of fixed positions with wildcards elsewhere, and the number of population members matching it at generation t
- the schema average fitness relative to the population average, which is the selection pressure acting on that pattern
- the defining length (distance between outermost fixed positions) and order (number of fixed positions): the two quantities that decide how likely crossover and mutation are to break the pattern
The second line is the schema theorem, read informally: a pattern of bits that is short, involves few fixed positions, and scores above the population average receives an exponentially growing share of the trials, because selection amplifies it by the fitness ratio while crossover and mutation only occasionally cut through it. That is the argument for why recombination can do something random restarts cannot, namely assemble good partial solutions found in different individuals into one. It is also much weaker than its reputation. It is an inequality about the next generation only, it says nothing about how new schemata are created, and the building-block hypothesis built on it, that real problems decompose into short independent blocks, is an assumption about the problem rather than a result about the algorithm. When the blocks are not independent (strong epistasis) crossover mostly destroys what selection built, and a mutation-only method such as a (1+1) evolution strategy or simulated annealing often wins. The practical reading is then a design rule: choose a genome encoding in which nearby bits interact and distant bits do not, and expect crossover to pay off only to that degree. Selection pressure and mutation rate are the exploration dial. Tournament size s sets how fast a good candidate takes over the population, roughly ln(N)/ln(s) generations. A mutation rate near 1/L per bit keeps about one change per child, which is the standard balance between local refinement and escape; a rate much above that is a random walk, and one well below it leaves the population unable to leave the basin it is in. Similarity belongs in this design too. Hamming distance, the count of differing bits, is the natural metric on bit-string genomes and it is what diversity-preserving variants use: fitness sharing divides each candidate fitness by the number of neighbours inside a niche radius, and crowding makes a child replace the most similar parent instead of the worst member. Swapping the metric breaks that silently. Hamming on a plain binary encoding of an integer has cliffs (7 and 8 differ in four bits though they are adjacent), so Gray-code the integer or the niches mean nothing. Euclidean distance is the right one for real-valued genomes, but only after each gene is scaled to a comparable range, or one wide-range gene decides every niche. And neither is meaningful for permutations, where a position-based or ordering distance such as Kendall tau is the matching choice.
population size N
Parallel hypotheses held at once. More diversity and slower convergence per generation; scales with how rugged the landscape is more than with genome length (50 to 1,000)
generations G
Search budget. Total evaluations are about N times G, and that product is the real cost knob (100 to 10,000, or stop on stagnation)
tournament size s
Selection pressure. s of 2 is gentle; large s takes over the population quickly and invites premature convergence (2 to 7)
crossover rate p_c
How often two parents are spliced rather than one being copied. Worth lowering when the encoding has strong interactions between distant genes (0.6 to 0.95)
mutation rate p_m
Per-gene change probability, the main source of new material. 1/L means about one flip per child; for real-valued genomes the equivalent is the step size sigma, which strong methods adapt online (0.5/L to 3/L per bit)
elite count k
Candidates carried over unchanged, which guarantees monotone best-so-far. Too many reduce the population to its elite (1 to 5 percent of N)
niche radius sigma_share
Hamming (or scaled Euclidean) distance inside which candidates share fitness, in the diversity-preserving variant. Too small does nothing; too large merges real optima into one niche (a few percent of L, tuned on diversity curves)
random seed and restarts
The run is a random variable. Seed it for reproducibility, and judge the method by its distribution over many seeds, never by one run (20 or more seeds per configuration)
There is no finite-time guarantee, and being precise about what is guaranteed matters. With elitism and a mutation operator that can reach any genome, the best score converges to the global optimum in probability as generations grow without bound, but random search satisfies the same statement, so it distinguishes nothing. What is known is mostly problem-specific runtime analysis: a (1+1) evolutionary algorithm solves OneMax in expected time of order L log L evaluations, and that sort of result does not extend to general landscapes. The No Free Lunch theorems apply in full: averaged over all possible fitness functions no search strategy beats another, so a genetic algorithm helps exactly to the degree that nearby candidates have correlated fitness and good partial solutions recombine, and a problem with neither is random search. The failures are specific and observable. Premature convergence is the diversity collapsing: the population fills with near-copies, mean pairwise Hamming distance falls toward zero, and further generations only polish one basin. A deceptive landscape, where the short patterns that look good lead away from the optimum, defeats the schema argument by construction. Epistasis makes crossover destructive. Noisy fitness lets a lucky evaluation of a mediocre candidate win a tournament and, once elitism has frozen it, stick forever unless elites are re-evaluated. And a penalty weight set too low converges to an infeasible optimum while one set too high produces a population that never crosses the feasibility boundary where the good solutions live.
O(G * N * (L + C_f)) time, where C_f is the cost of one fitness evaluation, and in practice C_f dominates everything: for a simulator or a model-training run the operators are free and the whole cost is evaluations. Memory is O(N * L) for the population, plus the cached scores. The embarrassingly parallel structure is the main practical lever, since the N evaluations in a generation are independent, but the generations themselves are strictly sequential. Cost scales with how many evaluations you can afford rather than with problem size, which is a real advantage when f is cheap and a real disqualifier when each evaluation takes minutes.
How, where, and why it is used
Time-Series Forecasting
AdaptedIt does not forecast. It searches the space of forecasting pipelines. Encode a configuration as a genome: which lags enter (a bit mask), the differencing order, the seasonal period, the regularization strength, the tree depth, the ensemble weights. Define fitness as the negative error of a rolling-origin backtest of that configuration, and let the population search the mixed discrete-and-continuous space. The forecast itself is still made by the model the search selects.
- Lag and feature subset selection for a gradient-boosted or linear forecaster, where the mask is a bit string and the objective is a backtest score
- Hyperparameter search for a forecasting pipeline whose space mixes categorical choices (model family, seasonality handling) with continuous ones
- Choosing ensemble weights or a combination of base forecasters under a non-differentiable metric such as pinball loss at several quantiles or a business cost
- Calibrating the parameters of a simulation-based demand model where the likelihood is intractable and only simulated-versus-observed error is available
A reasonable choice when the configuration space is mixed and structured and the metric is not differentiable, and an honest second choice when it is not. Each fitness evaluation here is a full backtest, which is expensive, and for a few continuous hyperparameters Bayesian optimization or even plain random search reaches a comparable result in far fewer evaluations. The genetic algorithm earns its place on the combinatorial part: subset selection over dozens of lags and exogenous features, where the space is too large to enumerate and recombining two good masks is a sensible move. Treat it as a way to pick the configuration, never as part of the forecaster, and be wary of how it can overfit the selection itself.
- Encode the configuration so that nearby genomes are similar configurations: a bit mask for lags, a Gray-coded or real-valued gene for ordered hyperparameters
- Make fitness a rolling-origin backtest error scaled against a seasonal-naive baseline (MASE), so scores are comparable across series
- Cache the score of every distinct genome, since the same mask recurs and a backtest is the expensive step
- Hold out a final period that the search never scores, to measure the selection effect honestly
Measure the selected configuration on a final held-out window that the search never saw, against a well-tuned random-search baseline given the same number of backtests. The comparison that matters is score at equal evaluation budget; a genetic algorithm that merely matches random search at the same cost has not justified its machinery.
- Selection overfits the backtest: the winning configuration is the luckiest of thousands scored on the same folds, so its in-search score is optimistically biased (the same winner curse as stepwise selection)
- Look-ahead leakage inside the fitness function silently rewards configurations that peek at the future, and the search is very good at finding exactly that
- Stochastic model training makes fitness noisy, so a lucky run of a mediocre configuration is promoted and frozen by elitism unless elites are re-scored
- Running a backtest per candidate per generation without caching, which spends most of the budget re-evaluating genomes already seen
Anomaly Detection
Not applicableIt searches for a decision that maximizes a score and defines no notion of normal behaviour or of an unusual observation, so there is nothing to detect with; it can tune a detector hyperparameters, but that is optimization applied to a detector rather than detection.
Optimization
Primary fitTreat the problem as a black box f over a candidate representation and search it with a population. Choose a genome that makes feasible solutions easy to represent (a bit string for subsets, a permutation for orderings, a real vector for continuous parameters), a fitness that folds constraints in by penalty, repair or feasibility-first comparison, and variation operators that respect the structure (order crossover for permutations, not one-point). The output is the best candidate found plus the convergence and diversity traces that say whether to trust it.
- Hyperparameter and neural architecture search, where each evaluation is a training run and the space mixes discrete structure with continuous settings
- Scheduling and timetabling (shifts, exams, job-shop sequences), where constraints are numerous, messy and awkward to write as a linear program
- Engineering and design optimization against a simulator, such as antenna shapes, airfoils or layout, where f is a physics solver and no gradient exists
- Feature and subset selection, where the genome is a mask and fitness is a cross-validated score
- Routing and packing heuristics (travelling salesman, bin packing) at sizes where exact solvers stall, often as a metaheuristic wrapped around local search
The right tool when the objective cannot be differentiated, cannot be written down in closed form, and is too irregular for a convex or mixed-integer formulation, and the wrong tool in almost every other case. The order of preference matters. If f is differentiable, use gradients, which are orders of magnitude more sample-efficient. If it is linear with linear constraints, use linear or mixed-integer programming, which is exact and returns an optimality certificate that this method cannot give. If it is continuous, smooth enough and low-dimensional, CMA-ES adapts its own step size and covariance and usually beats a hand-built genetic algorithm. If each evaluation is expensive and the dimension is modest, Bayesian optimization spends evaluations far more carefully. Simulated annealing and random search are the honest siblings in stochastic search: they have no population and no crossover, they are simpler, and on a surprising number of problems they match a genetic algorithm. What a genetic algorithm uniquely offers is recombination, so it deserves the job only when good partial solutions genuinely combine, and when a population of diverse answers (several different good designs, not one) is itself useful.
- Choose the encoding so that distance between genomes tracks distance between solutions; Hamming distance on bits, scaled Euclidean on reals, a position or order distance on permutations
- Gray-code integers that are stored as bits, or Hamming-1 neighbours will not be adjacent in value
- Handle constraints deliberately: a repair step that always returns a feasible solution, or a feasibility-first comparison rule, is usually sturdier than a hand-tuned penalty weight
- Normalise the fitness scale if using roulette selection, since proportional selection is sensitive to offsets that tournament selection ignores
- Cache evaluations by genome hash; duplicates are common once the population starts to converge
Report the distribution of best-so-far fitness against number of fitness evaluations across at least twenty seeds, with a median and an interquartile band, and compare it with random search and simulated annealing at the same evaluation budget. A single lucky run proves nothing. Track mean pairwise Hamming distance each generation to diagnose premature convergence directly, and when an exact method is applicable, measure the optimality gap against its certified bound.
- Using it where a gradient, a linear program or a convex solver applies, which wastes orders of magnitude in evaluations and gives up the optimality proof
- Reporting the best of many runs as though it were the expected result, which hides the variance that is the main property of the method
- Premature convergence mistaken for convergence: the best score stops rising because the population stopped exploring, not because the optimum was reached
- A penalty weight tuned until the demo works, which then fails on the next instance; this is the usual source of an infeasible final answer
- Applying one-point crossover to a permutation, which produces duplicate and missing items and needs a repair step the code did not have
Control & Operations
ViableScheduling, routing, rostering and packing problems are search over discrete arrangements with many awkward rules. Encode an arrangement as a genome (a permutation of jobs, an assignment vector), define fitness as total cost with penalties for violated rules, and let the population search. In practice it is most often the outer loop of a hybrid, with a fast local-search or constructive heuristic polishing each child (a memetic algorithm).
- Job-shop and flow-shop sequencing, where a permutation encodes the order and a decoder builds the schedule
- Staff rostering and exam timetabling with a long list of soft preferences and hard rules
- Vehicle routing and delivery-sequence heuristics at sizes beyond exact solvers
- Layout and bin-packing heuristics where the evaluation is a placement simulator
Viable rather than primary, and the reason is the competition. Where the problem has linear structure, a mixed-integer solver with a modern branch-and-cut engine returns a provably optimal or bounded answer and routinely handles instances that practitioners assume need a heuristic. Constraint programming is the other strong alternative for rule-heavy scheduling. A genetic algorithm earns its keep when the rules do not fit those formalisms (a simulator evaluates the schedule), when a good-enough answer within a time budget is acceptable, or when a warm start from the current schedule matters, since a population can be seeded with the incumbent plus perturbations. It is the pragmatic choice, not the principled one, and the optimality gap it leaves is unknown unless you also solve a relaxation.
- Use a representation plus a decoder that always yields a feasible schedule, so the search never wastes evaluations on infeasible ones
- Pair permutation genomes with order-preserving crossover (order crossover, partially mapped crossover) rather than one-point cuts
- Seed the initial population with the incumbent schedule and perturbations of it, plus a few random immigrants to keep diversity
- Separate hard constraints (repair or reject) from soft preferences (weighted cost)
Compare cost against the incumbent heuristic and, where tractable, against the bound from a mixed-integer relaxation, so that the gap is a number rather than a feeling. Re-run on perturbed instances to measure robustness, and report time-to-target alongside final cost, since the time budget is usually the binding constraint.
- Treating the best schedule found as optimal when no bound was ever computed
- A decoder that is slow, which makes it the whole cost; the search is only as fast as one fitness evaluation
- Over-tuned penalty weights that encode this week data and break when demand changes
- Solving a problem a mixed-integer solver would have settled exactly, in a fraction of the engineering time
What it costs to run
There is no training, and the cost is entirely evaluations: roughly population size times generations times the cost of one fitness evaluation. When fitness is a few arithmetic operations (a knapsack score) a search of a million evaluations is seconds. When it is a model-training run or a simulator, the same count is days and the operators are free by comparison, so the parallelism of evaluating a generation at once, across cores or machines, is the main lever. Illustrative cost profile, not a measured benchmark.
The output is a solution, not a model, so there is no inference step, and serving the result is free. The recurring cost is re-running the search whenever the instance changes. For repeated decisions (daily rosters, hourly schedules), warm-starting from the previous solution and bounding the run by a wall-clock budget rather than a generation count is what makes it deployable.
Re-run whenever the problem instance changes: new orders, new constraints, new costs. This is an event, not a drift. Seed the new population with the previous best and perturbations of it, plus a fraction of random immigrants, so the search starts from a good answer without being trapped in the old basin.
- Mean pairwise Hamming distance (or scaled Euclidean distance for real genomes) per generation, since a collapse toward zero is premature convergence caught early
- Best-versus-mean fitness gap per generation, which measures remaining selection pressure
- Best-so-far fitness against evaluation count across seeds, summarised as median and interquartile band, to detect a regression in search quality after any change
- Fraction of feasible candidates in the population, which exposes a penalty weight drifting away from the constraint boundary
- Evaluation cache hit rate, a free indicator of how much of the budget is spent on genomes already seen
- Seed every run and thread the generator explicitly; with a global random source or a thread-dependent stream, the same input produces a different answer and no regression can be diagnosed
- In a parallel run, derive each child random stream from (seed, generation, child index) instead of sharing one generator, or the result depends on thread scheduling
- Noisy fitness plus elitism freezes a lucky evaluation: re-evaluate elites each generation, or average several evaluations, when the function is stochastic
- Quote the distribution over seeds, not the best run, or the headline number will not reproduce
- Penalty-based constraint handling is a hidden hyperparameter that interacts with the instance scale, so prefer repair or feasibility-first comparison where possible
- Evaluate in batches and cache by genome hash; duplicates become the majority of the population near convergence
- A wall-clock budget needs a stopping rule that tolerates an unfinished generation, and the result must still be a valid, feasible candidate
Pros and cons, in context
Needs only the ability to score a candidate: no gradient, no convexity, no closed form
The decisive advantage when f is a simulator, a physical experiment, or defined on schedules, subsets and graphs. Worthless when f is differentiable, since gradient methods then get the same answer in far fewer evaluations.
Handles discrete, combinatorial and mixed search spaces natively
Feature masks, orderings and architecture choices are the genome itself, with no relaxation step. The catch is that the encoding and operators carry the real intelligence, and a poor encoding makes it slower than random search.
Returns a population of diverse good answers, not one
Useful when several distinct designs are wanted (trade-offs along a Pareto front with a multi-objective variant), or when the best answer may be infeasible for reasons the fitness did not capture. Irrelevant when only the single best value counts.
Embarrassingly parallel across the population within a generation
Evaluations are independent, so throughput scales with cores or machines when fitness is expensive. It does nothing for the sequential dependence between generations, so wall-clock speedup caps at the population size.
Tolerates noise, discontinuity and plateaus that break local methods
Rank-based selection ignores the magnitude of f, so a few outliers do not dominate. Noise still needs explicit handling, because elitism will freeze a lucky evaluation.
Sample-inefficient: spends thousands of evaluations to learn what a gradient reads off in one
Dominant cost when each evaluation is a training run. If f is differentiable, gradient methods are orders of magnitude cheaper; if evaluations are expensive and the dimension is modest, Bayesian optimization is the better deal.
No optimality guarantee and no bound on how far the answer is from the best
A mixed-integer or linear solver returns a certificate; this returns a candidate and a convergence plot. Acceptable for engineering heuristics, unacceptable where a proof of optimality or a worst-case gap is required.
Premature convergence: the population collapses into near-copies of one decent answer
Caused by selection pressure outrunning mutation. Visible as mean Hamming distance falling toward zero while best fitness plateaus. Mitigated with fitness sharing, crowding, a lower tournament size, or restarts, and each mitigation costs speed.
Many interacting hyperparameters, and results depend on them and on the random seed
Population size, mutation rate, selection pressure and the penalty weight interact, and a single run is a random sample. Judge it by a distribution over seeds; a one-off demo that works is the usual source of disappointment.
Often matched by simpler stochastic search
Random search is a strong baseline for hyperparameters, and simulated annealing or a (1+1) evolution strategy needs no crossover and often ties on rugged landscapes. The population machinery earns its cost only when recombination of building blocks actually helps.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""A genetic algorithm on 0/1 knapsack - every operator written out.
Population, fitness, tournament selection, one-point crossover, bit-flip
mutation, elitism. The only thing the search ever asks of the problem is
fitness(genome) -> number: no gradient, no structure, just a ranking.
"""
import random
def fitness(genome, values, weights, capacity):
total_value = 0
total_weight = 0
for j in range(len(genome)):
if genome[j] == 1:
total_value += values[j]
total_weight += weights[j]
if total_weight > capacity:
# infeasible: rank by how far over the limit, so any feasible genome wins
return -(total_weight - capacity)
return total_value
def hamming(a, b):
return sum(1 for j in range(len(a)) if a[j] != b[j])
def diversity(population):
"""Mean pairwise Hamming distance: it collapsing is premature convergence."""
total = 0
pairs = 0
for i in range(len(population)):
for k in range(i + 1, len(population)):
total += hamming(population[i], population[k])
pairs += 1
return total / pairs
def tournament(population, scores, size):
best = random.randrange(len(population))
for _ in range(size - 1):
challenger = random.randrange(len(population))
if scores[challenger] > scores[best]:
best = challenger
return population[best]
def evolve(values, weights, capacity, pop_size=60, generations=200,
tournament_size=3, crossover_rate=0.9, elite=2):
length = len(values)
mutation_rate = 1.0 / length # about one flipped bit per child
population = [[random.randint(0, 1) for _ in range(length)]
for _ in range(pop_size)]
history = []
for generation in range(generations):
scores = [fitness(g, values, weights, capacity) for g in population]
history.append((max(scores), diversity(population)))
# elitism: the best genomes pass to the next generation unchanged
ranked = sorted(range(pop_size), key=lambda i: scores[i], reverse=True)
next_population = [population[i][:] for i in ranked[:elite]]
while len(next_population) < pop_size:
mother = tournament(population, scores, tournament_size)
father = tournament(population, scores, tournament_size)
if random.random() < crossover_rate:
cut = random.randint(1, length - 1) # one-point crossover
child = mother[:cut] + father[cut:]
else:
child = mother[:]
for j in range(length):
if random.random() < mutation_rate:
child[j] = 1 - child[j] # bit flip
next_population.append(child)
population = next_population
scores = [fitness(g, values, weights, capacity) for g in population]
best = max(range(pop_size), key=lambda i: scores[i])
return population[best], scores[best], historyO(G * N * (L + tournament)) in pure Python, plus an O(N^2 * L) diversity measure every generation that dominates for large N.
How this language actually wants it written.
"""Genetic algorithm - typed, seeded, validated, bit-identical on rerun."""
from dataclasses import dataclass
from typing import Callable
import numpy as np
from numpy.typing import NDArray
Genome = NDArray[np.bool_] # shape (L,)
Population = NDArray[np.bool_] # shape (N, L)
Fitness = Callable[[Genome], float] # the only thing the problem supplies
@dataclass(frozen=True)
class GAConfig:
pop_size: int = 100
generations: int = 200
tournament_size: int = 3
crossover_rate: float = 0.9
mutation_rate: float | None = None # None means 1/L
elite: int = 2
seed: int = 0
def __post_init__(self) -> None:
if self.pop_size < 2:
raise ValueError(f"pop_size must be at least 2, got {self.pop_size}")
if not 0 <= self.elite < self.pop_size:
raise ValueError(f"elite must be in [0, pop_size), got {self.elite}")
if self.tournament_size < 1:
raise ValueError("tournament_size must be at least 1")
for name in ("crossover_rate", "mutation_rate"):
rate = getattr(self, name)
if rate is not None and not 0.0 <= rate <= 1.0:
raise ValueError(f"{name} must be in [0, 1], got {rate}")
@dataclass(frozen=True)
class GAResult:
best_genome: Genome
best_fitness: float
best_history: NDArray[np.float64]
diversity_history: NDArray[np.float64]
evaluations: int
def mean_pairwise_hamming(population: Population) -> float:
"""Mean Hamming distance over all pairs in O(N*L), not O(N^2 * L).
Per locus, k ones and (N - k) zeros contribute k * (N - k) differing pairs.
"""
n = population.shape[0]
ones = population.sum(axis=0, dtype=np.int64)
return float((ones * (n - ones)).sum()) / (n * (n - 1) / 2)
def _tournament(scores: NDArray[np.float64], size: int, count: int,
rng: np.random.Generator) -> NDArray[np.intp]:
contenders = rng.integers(0, scores.size, size=(count, size))
best_slot = scores[contenders].argmax(axis=1)
return contenders[np.arange(count), best_slot]
def _crossover(mothers: Population, fathers: Population, rate: float,
rng: np.random.Generator) -> Population:
count, length = mothers.shape
cut = rng.integers(1, length, size=(count, 1))
children = np.where(np.arange(length) < cut, mothers, fathers)
keep_mother = rng.random(count) >= rate
children[keep_mother] = mothers[keep_mother]
return children
def evolve(fitness: Fitness, genome_length: int, config: GAConfig) -> GAResult:
"""Maximise fitness over bit strings. Same config and seed, same answer."""
if genome_length < 2:
raise ValueError(f"genome_length must be at least 2, got {genome_length}")
rng = np.random.default_rng(config.seed) # explicit stream, never global
rate = config.mutation_rate if config.mutation_rate is not None else 1.0 / genome_length
n_children = config.pop_size - config.elite
population: Population = rng.random((config.pop_size, genome_length)) < 0.5
scores = np.array([fitness(genome) for genome in population], dtype=np.float64)
evaluations = config.pop_size
best_history = np.empty(config.generations)
diversity_history = np.empty(config.generations)
for generation in range(config.generations):
best_history[generation] = scores.max()
diversity_history[generation] = mean_pairwise_hamming(population)
elite_rows = np.argsort(scores)[::-1][: config.elite]
mothers = population[_tournament(scores, config.tournament_size, n_children, rng)]
fathers = population[_tournament(scores, config.tournament_size, n_children, rng)]
children = _crossover(mothers, fathers, config.crossover_rate, rng)
children ^= rng.random(children.shape) < rate # bit-flip mutation
# Elite scores are carried over rather than recomputed. That is only
# sound for a deterministic fitness; re-score elites if it is noisy.
child_scores = np.array([fitness(genome) for genome in children])
evaluations += n_children
population = np.concatenate([population[elite_rows], children])
scores = np.concatenate([scores[elite_rows], child_scores])
best = int(scores.argmax())
return GAResult(population[best].copy(), float(scores[best]),
best_history, diversity_history, evaluations)What changed: Four changes, none of them about the operators. Randomness comes from one explicit numpy Generator seeded from the config, instead of the global random module, so a run is reproducible and two runs cannot interfere. Configuration is a frozen, validated dataclass, so a bad mutation rate fails at construction instead of producing a quietly wrong search. The per-child loops become array operations: tournament, crossover and mutation each act on the whole generation at once. And diversity is measured in O(N*L) from per-locus bit counts rather than O(N^2*L) from all pairs, which makes it cheap enough to record every generation.
O(G * N * L) for the operators, vectorised; fitness is still called once per child from Python.
Faster, and the justification for each change.
"""Genetic algorithm - whole-generation array ops, preallocated, one pass per score."""
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float32]
def knapsack_scores(population: Matrix, value_weight: Matrix, capacity: float) -> NDArray[np.float32]:
"""Score every genome in one matrix product.
value_weight is (L, 2): column 0 values, column 1 weights. One gemm reads
the population once and yields both totals for every genome.
"""
totals = population @ value_weight
overweight = np.maximum(totals[:, 1] - capacity, 0.0)
return np.where(overweight > 0.0, -overweight, totals[:, 0])
def evolve_fast(values: Matrix, weights: Matrix, capacity: float, *, pop_size: int,
generations: int, tournament_size: int, crossover_rate: float,
elite: int, seed: int) -> tuple[NDArray[np.float32], float]:
rng = np.random.default_rng(seed)
length = values.size
n_children = pop_size - elite
rate = 1.0 / length
value_weight = np.ascontiguousarray(np.stack([values, weights], axis=1), dtype=np.float32)
columns = np.arange(length)[None, :]
# float32 0/1 genomes: the matmul needs no cast copy on every generation.
current = (rng.random((pop_size, length), dtype=np.float32) < 0.5).astype(np.float32)
following = np.empty_like(current)
scores = knapsack_scores(current, value_weight, capacity)
for _ in range(generations):
top = np.argpartition(scores, -elite)[-elite:] # O(N), not a full sort
elite_scores = scores[top].copy()
following[:elite] = current[top]
# one RNG call draws every contender for both parents of every child
contenders = rng.integers(0, pop_size, size=(2, n_children, tournament_size))
winners = np.take_along_axis(
contenders, scores[contenders].argmax(axis=2)[..., None], axis=2
)[..., 0]
children = following[elite:] # a view, not a copy
children[...] = current[winners[1]] # father everywhere
cut = rng.integers(1, length, size=(n_children, 1))
np.copyto(children, current[winners[0]], where=columns < cut) # mother's prefix
np.copyto(children, current[winners[0]],
where=(rng.random(n_children) >= crossover_rate)[:, None]) # no crossover
flips = rng.random(children.shape, dtype=np.float32) < rate
np.subtract(1.0, children, out=children, where=flips) # in-place bit flip
scores[elite:] = knapsack_scores(children, value_weight, capacity)
scores[:elite] = elite_scores
current, following = following, current # swap buffers, no allocation
best = int(scores.argmax())
return current[best].astype(np.uint8), float(scores[best])What changed: The fitness function stops being a per-genome Python callable and becomes one matrix product over the whole population, so scoring a generation costs a single BLAS call. That is the largest change and also the main limit: it only applies when fitness can be written as an array expression. The rest removes allocation and interpreter overhead. Both populations are allocated once and swapped, crossover and mutation write into the next generation in place, and the elite are picked with a partial selection instead of a sort. Where fitness is a black-box simulator that cannot be batched this way, the equivalent move is to evaluate the generation with a process pool, because the evaluations are independent.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Stacking values and weights into an (L, 2) matrix turns scoring the whole population into one gemm that reads the genomes once and returns both totals for every candidate.
Trade-off: Only works when fitness is expressible as an array operation. A simulator, a model-training run or any branching logic cannot be batched this way, and forcing it to is a rewrite of the problem, not an optimization.
Pre-allocate output arrays and use in-place operations
The current and next populations are allocated once and swapped each generation, and crossover and mutation write into the next buffer with copyto and subtract(out=...), so no population-sized array is created in the loop.
Trade-off: In-place writes into views make aliasing bugs easy (a write into the next buffer that silently reads the current one), and the code is harder to read and to test than the functional version.
Batch work to amortize interpreter overhead
One integers() call draws every tournament contender for both parents of every child, and one random() call decides every mutation, so the interpreter is entered a fixed number of times per generation instead of once per child.
Trade-off: Large temporaries (n_children by tournament_size by 2 indices, and an N by L random matrix) raise peak memory, which can matter for a long genome and a large population.
Ensure contiguous memory layout and a single dtype
Genomes are stored as float32 zeros and ones in one C-contiguous array, so the matmul needs no per-generation cast copy of a bool array.
Trade-off: Uses 4 bytes per gene where a bit would do (a 32-fold memory cost over bit packing), and float32 sums are exact only for integer totals below 2^24, so real-valued fitness can lose precision.
O(G * N * L) per run with one BLAS call per generation for fitness. Illustrative, not a measured benchmark.