Tutorial 2: Defining your own Problem¶
Every benchmark handle (sezgi.bbob(...), sezgi.problems.cec2022(...),
...) is really just a Problem — a search space plus an objective
function. This tutorial writes two of your own: a classic multimodal
benchmark function (Rastrigin), and a small data-driven objective built
from a fixed dataset, the shape most real optimization problems actually
take.
The Problem ABC¶
Subclassing sezgi.Problem requires exactly two methods:
space()— returns asezgi.Space(...)or a bare block (sezgi.Float(...),sezgi.Int(...), ...) declaring the search space. Called once, when the instance is converted to a native handle — not re-evaluated per generation, so it must not depend on state that changes during a run.evaluate(x)—x -> float. For a single-block space (every example on this page),xis that block's own converted Python value: aFloatblock hands youlist[float], anIntblocklist[int], and so on (see Problem and spaces for the full conversion table and the multi-block/tuple case Tutorial 6 uses).
Two optional overrides: optimum() (default None) reports a known
global minimum, which is what populates SolveResult.gap; batch_evaluate
(default: loops evaluate) lets a vectorized objective process a whole
generation's population in one call.
Rastrigin: a known-optimum benchmark, pure stdlib¶
Rastrigin (f(x) = A*n + sum(x_i^2 - A*cos(2*pi*x_i)), A=10) is a
classic highly-multimodal function — many regularly-spaced local minima
around one global minimum at the origin. Unlike a compiled-in BBOB
function, its math is fully visible here, using nothing beyond math:
import math
import sezgi
class Rastrigin(sezgi.Problem):
A = 10.0
def __init__(self, n=5, lo=-5.12, hi=5.12):
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):
return self.A * len(x) + sum(
v * v - self.A * math.cos(2.0 * math.pi * v) for v in x)
def optimum(self):
return 0.0 # the global minimum is known exactly
result = sezgi.GreyWolfOptimizer(pop_size=30).run(Rastrigin(n=5), budget=3000, seed=42)
print(f"evals_used={result.evals_used} best_f={result.best_f:.6g} gap={result.gap:.6g}")
evals_used=3000 best_f=1.42109e-14 gap=1.42109e-14
optimum() returning 0.0 (rather than the base class's default None)
is what makes result.gap a real number instead of None — overriding it
is the whole difference between "a problem with no known answer" and "a
problem you can measure exact progress against". [-5.12, 5.12] is
Rastrigin's conventional per-coordinate domain, chosen so the
highly-multimodal region around the origin is fully inside the search box.
From data: fitting a linear model as a Problem¶
A problem built from a fixed dataset looks the same as any other Problem
— evaluate(x) just closes over the data instead of a closed-form
formula. This one treats the coefficients of a linear model as the search
point and the mean squared error on a fixed dataset as the objective — the
same shape a hyperparameter search or a model-fitting task takes in
practice.
import numpy as np
import sezgi
class LinearFit(sezgi.Problem):
"""Search over coefficient vectors w minimizing MSE(X @ w, y) on a
FIXED dataset (X, y) -- the search space's dimension is the number of
coefficients, not the dataset size."""
def __init__(self, X, y, lo=-5.0, hi=5.0):
self.X, self.y = X, y
self.n = X.shape[1]
self.lo, self.hi = lo, hi
def space(self):
return sezgi.Float(self.lo, self.hi, self.n)
def evaluate(self, x):
pred = self.X @ np.asarray(x)
return float(np.mean((pred - self.y) ** 2))
def batch_evaluate(self, xs):
"""Vectorized: scores the WHOLE population in one matrix
multiply instead of looping evaluate() once per individual --
called once per generation with xs = the current population."""
W = np.asarray(xs) # (pop_size, n)
preds = self.X @ W.T # (n_samples, pop_size)
errs = preds - self.y[:, None] # broadcast y over the population
return list(np.mean(errs ** 2, axis=0))
rng = np.random.default_rng(11)
X = rng.standard_normal((30, 3))
true_w = np.array([2.0, -1.0, 0.5])
y = X @ true_w + rng.normal(scale=0.1, size=30) # noisy linear target
problem = LinearFit(X, y)
result = sezgi.GeneticAlgorithm(pop_size=30).run(problem, budget=2000, seed=7)
print(f"true_w = {true_w.tolist()}")
print(f"best_x = {[round(v, 4) for v in result.best_x]}")
print(f"best_f (MSE) = {result.best_f:.6g}")
true_w = [2.0, -1.0, 0.5] best_x = [2.0276, -0.9475, 0.5029] best_f (MSE) = 0.00965205
batch_evaluate is called once per generation with the whole population
(pop_size points), not once per point — for LinearFit above this turns
pop_size separate X @ w matrix-vector products into one X @ W.T
matrix-matrix product, the same vectorization discipline a real
data-driven objective needs to stay fast. LinearFit has no optimum()
override, so result.gap is None here — the recovered best_x is
compared to true_w directly instead, since the noisy dataset's own true
minimum MSE is not exactly 0.0 and was never computed in closed form.
Next¶
- Writing an Algorithm subclass — author the SEARCH side instead of the problem side.
- Feature selection recipe — a specialized,
ready-made
Problemsubclass (sezgi.recipes.FeatureSelection) for exactly this "optimize against data" shape, with aBinarymask space instead ofLinearFit's ownFloatone.