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