Source code for saealib.operators.selection

"""Selection operators for evolutionary algorithms."""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import replace
from typing import Any, Protocol

import numpy as np

from saealib.core.contracts import (
    MANY,
    ComponentContract,
    DataSpec,
    PortContract,
    PortDirection,
    PortSpec,
    ServiceRequirement,
    StateContract,
    Var,
)
from saealib.core.state import RUNTIME_RNG
from saealib.population import Population
from saealib.registry import register


class _SelectionContext(Protocol):
    """Comparison and random capabilities used by selection operators."""

    @property
    def comparator(self) -> Any: ...

    @property
    def rng(self) -> np.random.Generator: ...


[docs] class ParentSelection(ABC): """Base class for parent selection operators."""
[docs] def contract(self) -> ComponentContract: """Return the parent-selection contract.""" parents = DataSpec( kind="Ordering", bindings={"parent_count": Var(name="P")}, ) return ComponentContract( ports={ "parent_selection": PortContract( inputs=( PortSpec( name="population", direction=PortDirection.INPUT, data=DataSpec(kind="Population"), cardinality=MANY, ), ), outputs=( PortSpec( name="parents", direction=PortDirection.OUTPUT, data=parents, cardinality=MANY, ), ), ) } )
[docs] @abstractmethod def select( self, ctx: _SelectionContext, population: Population, n_pair: int, n_parents: int, rng: np.random.Generator = np.random.default_rng(), ) -> np.ndarray: """ Select parents for reproduction. Parameters ---------- ctx : ExecutionContext Optimization context. population : Population Population to select from. n_pair : int Number of pairs to select. n_parents : int Number of parents per pair. rng : np.random.Generator, optional Random number generator, by default np.random.default_rng() Returns ------- np.ndarray Selected parent indices. shape = (n_pair, n_parents) """ pass
[docs] class TournamentSelection(ParentSelection): """ Tournament selection operator. References ---------- :cite:`miller1995tournament`: Miller, B. L., & Goldberg, D. E. (1995). Genetic algorithms, tournament selection, and the effects of noise. *Complex Systems*, 9(3), 193-212. :cite:`blickle1996selection`: Blickle, T., & Thiele, L. (1996). A comparison of selection schemes used in evolutionary algorithms. *Evolutionary Computation*, 4(4), 361-394. (Basis for the ``tournament_size`` design.) """
[docs] def __init__(self, tournament_size: int): super().__init__() self.tournament_size = tournament_size
[docs] def contract(self) -> ComponentContract: """Return the tournament-selection contract.""" base = super().contract() role = base.ports["parent_selection"] return replace( base, ports={ "parent_selection": replace( role, inputs=( replace( role.inputs[0], required_services=( ServiceRequirement(name="ComparisonService"), ), ), ), ) }, )
[docs] def select( self, ctx: _SelectionContext, population: Population, n_pair: int, n_parents: int, rng: np.random.Generator = np.random.default_rng(), ) -> np.ndarray: """ Execute tournament selection. Parameters ---------- ctx : ExecutionContext Optimization context. population : Population Population to select from. n_pair : int Number of pairs to select. n_parents : int Number of parents per pair. rng : np.random.Generator, optional Random number generator, by default np.random.default_rng() Returns ------- np.ndarray Selected parent indices. shape = (n_pair, n_parents) """ n_pop = len(population) cmp = ctx.comparator selected_idx = np.zeros((n_pair, n_parents), dtype=int) for i in range(n_pair): for j in range(n_parents): tournament_idx = rng.choice( n_pop, size=self.tournament_size, replace=False ) best_idx = tournament_idx[0] for idx in tournament_idx[1:]: if ( cmp.compare_population( population, idx, best_idx, ) < 0 ): best_idx = idx selected_idx[i, j] = best_idx return selected_idx
[docs] @register() class SequentialSelection(ParentSelection): """Sequential selection operator."""
[docs] def __init__(self): super().__init__()
[docs] def select( self, ctx: _SelectionContext, population: Population, n_pair: int, n_parents: int, rng: np.random.Generator = np.random.default_rng(), ) -> np.ndarray: """ Execute sequential selection. Assign parents sequentially without any fitness-based comparison. Parameters ---------- ctx : ExecutionContext Optimization context. population : Population Population to select from. n_pair : int Number of pairs to select. n_parents : int Number of parents per pair. rng : np.random.Generator, optional Random number generator, by default np.random.default_rng() Returns ------- np.ndarray Selected parent indices. shape = (n_pair, n_parents) """ selected_idx = np.zeros((n_pair, n_parents), dtype=int) i_grid, j_grid = np.meshgrid( np.arange(n_pair), np.arange(n_parents), indexing="ij" ) selected_idx = i_grid * n_parents + j_grid return selected_idx
[docs] class LinearRankSelection(ParentSelection): """ Parent selection using linear rank-based probabilities. Selection probability is proportional to rank: the best individual has the highest probability and the worst has the lowest. This avoids numerical issues with raw fitness values (negative values, scale sensitivity) while preserving a fitness-proportionate behaviour. """
[docs] def __init__(self): super().__init__()
[docs] def contract(self) -> ComponentContract: """Return the linear-rank-selection contract.""" base = super().contract() role = base.ports["parent_selection"] return replace( base, ports={ "parent_selection": replace( role, inputs=( replace( role.inputs[0], required_services=( ServiceRequirement(name="ComparisonService"), ), ), ), ) }, )
[docs] def select( self, ctx: _SelectionContext, population: Population, n_pair: int, n_parents: int, rng: np.random.Generator = np.random.default_rng(), ) -> np.ndarray: """ Execute linear rank selection. Parameters ---------- ctx : ExecutionContext Optimization context. population : Population Population to select from. n_pair : int Number of pairs to select. n_parents : int Number of parents per pair. rng : np.random.Generator, optional Random number generator, by default np.random.default_rng() Returns ------- np.ndarray Selected parent indices. shape = (n_pair, n_parents) """ n_pop = len(population) sorted_idx = ctx.comparator.sort_population(population) weights = np.arange(n_pop, 0, -1, dtype=float) probs = weights / weights.sum() rank_probs = np.empty(n_pop) rank_probs[sorted_idx] = probs chosen = rng.choice(n_pop, size=n_pair * n_parents, p=rank_probs) return chosen.reshape(n_pair, n_parents)
[docs] class SurvivorSelection(ABC): """Base class for survivor selection operators."""
[docs] def contract(self) -> ComponentContract: """Return the survivor-selection contract.""" return ComponentContract( ports={ "survivor_selection": PortContract( inputs=( PortSpec( name="pool", direction=PortDirection.INPUT, data=DataSpec(kind="Population"), cardinality=MANY, ), ), outputs=( PortSpec( name="survivors", direction=PortDirection.OUTPUT, data=DataSpec(kind="Ordering"), cardinality=MANY, ), ), ) } )
[docs] @abstractmethod def select( self, ctx: _SelectionContext, pool: Population, n_survivors: int, ) -> np.ndarray: """ Select survivors from the selection pool. The pool is a merged Population (e.g., parents + offspring) constructed by the Algorithm. The replacement strategy (μ+λ or μ,λ) is determined by the Algorithm, not by this class. Parameters ---------- ctx : ExecutionContext Optimization context. pool : Population Selection pool to choose survivors from. n_survivors : int Number of survivors to select. Returns ------- np.ndarray Indices of selected survivors in the pool. """ pass
[docs] @register() class TruncationSelection(SurvivorSelection): """ Truncation selection operator. Attributes ---------- randomize_ties : bool When True, individuals tied at the truncation boundary (equal under ``comparator.compare_population``) are shuffled before truncating, matching pymoo's ``randomized_argsort`` treatment of the splitting front. When False (default), ties keep the order returned by ``comparator.rank_population`` (deterministic). """
[docs] def __init__(self, randomize_ties: bool = False): """ Initialize truncation selection operator. Parameters ---------- randomize_ties : bool, optional Randomize the order of individuals tied at the truncation boundary, by default False. """ self.randomize_ties = randomize_ties
[docs] def contract(self) -> ComponentContract: """Return the truncation-selection contract.""" base = super().contract() role = base.ports["survivor_selection"] state = ( StateContract(reads=(RUNTIME_RNG,), writes=(RUNTIME_RNG,)) if self.randomize_ties else StateContract() ) return replace( base, state=state, ports={ "survivor_selection": replace( role, inputs=( replace( role.inputs[0], required_services=( ServiceRequirement(name="ComparisonService"), ), ), ), ) }, )
[docs] def select( self, ctx: _SelectionContext, pool: Population, n_survivors: int, ) -> np.ndarray: """ Select survivors by truncating the sorted pool. Parameters ---------- ctx : ExecutionContext Optimization context. pool : Population Selection pool to choose survivors from. n_survivors : int Number of survivors to select. Returns ------- np.ndarray Indices of selected survivors in the pool. """ cmp = ctx.comparator sorted_idx = cmp.rank_population(pool) if not self.randomize_ties or n_survivors >= len(sorted_idx): return sorted_idx[:n_survivors] boundary = sorted_idx[n_survivors - 1] lo = n_survivors - 1 while ( lo > 0 and cmp.compare_population(pool, sorted_idx[lo - 1], boundary) == 0 ): lo -= 1 hi = n_survivors while ( hi < len(sorted_idx) and cmp.compare_population(pool, sorted_idx[hi], boundary) == 0 ): hi += 1 if hi - lo <= 1: return sorted_idx[:n_survivors] tie_block = sorted_idx[lo:hi].copy() ctx.rng.shuffle(tie_block) result = sorted_idx.copy() result[lo:hi] = tie_block return result[:n_survivors]