Mutation#
GA (saealib.GA) delegates adding perturbation to the offspring generated by crossover to Mutation, a swappable operator.
To change the mutation scheme, you only need to swap out this Mutation, not GA itself.
Mutation’s role#
Mutation requires only one method, mutate_batch(candidates_batch, mutate_range, rng=...), to be implemented.
It receives candidate individuals with shape (n, dim) and returns mutated individuals with the same shape.
mutate_range receives the design variables’ lower and upper bounds as a (lb, ub) tuple.
The base class derives the convenience method mutate(p, mutate_range, rng=...) from a single-row call to mutate_batch().
GA uses this convenience method under variation_execution="sequential".
Overriding only mutate() has no effect in the default batch mode; see Algorithm and the GA API reference for variation_execution.
The individual-level mutation probability is held by the class attribute prob.
Whereas for Crossover the decision of whether to perform crossover is GA’s responsibility, each Mutation implementation makes its own mutation decision.
Every mutate_batch() implementation must draw one gate per row and leave ungated rows unchanged.
This also supplies the self-gate for the inherited single-row mutate().
GA therefore calls the selected mutation method unconditionally and doesn’t pre-filter candidates; a custom Mutation must implement this prob gate itself.
The other class attribute, prob_var, is the per-variable mutation probability.
When None, built-in mutations resolve it to min(0.5, 1/dim) when mutate_batch() is called.
Built-in Mutations#
Class |
Parameters |
Characteristics |
|---|---|---|
|
|
Replaces a chosen dimension with a uniform random value from |
|
|
Adds |
|
|
Polynomial mutation [13]. Larger |
|
|
Replaces with a uniform random integer in |
|
|
Replaces the category index with a uniform random integer from |
For problems with only continuous variables, pick one of these and pass it to GA(mutation=..., ...).
The basic decision is: choose MutationUniform if you want the search’s coarseness matched to the variable’s own value range, or MutationGaussian or MutationPolynomial if you want to perturb only the neighborhood of the existing value.
MutationGaussian specifies the perturbation magnitude directly via sigma, whereas MutationPolynomial specifies the concentration of the perturbation via eta (the distribution index), with the perturbation width relative to the value range determined automatically.
MutationIntegerUniform and MutationCategorical merely share a common private implementation for how the random number is generated (replacement with a uniform random integer within the value range), but are treated as separate classes in the public API.
For problems where design variables mix integer and categorical variables, GA uses a different Mutation instance per variable type.
If the GA constructor’s integer_mutation/categorical_mutation arguments are omitted, MutationIntegerUniform/MutationCategorical are supplied automatically (with prob_var inherited from the continuous-variable mutation).
GA.ask() splits individuals into columns by variable type, applies each Mutation only to its corresponding columns, and then reassembles the results.
The default batch mode calls each type’s mutate_batch() on its columns; sequential mode calls each type’s mutate() and preserves the earlier per-individual routing and random-number sequence.
The correspondence between variable types and Mutation is determined by Problem’s variables argument.
Note
Only MutationUniform is currently @register()ed; the other 4 classes are not yet registered with the Registry.
Keep this difference in mind if you resolve classes from strings via the Registry.
External library adapters#
PymooMutation(operator, *, prob=1.0) wraps an already-constructed pymoo mutation operator (e.g. PM()) so existing pymoo-based research code can be reused unchanged inside GA.
Unlike PymooCrossover, prob is applied by PymooMutation itself, matching every built-in Mutation’s convention that the individual-level probability check lives inside mutate_batch().
prob_var is deliberately not mirrored from the wrapped pymoo operator — it stays None here, so mixed-variable routing in GA falls back to its own default rather than reading a foreign, non-float value.
In GA’s default batch mode, PymooMutation.mutate_batch() calls the wrapped operator’s _do() at most once, on the gated subset.
Under variation_execution="sequential", the inherited mutate() passes one individual to that batch implementation, so _do() is called once per gated individual, exactly as with a direct mutate() call.
rng is forwarded via pymoo’s random_state parameter.
See Installation for the pymoo extra.
Extension hooks#
If you just want to add post-processing, such as rounding values that fall outside the bounds, you can add it to an existing Mutation instance with with_post(fn) instead of creating a new subclass.
with_post doesn’t modify the original instance — it returns a copy with fn added.
import numpy as np
from saealib import MutationUniform
def clip_offspring(offspring, mutate_range, rng, ctx=None):
lb, ub = mutate_range
return np.clip(offspring, lb, ub)
base = MutationUniform(prob=1.0)
clipped = base.with_post(clip_offspring)
fn’s signature is fn(offspring, mutate_range, rng, ctx) -> np.ndarray, receiving the result of the existing hook (by default an identity function that does nothing) and returning an additional transformation.
Calling with_post multiple times chains the hooks in the order called.
In batch mode, GA calls post_mutation_batch(offspring_batch, mutate_range, rng, ctx).
Its default implementation calls post_mutation() once per individual, so hooks installed by with_post() continue to work.
Override post_mutation_batch() when the post-processing itself should be genuinely vectorized; such an override is responsible for composing any with_post() hook it needs to retain.
Implementing a custom Mutation#
If you need a custom mutation scheme, subclass Mutation and implement mutate_batch().
The following example replaces selected dimensions with the midpoint of their value range.
import numpy as np
from saealib import Mutation
class MidpointMutation(Mutation):
def __init__(self, prob: float = 1.0, *, prob_var: float | None = None):
super().__init__()
self.prob = prob
self.prob_var = prob_var
def mutate_batch(self, candidates_batch, mutate_range, rng=np.random.default_rng()):
candidates_batch = np.asarray(candidates_batch, dtype=float)
n, dim = candidates_batch.shape
p_var = self.prob_var if self.prob_var is not None else min(0.5, 1.0 / dim)
gate = rng.random(n) < self.prob
result = candidates_batch.copy()
if not np.any(gate):
return result
selected = result[gate]
var_gate = rng.random(selected.shape) < p_var
lb, ub = mutate_range
midpoint = (np.asarray(lb) + np.asarray(ub)) / 2.0
selected[var_gate] = np.broadcast_to(midpoint, selected.shape)[var_gate]
result[gate] = selected
return result
The individual-level gate must contain one draw per row and preserve every ungated row.
The variable-level prob_var gate is operator-specific but follows the same convention as the built-in classes here.
Omitting these checks results in an implementation where every dimension always mutates.