Writing your own algorithm¶
Tutorial 2 writes a Problem
subclass; Tutorial 3 writes an
Algorithm subclass. This notebook does both together, step by step, on
a problem and a search operator neither tutorial uses — then checks the
result the same way every notebook in this set does: is it actually
deterministic, and how does it compare to a built-in?
Step 1: a Problem neither tutorial already used¶
Subclassing sezgi.Problem requires exactly two methods: space()
(the search space, built once) and evaluate(x) (x -> float). Ackley's
function is a classic multimodal benchmark — a nearly flat outer region
falling into a narrow, deeply pitted basin at the origin — defined with
nothing beyond math:
import math
import sezgi
class Ackley(sezgi.Problem):
'''f(x) = -20*exp(-0.2*sqrt(mean(x_i^2))) - exp(mean(cos(2*pi*x_i))) + 20 + e'''
def __init__(self, n=5, lo=-5.0, hi=5.0):
self.n, self.lo, self.hi = n, lo, hi
def space(self):
return sezgi.Float(self.lo, self.hi, self.n)
def evaluate(self, x):
n = len(x)
sum_sq = sum(v * v for v in x)
sum_cos = sum(math.cos(2.0 * math.pi * v) for v in x)
return (-20.0 * math.exp(-0.2 * math.sqrt(sum_sq / n))
- math.exp(sum_cos / n) + 20.0 + math.e)
def optimum(self):
return 0.0 # f(0, ..., 0) = 0 exactly
problem = Ackley(n=5)
print(f"evaluate at the origin: {problem.evaluate([0.0] * 5):.6g}")
print(f"evaluate at a corner: {problem.evaluate([5.0] * 5):.6g}")
evaluate at the origin: 4.44089e-16 evaluate at a corner: 12.6424
The origin evaluates to (numerically) zero, matching optimum(); a far
corner evaluates much higher — a quick sanity check that the objective is
wired up before handing it to any search.
Step 2: an Algorithm neither tutorial already used¶
sezgi.PopulationAlgorithm implements generate() for you from
tournament select() (kept as the default) plus a vary() method you
provide — the parent-pool-into-offspring step. Tutorial 4's own example
overrides vary() with a DE/rand/1-shaped update; this one instead does
arithmetic crossover — blend two randomly chosen parents by a random
weight, then add a small mutation step — a different operator family
entirely (no donor-vector-difference term anywhere):
class ArithmeticCrossover(sezgi.PopulationAlgorithm):
'''vary(): for each offspring slot, blend two random parents by a
random weight alpha, then perturb every coordinate by an independent
uniform step in [-mutation_step, mutation_step].'''
def __init__(self, mutation_step=0.1):
self.mutation_step = mutation_step
def vary(self, parents, ctx):
n = len(parents)
offspring = []
for _ in range(n):
i = ctx.rng.next_below(n)
j = ctx.rng.next_below(n)
alpha = ctx.rng.next_f64()
a, b = parents[i], parents[j]
offspring.append([
alpha * ai + (1.0 - alpha) * bi
+ (ctx.rng.next_f64() - 0.5) * 2.0 * self.mutation_step
for ai, bi in zip(a, b)
])
return offspring
custom_result = ArithmeticCrossover(mutation_step=0.1).run(
problem, budget=2000, seed=7, pop_size=20)
print(f"evals_used={custom_result.evals_used} "
f"best_f={custom_result.best_f:.6g} gap={custom_result.gap:.6g}")
evals_used=2000 best_f=2.32619 gap=2.32619
Everything else — population initialization, boundary repair, tournament
selection, replacement, termination — is the engine's own Rust code,
exactly as for a built-in algorithm; only vary() above is Python.
Step 3: determinism check¶
Same as every other notebook in this set: the same
(problem, budget, seed, pop_size) must reproduce the same run,
bit-for-bit, including for a Python-authored vary() — ctx.rng is the
engine's own seeded stream, so drawing from it (rather than, say, Python's
random module) is what keeps this deterministic.
custom_result_again = ArithmeticCrossover(mutation_step=0.1).run(
problem, budget=2000, seed=7, pop_size=20)
assert custom_result_again.best_x == custom_result.best_x
assert custom_result_again.best_f == custom_result.best_f
print("same seed twice: best_x, best_f bit-identical -- OK")
same seed twice: best_x, best_f bit-identical -- OK
Step 4: compare against a built-in¶
Same problem, same seed, same budget, same pop_size — the only
difference is which class produces the offspring:
builtin_result = sezgi.GeneticAlgorithm(pop_size=20).run(problem, budget=2000, seed=7)
print(f"ArithmeticCrossover (ours): best_f={custom_result.best_f:.6g} gap={custom_result.gap:.6g}")
print(f"GeneticAlgorithm (built-in): best_f={builtin_result.best_f:.6g} gap={builtin_result.gap:.6g}")
ArithmeticCrossover (ours): best_f=2.32619 gap=2.32619 GeneticAlgorithm (built-in): best_f=0.0347272 gap=0.0347272
One seeded run on one problem is an illustration of the mechanism, not a verdict on which operator is "better" — see Comparing algorithms fairly and notebook 3 for what a real comparison needs (several seeds, a paired test, an effect size).
Next¶
- Tutorial 3 and
Tutorial 4 — the same
hooks (
generate,vary,neighbor) covered in full prose. - Benchmarking and statistics — turning "one seeded run" into an honest multi-seed comparison.