Performance and Advanced Usage#

This page continues the Genetic Algorithms walkthrough and covers evaluating the objective function faster, choosing among the built-in representations, and posing objectives symbolically.

Vectorized and parallel evaluation#

By default, evaluate calls the objective function once per individual in a plain Python loop. If your objective function can consume the whole (nPop, dim) array of phenotypes at once (e.g. because it’s written with NumPy broadcasting), pass vectorized=True to avoid the per-individual Python-level call overhead:

from sigmaepsilon.math.optimize import BinaryGeneticAlgorithm as BGA

ranges = [[-2, 2], [-1, 3]]
def Rosenbrock_vectorized(x, a=1, b=100):
    return (a - x[:, 0]) ** 2 + b * (x[:, 1] - x[:, 0] ** 2) ** 2


bga = BGA(
    Rosenbrock_vectorized, ranges, length=12, nPop=100, minimize=True, seed=0, vectorized=True
)
result = bga.solve()
result.phenotype, result.fitness

Alternatively, if the objective function is expensive and must still be called once per individual, set n_jobs to a value other than 1 to evaluate the population in parallel worker processes (-1 uses all available CPUs). This requires the objective function to be picklable (a module-level function, not a lambda or a notebook-local closure) so that a worker process can reconstruct it.

In a regular script this just works, since Python re-imports the script as __main__ in each worker and finds the function there (guard the entry point with if __name__ == "__main__":). Inside a Jupyter kernel there is no such script to re-import, so the default "spawn" start method (macOS/Windows) can’t find a notebook-local function in the worker. Switching to the "fork" start method sidesteps this: a forked worker is a copy of the already-running kernel process, so it already has the function in memory and needs to pickle only the arguments/results, not the function itself. "fork" is the default start method on Linux, and is used below purely so this example can run inside a notebook — a regular script doesn’t need it.

To make the timing difference visible, the objective function below does a bit of deliberately wasteful pure-Python busywork per call, standing in for “a real objective function that’s actually expensive to evaluate”:

import time
import multiprocessing as mp

if mp.get_start_method(allow_none=True) != "fork":
    mp.set_start_method("fork", force=True)


def expensive_Rosenbrock(x, a=1, b=100):
    # deliberately wasteful pure-Python work, standing in for "an objective
    # function that's actually expensive to evaluate"
    acc = 0.0
    for i in range(200_000):
        acc += (i % 7) * 1e-9
    return (a - x[0]) ** 2 + b * (x[1] - x[0] ** 2) ** 2 + acc


def time_evolution(n_jobs, n_generations=5, nPop=48):
    bga = BGA(
        expensive_Rosenbrock,
        ranges,
        length=12,
        nPop=nPop,
        minimize=True,
        seed=0,
        n_jobs=n_jobs,
    )
    t0 = time.perf_counter()
    bga.evolve(n_generations)
    return time.perf_counter() - t0


t_serial = time_evolution(n_jobs=1)
t_parallel = time_evolution(n_jobs=-1)

print(f"n_jobs=1  (serial):   {t_serial:.2f} s")
print(f"n_jobs=-1 (parallel): {t_parallel:.2f} s")
print(f"speedup:              {t_serial / t_parallel:.2f}x  (on {mp.cpu_count()} CPUs)")

Choosing a representation#

None of BinaryGeneticAlgorithm/IntegerGeneticAlgorithm/RealValuedGeneticAlgorithm will fit every problem — sometimes the natural representation for a candidate solution (e.g. a permutation) needs its own crossover/mutation operators. See Building a Custom Genetic Algorithm for a full, worked walkthrough of writing such a subclass from scratch on top of GeneticAlgorithm, reusing the framework’s selection strategies, reproducible RNG, and evolution loop.

Symbolic objectives#

Just like for linear programming, you can use the Function class to pose problems using symbolic functions.

import sympy as sy
from sigmaepsilon.math.function import Function

variables = sy.symbols("x y")
obj = Function("(1-x)**2 + 100*(y-x**2)**2", variables=variables)
ranges = [[-2, 2], [-1, 3]]
bga = BGA(obj, ranges, length=12, nPop=100, minimize=True)
result = bga.solve()
result.phenotype, result.fitness