Writing a genetic algorithm
Inherit an algorithm, supply a fitness, run it by Path — the knapsack from start to answer.
An evolutionary algorithm in Hilbert is two declarations: a category in the Prelude that states the algorithm once, and a process that names it as its parent and says what the problem is. Nothing about the algorithm is written in C#, and nothing about the problem is written in the category.
1. State the problem as a fitness
A knapsack choice is a bit per item. A whole population is a matrix \(P \in \{0,1\}^{n \times g}\), so the value of every choice at once is one contraction, and an overweight choice is fined the total value per unit over:
-- Knapsack.hb
open Prelude.Evolution
process Knapsack(values, weights, capacity, size, genes, seed) : BitGenetic {
def packed(P) ≔ einsum("pi,i->p", P, values)
def load(P) ≔ einsum("pi,i->p", P, weights)
def fitness(P) ≔ packed(P) − einsum("i->", values) · max(load(P) − capacity, 0)
}
Everything maximises — to minimise a cost, negate it, as Evolution’s Ellipsoid,
Rosenbrock and Rastrigin samples all write fitness(P) ≔ 0 − cost(P). The parameters size, genes and
seed are the ones Genetic reads; forget one and the build says
'Knapsack' is Genetic and has no 'size'; add it to the parameter list at the process line.
2. Run it
The build generates one class, Knapsack, flattened from the category and the process. Construct it and
advance it:
var knapsack = new Knapsack(values, weights, 80d, size: 40d, genes: 10d, seed: 20260915d);
knapsack.Path(40L); // forty generations; the path is the population at each one
var answer = knapsack.Best().Values; // [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
var worth = knapsack.BestFitness().Values[0]; // 390, for this seed at generation 24 of the 40
values and weights are Tensors (item worths and weights); capacity is a scalar Tensor, which a plain
80d converts to. Two objects built from one seed evolve one population: every draw is fresh, renewed before
each generation from a generator seeded once, so Path is a pure function of the seed and the generation count
— this is the exact case EvolutionCase.Knapsack_from_seed_20260915_reaches_its_brute_forced_optimum asserts,
against the ten items and the capacity of 80 that make 390 the only optimum brute force finds.
3. Change an operator
A child replaces anything its category defines by redefining it with the same parameters:
process Knapsack(values, weights, capacity, size, genes, seed) : BitGenetic {
def cross(a, b, k) ≔ twoPoint(a, b, cuts)
def elites ≔ 2
def fitness(P) ≔ einsum("pi,i->p", P, values) − einsum("i->", values) · max(einsum("pi,i->p", P, weights) − capacity, 0)
}
Changing the number of parameters is refused, naming the category that actually declares it — cross is
BitGenetic’s, not Genetic’s own: 'Knapsack' overrides cross with 2 parameters; BitGenetic declares it with 3.
4. Pick another algorithm
| Your genome | Category | Supply |
|---|---|---|
| bits | BitGenetic |
fitness(P) |
| reals in a box | RealGenetic, Differential, Swarm, Strategy, Covariance |
fitness(P) |
| whole numbers | IntegerGenetic |
fitness(P) |
| a permutation | PermutationGenetic |
fitness(P) |
| several objectives | Pareto |
objectives(P) |
| a tour over a distance matrix | Colony |
nothing |
Ants.hb in the sample project is process Ants(size, cities, distances, seed) : Colony { } — an empty body,
since the colony needs no problem-specific definition at all.
The Genetics operators also work in any process of your own, without a category —
Seeker.hb in the sample project evolves one-max that way, with its own draws declared once and read a
generation at a time by generationOf. A spawn draw must never be the same draw a step reads: Seeker
gives the starting population its own draw, born, separate from mix — sharing one would cross the first
population by the very uniforms that just made it.