Skip to content

emo

Templates

desdeo.emo.methods.templates

This module contains the basic functional implementations for the EMO methods.

This can be used as a template for the implementation of the EMO methods.

template1

template1(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    terminator: BaseTerminator,
    repair: Callable[[DataFrame], DataFrame] = lambda x: x,
) -> EMOResult

Implements a template that many EMO methods, such as RVEA and NSGA-III, follow.

Parameters:

Name Type Description Default
evaluator EMOEvaluator

A class that evaluates the solutions and provides the objective vectors, constraint vectors, and targets.

required
crossover BaseCrossover

The crossover operator.

required
mutation BaseMutation

The mutation operator.

required
generator BaseGenerator

A class that generates the initial population.

required
selection BaseSelector

The selection operator.

required
terminator BaseTerminator

The termination operator.

required
repair Callable

A function that repairs the offspring if they go out of bounds. Defaults to an identity function, meaning no repair is done. See desdeo.tools.utils.repair as an example of a repair function.

lambda x: x

Returns:

Name Type Description
EMOResult EMOResult

The final population and their objective vectors, constraint vectors, and targets

Source code in desdeo/emo/methods/templates.py
def template1(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    terminator: BaseTerminator,
    repair: Callable[[pl.DataFrame], pl.DataFrame] = lambda x: x,  # Default to identity function if no repair is needed
) -> EMOResult:
    """Implements a template that many EMO methods, such as RVEA and NSGA-III, follow.

    Args:
        evaluator (EMOEvaluator): A class that evaluates the solutions and provides the objective vectors, constraint
            vectors, and targets.
        crossover (BaseCrossover): The crossover operator.
        mutation (BaseMutation): The mutation operator.
        generator (BaseGenerator): A class that generates the initial population.
        selection (BaseSelector): The selection operator.
        terminator (BaseTerminator): The termination operator.
        repair (Callable, optional): A function that repairs the offspring if they go out of bounds. Defaults to an
            identity function, meaning no repair is done. See [desdeo.tools.utils.repair][] as an example of a
            repair function.

    Returns:
        EMOResult: The final population and their objective vectors, constraint vectors, and targets
    """
    solutions, outputs = generator.do()

    while not terminator.check():
        offspring = crossover.do(population=solutions)
        offspring = mutation.do(offspring, solutions)
        # Repair offspring if they go out of bounds
        offspring = repair(offspring)
        offspring_outputs = evaluator.evaluate(offspring)
        solutions, outputs = selection.do(parents=(solutions, outputs), offsprings=(offspring, offspring_outputs))

    return EMOResult(optimal_variables=solutions, optimal_outputs=outputs)

template2

template2(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    mate_selection: BaseScalarSelector,
    terminator: BaseTerminator,
    repair: Callable[[DataFrame], DataFrame] = lambda x: x,
) -> EMOResult

Implements a template that many EMO methods, such as IBEA, follow.

Parameters:

Name Type Description Default
evaluator EMOEvaluator

A class that evaluates the solutions and provides the objective vectors, constraint vectors, and targets.

required
crossover BaseCrossover

The crossover operator.

required
mutation BaseMutation

The mutation operator.

required
generator BaseGenerator

A class that generates the initial population.

required
selection BaseSelector

The selection operator.

required
mate_selection BaseScalarSelector

The mating selection operator, which selects parents for mating. This is typically a scalar selector that selects parents based on their fitness.

required
terminator BaseTerminator

The termination operator.

required
repair Callable

A function that repairs the offspring if they go out of bounds. Defaults to an identity function, meaning no repair is done. See desdeo.tools.utils.repair as an example of a repair function.

lambda x: x

Returns:

Name Type Description
EMOResult EMOResult

The final population and their objective vectors, constraint vectors, and targets

Source code in desdeo/emo/methods/templates.py
def template2(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    mate_selection: BaseScalarSelector,
    terminator: BaseTerminator,
    repair: Callable[[pl.DataFrame], pl.DataFrame] = lambda x: x,  # Default to identity function if no repair is needed
) -> EMOResult:
    """Implements a template that many EMO methods, such as IBEA, follow.

    Args:
        evaluator (EMOEvaluator): A class that evaluates the solutions and provides the objective vectors, constraint
            vectors, and targets.
        crossover (BaseCrossover): The crossover operator.
        mutation (BaseMutation): The mutation operator.
        generator (BaseGenerator): A class that generates the initial population.
        selection (BaseSelector): The selection operator.
        mate_selection (BaseScalarSelector): The mating selection operator, which selects parents for mating.
            This is typically a scalar selector that selects parents based on their fitness.
        terminator (BaseTerminator): The termination operator.
        repair (Callable, optional): A function that repairs the offspring if they go out of bounds. Defaults to an
            identity function, meaning no repair is done. See [desdeo.tools.utils.repair][] as an example of a
            repair function.

    Returns:
        EMOResult: The final population and their objective vectors, constraint vectors, and targets
    """
    solutions, outputs = generator.do()
    # This is just a hack to make all selection operators work (they require offsprings to be passed separately rn)
    offspring = pl.DataFrame(
        schema=solutions.schema,
    )
    offspring_outputs = pl.DataFrame(
        schema=outputs.schema,
    )

    while True:
        solutions, outputs = selection.do(parents=(solutions, outputs), offsprings=(offspring, offspring_outputs))
        if terminator.check():
            # Weird way to do looping, but IBEA does environmental selection before the loop check, and...
            # does mating afterwards.
            break
        parents, _ = mate_selection.do((solutions, outputs))
        offspring = crossover.do(population=parents)
        offspring = mutation.do(offspring, solutions)
        # Repair offspring if they go out of bounds
        offspring = repair(offspring)
        offspring_outputs = evaluator.evaluate(offspring)

    return EMOResult(optimal_variables=solutions, optimal_outputs=outputs)

template3

template3(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    terminator: BaseTerminator,
    seed: int,
    repair: Callable[[DataFrame], DataFrame] = lambda x: x,
) -> EMOResult

Implements a template that many steady state EMO methods such as SMS-EMOA follow.

The algorithm follows the one described in Beume et al. (2007).

Beume, N., Naujoks, B., & Emmerich, M. (2007). SMS-EMOA: Multiobjective selection based on dominated hypervolume. European Journal of Operational Research, 181(3), 1653-1669. https://doi.org/10.1016/j.ejor.2006.08.008

Parameters:

Name Type Description Default
evaluator EMOEvaluator

A class that evaluates the solutions and provides the objective vectors, constraint vectors, and targets.

required
crossover BaseCrossover

The crossover operator.

required
mutation BaseMutation

The mutation operator.

required
generator BaseGenerator

A class that generates the initial population.

required
selection BaseSelector

The selection operator.

required
terminator BaseTerminator

The termination operator.

required
seed int

The random seed for reproducibility.

required
repair Callable

A function that repairs the offspring if they go out of bounds. Defaults to an identity function, meaning no repair is done. See desdeo.tools.utils.repair as an example of a repair function.

lambda x: x

Returns:

Name Type Description
EMOResult EMOResult

The final population and their objective vectors, constraint vectors, and targets

Source code in desdeo/emo/methods/templates.py
def template3(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseSelector,
    terminator: BaseTerminator,
    seed: int,
    repair: Callable[[pl.DataFrame], pl.DataFrame] = lambda x: x,  # Default to identity function if no repair is needed
) -> EMOResult:
    """Implements a template that many steady state EMO methods such as SMS-EMOA follow.

    The algorithm follows the one described in Beume et al. (2007).

    Beume, N., Naujoks, B., & Emmerich, M. (2007). SMS-EMOA: Multiobjective selection based on dominated hypervolume.
    European Journal of Operational Research, 181(3), 1653-1669. https://doi.org/10.1016/j.ejor.2006.08.008


    Args:
        evaluator (EMOEvaluator): A class that evaluates the solutions and provides the objective vectors, constraint
            vectors, and targets.
        crossover (BaseCrossover): The crossover operator.
        mutation (BaseMutation): The mutation operator.
        generator (BaseGenerator): A class that generates the initial population.
        selection (BaseSelector): The selection operator.
        terminator (BaseTerminator): The termination operator.
        seed (int): The random seed for reproducibility.
        repair (Callable, optional): A function that repairs the offspring if they go out of bounds. Defaults to an
            identity function, meaning no repair is done. See [desdeo.tools.utils.repair][] as an example of a
            repair function.

    Returns:
        EMOResult: The final population and their objective vectors, constraint vectors, and targets
    """
    rng = np.random.default_rng(seed)
    solutions, outputs = generator.do()  # Algorithm 1 line 1

    while not terminator.check():
        # Generate one offspring at a time
        # choose two random parents from the current population
        parents_idx = rng.choice(solutions.height, size=2, replace=False).tolist()
        offsprings = crossover.do(population=solutions, to_mate=parents_idx)  # Algorithm 1 line 4
        offsprings = mutation.do(offsprings, solutions)  # Algorithm 1 line 4
        # Repair offspring if they go out of bounds
        offsprings = repair(offsprings)
        # The crossover generates two offsprings, but we only want to keep one of them,
        # so we randomly choose one of the two (i think just always choosing the first one is not fine)
        offspring_idx = rng.choice(offsprings.height, size=1, replace=False).tolist()
        offspring = offsprings[offspring_idx, :]
        offspring_outputs = evaluator.evaluate(offspring)
        solutions, outputs = selection.do(
            parents=(solutions, outputs), offsprings=(offspring, offspring_outputs)
        )  # Algorithm 1 line 5

    return EMOResult(optimal_variables=solutions, optimal_outputs=outputs)

template_xlemoo

template_xlemoo(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseScalarSelector,
    learning_operator: LearningModeOperator,
    terminator: BaseTerminator,
    repair: Callable[[DataFrame], DataFrame] = lambda x: x,
    n_darwin_per_cycle: int = 20,
    n_learning_per_cycle: int = 1,
) -> EMOResult

Implements the XLEMOO loop alternating between Darwinian and Learning modes.

The loop interleaves n_darwin_per_cycle Darwinian iterations with n_learning_per_cycle Learning iterations. Each iteration counts as one generation against the terminator, so the cycle structure is just a phase selector for what happens inside that generation.

Parameters:

Name Type Description Default
evaluator EMOEvaluator

Evaluator for objective and target values.

required
crossover BaseCrossover

The crossover operator.

required
mutation BaseMutation

The mutation operator.

required
generator BaseGenerator

Initial population generator.

required
selection BaseScalarSelector

Scalar selector that ranks the combined parent and offspring population by a single fitness column (e.g. ElitistSelection).

required
learning_operator LearningModeOperator

Operator that performs one learning step (rule extraction + instantiation) using the archive. Its do() returns instantiated decision vectors (or None when no rules can be extracted).

required
terminator BaseTerminator

Termination operator. Its check() advances the generation counter and notifies subscribers (e.g. the Archive).

required
repair Callable

Function repairing offspring back into bounds. Defaults to identity.

lambda x: x
n_darwin_per_cycle int

Number of Darwinian iterations per cycle. Defaults to 20.

20
n_learning_per_cycle int

Number of Learning iterations per cycle. Set to 0 to disable Learning mode entirely. Defaults to 1.

1

Returns:

Name Type Description
EMOResult EMOResult

The final population and its objective/target values.

Source code in desdeo/emo/methods/templates.py
def template_xlemoo(
    evaluator: EMOEvaluator,
    crossover: BaseCrossover,
    mutation: BaseMutation,
    generator: BaseGenerator,
    selection: BaseScalarSelector,
    learning_operator: LearningModeOperator,
    terminator: BaseTerminator,
    repair: Callable[[pl.DataFrame], pl.DataFrame] = lambda x: x,
    n_darwin_per_cycle: int = 20,
    n_learning_per_cycle: int = 1,
) -> EMOResult:
    """Implements the XLEMOO loop alternating between Darwinian and Learning modes.

    The loop interleaves ``n_darwin_per_cycle`` Darwinian iterations with
    ``n_learning_per_cycle`` Learning iterations. Each iteration counts as one
    generation against the terminator, so the cycle structure is just a phase
    selector for what happens inside that generation.

    Args:
        evaluator (EMOEvaluator): Evaluator for objective and target values.
        crossover (BaseCrossover): The crossover operator.
        mutation (BaseMutation): The mutation operator.
        generator (BaseGenerator): Initial population generator.
        selection (BaseScalarSelector): Scalar selector that ranks the combined parent
            and offspring population by a single fitness column (e.g.
            [ElitistSelection][desdeo.emo.operators.scalar_selection.ElitistSelection]).
        learning_operator (LearningModeOperator): Operator that performs one learning step
            (rule extraction + instantiation) using the archive. Its `do()` returns
            instantiated decision vectors (or ``None`` when no rules can be extracted).
        terminator (BaseTerminator): Termination operator. Its ``check()`` advances the
            generation counter and notifies subscribers (e.g. the Archive).
        repair (Callable, optional): Function repairing offspring back into bounds. Defaults
            to identity.
        n_darwin_per_cycle (int, optional): Number of Darwinian iterations per cycle.
            Defaults to 20.
        n_learning_per_cycle (int, optional): Number of Learning iterations per cycle. Set
            to 0 to disable Learning mode entirely. Defaults to 1.

    Returns:
        EMOResult: The final population and its objective/target values.
    """
    if n_darwin_per_cycle == 0 and n_learning_per_cycle == 0:
        raise ValueError("At least one of n_darwin_per_cycle and n_learning_per_cycle must be > 0.")

    cycle_len = n_darwin_per_cycle + n_learning_per_cycle
    solutions, outputs = generator.do()
    gen_in_cycle = 0

    while not terminator.check():
        if gen_in_cycle < n_darwin_per_cycle:
            offspring = crossover.do(population=solutions)
            offspring = mutation.do(offspring, solutions)
            offspring = repair(offspring)
            offspring_outputs = evaluator.evaluate(offspring)
            combined_decvars = solutions.vstack(offspring)
            combined_outputs = outputs.vstack(offspring_outputs)
            solutions, outputs = selection.do((combined_decvars, combined_outputs))
        else:
            instantiated = learning_operator.do()
            if instantiated is not None:
                instantiated_outputs = evaluator.evaluate(instantiated)
                combined_decvars = solutions.vstack(instantiated)
                combined_outputs = outputs.vstack(instantiated_outputs)
                solutions, outputs = selection.do((combined_decvars, combined_outputs))
            # else: no usable rules this round; keep the current population unchanged
        gen_in_cycle = (gen_in_cycle + 1) % cycle_len

    return EMOResult(optimal_variables=solutions, optimal_outputs=outputs)

Generators

desdeo.emo.operators.generator

Class for generating initial population for the evolutionary optimization algorithms.

ArchiveGenerator

Bases: BaseGenerator

Class for getting initial population from an archive.

Source code in desdeo/emo/operators/generator.py
class ArchiveGenerator(BaseGenerator):
    """Class for getting initial population from an archive."""

    def __init__(
        self,
        problem: Problem,
        evaluator: EMOEvaluator,
        publisher: Publisher,
        verbosity: int,
        solutions: pl.DataFrame,
        **kwargs: dict,  # just to dump seed
    ):
        """Initialize the ArchiveGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population. Only used to check that the outputs
                have the correct variables.
            publisher (Publisher): The publisher to publish the messages.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            solutions (pl.DataFrame): The decision variable vectors to use as the initial population.
            kwargs (dict): Other keyword arguments to pass, e.g., a random seed.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        if not isinstance(solutions, pl.DataFrame):
            raise TypeError("The solutions must be a polars DataFrame.")
        if solutions.shape[0] == 0:
            raise ValueError("The solutions DataFrame is empty.")
        self.solutions = solutions
        # self.outputs = outputs
        if not set(self.solutions.columns) == set(self.variable_symbols):
            raise ValueError("The solutions DataFrame must have the same columns as the problem variables.")
        # TODO: Check that the outputs have the correct columns
        self.evaluator = evaluator

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Get the initial population from the archive.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        self.outputs = self.evaluator.evaluate(self.solutions)
        self.notify()
        return self.solutions, self.outputs

    def state(self) -> Sequence[Message]:
        """Return the state of the generator.

        This method overrides the state method of the BaseGenerator class, because the solutions and outputs are
        already provided and not generated by the generator.

        Returns:
            dict: The state of the generator.
        """
        # TODO: Should we do it like this? Or just do super().state()?
        # Maybe saying that zero evaluations have been done is misleading?
        # idk
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                IntMessage(
                    topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                    value=0,
                    source=self.__class__.__name__,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=GeneratorMessageTopics.VERBOSE_OUTPUTS,
                value=self.solutions.hstack(self.outputs),
                source=self.__class__.__name__,
            ),
            IntMessage(
                topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                value=0,
                source=self.__class__.__name__,
            ),
        ]

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    publisher: Publisher,
    verbosity: int,
    solutions: DataFrame,
    **kwargs: dict,
)

Initialize the ArchiveGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population. Only used to check that the outputs have the correct variables.

required
publisher Publisher

The publisher to publish the messages.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
solutions DataFrame

The decision variable vectors to use as the initial population.

required
kwargs dict

Other keyword arguments to pass, e.g., a random seed.

{}
Source code in desdeo/emo/operators/generator.py
def __init__(
    self,
    problem: Problem,
    evaluator: EMOEvaluator,
    publisher: Publisher,
    verbosity: int,
    solutions: pl.DataFrame,
    **kwargs: dict,  # just to dump seed
):
    """Initialize the ArchiveGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population. Only used to check that the outputs
            have the correct variables.
        publisher (Publisher): The publisher to publish the messages.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        solutions (pl.DataFrame): The decision variable vectors to use as the initial population.
        kwargs (dict): Other keyword arguments to pass, e.g., a random seed.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    if not isinstance(solutions, pl.DataFrame):
        raise TypeError("The solutions must be a polars DataFrame.")
    if solutions.shape[0] == 0:
        raise ValueError("The solutions DataFrame is empty.")
    self.solutions = solutions
    # self.outputs = outputs
    if not set(self.solutions.columns) == set(self.variable_symbols):
        raise ValueError("The solutions DataFrame must have the same columns as the problem variables.")
    # TODO: Check that the outputs have the correct columns
    self.evaluator = evaluator
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Get the initial population from the archive.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Get the initial population from the archive.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    self.outputs = self.evaluator.evaluate(self.solutions)
    self.notify()
    return self.solutions, self.outputs
state
state() -> Sequence[Message]

Return the state of the generator.

This method overrides the state method of the BaseGenerator class, because the solutions and outputs are already provided and not generated by the generator.

Returns:

Name Type Description
dict Sequence[Message]

The state of the generator.

Source code in desdeo/emo/operators/generator.py
def state(self) -> Sequence[Message]:
    """Return the state of the generator.

    This method overrides the state method of the BaseGenerator class, because the solutions and outputs are
    already provided and not generated by the generator.

    Returns:
        dict: The state of the generator.
    """
    # TODO: Should we do it like this? Or just do super().state()?
    # Maybe saying that zero evaluations have been done is misleading?
    # idk
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            IntMessage(
                topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                value=0,
                source=self.__class__.__name__,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=GeneratorMessageTopics.VERBOSE_OUTPUTS,
            value=self.solutions.hstack(self.outputs),
            source=self.__class__.__name__,
        ),
        IntMessage(
            topic=GeneratorMessageTopics.NEW_EVALUATIONS,
            value=0,
            source=self.__class__.__name__,
        ),
    ]
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

BaseGenerator

Bases: Subscriber

Base class for generating initial population for the evolutionary optimization algorithms.

This class should be inherited by the classes that implement the initial population generation for the evolutionary optimization algorithms.

Source code in desdeo/emo/operators/generator.py
class BaseGenerator(Subscriber):
    """Base class for generating initial population for the evolutionary optimization algorithms.

    This class should be inherited by the classes that implement the initial population generation
    for the evolutionary optimization algorithms.

    """

    @property
    def provided_topics(self) -> dict[int, Sequence[GeneratorMessageTopics]]:
        """Return the topics provided by the generator.

        Returns:
            dict[int, Sequence[GeneratorMessageTopics]]: The topics provided by the generator.
        """
        return {
            0: [],
            1: [GeneratorMessageTopics.NEW_EVALUATIONS],
            2: [
                GeneratorMessageTopics.NEW_EVALUATIONS,
                GeneratorMessageTopics.VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        """Return the message topics that the generator is interested in."""
        return []

    def __init__(self, problem: Problem, publisher: Publisher, verbosity: int):
        """Initialize the BaseGenerator class."""
        super().__init__(publisher=publisher, verbosity=verbosity)
        self.problem = problem
        self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
        self.bounds = np.array([[var.lowerbound, var.upperbound] for var in problem.get_flattened_variables()])
        self.population: pl.DataFrame = None
        self.out: pl.DataFrame = None

    @abstractmethod
    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        This method should be implemented by the inherited classes.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the
                second element.
        """

    def state(self) -> Sequence[Message]:
        """Return the state of the generator.

        This method should be implemented by the inherited classes.

        Returns:
            dict: The state of the generator.
        """
        if self.population is None or self.out is None or self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                IntMessage(
                    topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                    value=self.population.shape[0],
                    source=self.__class__.__name__,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=GeneratorMessageTopics.VERBOSE_OUTPUTS,
                value=self.population.hstack(self.out),
                source=self.__class__.__name__,
            ),
            IntMessage(
                topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                value=self.population.shape[0],
                source=self.__class__.__name__,
            ),
        ]
interested_topics property
interested_topics

Return the message topics that the generator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[GeneratorMessageTopics]]

Return the topics provided by the generator.

Returns:

Type Description
dict[int, Sequence[GeneratorMessageTopics]]

dict[int, Sequence[GeneratorMessageTopics]]: The topics provided by the generator.

__init__
__init__(
    problem: Problem, publisher: Publisher, verbosity: int
)

Initialize the BaseGenerator class.

Source code in desdeo/emo/operators/generator.py
def __init__(self, problem: Problem, publisher: Publisher, verbosity: int):
    """Initialize the BaseGenerator class."""
    super().__init__(publisher=publisher, verbosity=verbosity)
    self.problem = problem
    self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
    self.bounds = np.array([[var.lowerbound, var.upperbound] for var in problem.get_flattened_variables()])
    self.population: pl.DataFrame = None
    self.out: pl.DataFrame = None
do abstractmethod
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

This method should be implemented by the inherited classes.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
@abstractmethod
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    This method should be implemented by the inherited classes.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the
            second element.
    """
state
state() -> Sequence[Message]

Return the state of the generator.

This method should be implemented by the inherited classes.

Returns:

Name Type Description
dict Sequence[Message]

The state of the generator.

Source code in desdeo/emo/operators/generator.py
def state(self) -> Sequence[Message]:
    """Return the state of the generator.

    This method should be implemented by the inherited classes.

    Returns:
        dict: The state of the generator.
    """
    if self.population is None or self.out is None or self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            IntMessage(
                topic=GeneratorMessageTopics.NEW_EVALUATIONS,
                value=self.population.shape[0],
                source=self.__class__.__name__,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=GeneratorMessageTopics.VERBOSE_OUTPUTS,
            value=self.population.hstack(self.out),
            source=self.__class__.__name__,
        ),
        IntMessage(
            topic=GeneratorMessageTopics.NEW_EVALUATIONS,
            value=self.population.shape[0],
            source=self.__class__.__name__,
        ),
    ]

LHSGenerator

Bases: BaseGenerator

Class for generating Latin Hypercube Sampling (LHS) initial population for the MOEAs.

This class generates the initial population by using the Latin Hypercube Sampling (LHS) method. If the seed is not provided, the seed is set to 0.

Source code in desdeo/emo/operators/generator.py
class LHSGenerator(BaseGenerator):
    """Class for generating Latin Hypercube Sampling (LHS) initial population for the MOEAs.

    This class generates the initial population by using the Latin Hypercube Sampling (LHS) method.
    If the seed is not provided, the seed is set to 0.
    """

    def __init__(
        self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
    ):
        """Initialize the LHSGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population.
            n_points (int): The number of points to generate for the initial population.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            publisher (Publisher): The publisher to publish the messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.n_points = n_points
        self.evaluator = evaluator
        self.seed = seed
        rng = np.random.default_rng(self.seed)
        self.lhsrng = LatinHypercube(d=len(self.variable_symbols), rng=rng)

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        This method should be implemented by the inherited classes.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out

        self.population = pl.from_numpy(
            self.lhsrng.random(n=self.n_points) * (self.bounds[:, 1] - self.bounds[:, 0]) + self.bounds[:, 0],
            schema=self.variable_symbols,
        )
        self.out = self.evaluator.evaluate(self.population)
        self.notify()
        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    n_points: int,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the LHSGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population.

required
n_points int

The number of points to generate for the initial population.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
publisher Publisher

The publisher to publish the messages.

required
Source code in desdeo/emo/operators/generator.py
def __init__(
    self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
):
    """Initialize the LHSGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population.
        n_points (int): The number of points to generate for the initial population.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        publisher (Publisher): The publisher to publish the messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.n_points = n_points
    self.evaluator = evaluator
    self.seed = seed
    rng = np.random.default_rng(self.seed)
    self.lhsrng = LatinHypercube(d=len(self.variable_symbols), rng=rng)
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

This method should be implemented by the inherited classes.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    This method should be implemented by the inherited classes.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out

    self.population = pl.from_numpy(
        self.lhsrng.random(n=self.n_points) * (self.bounds[:, 1] - self.bounds[:, 0]) + self.bounds[:, 0],
        schema=self.variable_symbols,
    )
    self.out = self.evaluator.evaluate(self.population)
    self.notify()
    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

RandomBinaryGenerator

Bases: BaseGenerator

Class for generating random initial population for problems with binary variables.

This class generates an initial population by randomly setting variable values to be either 0 or 1.

Source code in desdeo/emo/operators/generator.py
class RandomBinaryGenerator(BaseGenerator):
    """Class for generating random initial population for problems with binary variables.

    This class generates an initial population by randomly setting variable values to be either 0 or 1.
    """

    def __init__(
        self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
    ):
        """Initialize the RandomBinaryGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population.
            n_points (int): The number of points to generate for the initial population.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            publisher (Publisher): The publisher to publish the messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.n_points = n_points
        self.evaluator = evaluator
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out

        self.population = pl.from_numpy(
            self.rng.integers(low=0, high=2, size=(self.n_points, self.bounds.shape[0])).astype(dtype=np.float64),
            schema=self.variable_symbols,
        )

        self.out = self.evaluator.evaluate(self.population)
        self.notify()
        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    n_points: int,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the RandomBinaryGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population.

required
n_points int

The number of points to generate for the initial population.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
publisher Publisher

The publisher to publish the messages.

required
Source code in desdeo/emo/operators/generator.py
def __init__(
    self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
):
    """Initialize the RandomBinaryGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population.
        n_points (int): The number of points to generate for the initial population.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        publisher (Publisher): The publisher to publish the messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.n_points = n_points
    self.evaluator = evaluator
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out

    self.population = pl.from_numpy(
        self.rng.integers(low=0, high=2, size=(self.n_points, self.bounds.shape[0])).astype(dtype=np.float64),
        schema=self.variable_symbols,
    )

    self.out = self.evaluator.evaluate(self.population)
    self.notify()
    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

RandomGenerator

Bases: BaseGenerator

Class for generating random initial population for the evolutionary optimization algorithms.

This class generates the initial population by randomly sampling the points from the variable bounds. The distribution of the points is uniform. If the seed is not provided, the seed is set to 0.

Source code in desdeo/emo/operators/generator.py
class RandomGenerator(BaseGenerator):
    """Class for generating random initial population for the evolutionary optimization algorithms.

    This class generates the initial population by randomly sampling the points from the variable bounds. The
    distribution of the points is uniform. If the seed is not provided, the seed is set to 0.
    """

    def __init__(
        self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
    ):
        """Initialize the RandomGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population.
            n_points (int): The number of points to generate for the initial population.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            publisher (Publisher): The publisher to publish the messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.n_points = n_points
        self.evaluator = evaluator
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        This method should be implemented by the inherited classes.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out
        self.population = pl.from_numpy(
            self.rng.uniform(low=self.bounds[:, 0], high=self.bounds[:, 1], size=(self.n_points, self.bounds.shape[0])),
            schema=self.variable_symbols,
        )
        self.out = self.evaluator.evaluate(self.population)
        self.notify()
        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    n_points: int,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the RandomGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population.

required
n_points int

The number of points to generate for the initial population.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
publisher Publisher

The publisher to publish the messages.

required
Source code in desdeo/emo/operators/generator.py
def __init__(
    self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
):
    """Initialize the RandomGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population.
        n_points (int): The number of points to generate for the initial population.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        publisher (Publisher): The publisher to publish the messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.n_points = n_points
    self.evaluator = evaluator
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

This method should be implemented by the inherited classes.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    This method should be implemented by the inherited classes.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out
    self.population = pl.from_numpy(
        self.rng.uniform(low=self.bounds[:, 0], high=self.bounds[:, 1], size=(self.n_points, self.bounds.shape[0])),
        schema=self.variable_symbols,
    )
    self.out = self.evaluator.evaluate(self.population)
    self.notify()
    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

RandomIntegerGenerator

Bases: BaseGenerator

Class for generating random initial population for problems with integer variables.

This class generates an initial population by randomly setting variable values to be integers between the bounds of the variables.

Source code in desdeo/emo/operators/generator.py
class RandomIntegerGenerator(BaseGenerator):
    """Class for generating random initial population for problems with integer variables.

    This class generates an initial population by randomly setting variable values to be integers between the bounds of
    the variables.
    """

    def __init__(
        self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
    ):
        """Initialize the RandomIntegerGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population.
            n_points (int): The number of points to generate for the initial population.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            publisher (Publisher): The publisher to publish the messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.n_points = n_points
        self.evaluator = evaluator
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out

        self.population = pl.from_numpy(
            self.rng.integers(
                low=self.bounds[:, 0],
                high=self.bounds[:, 1],
                size=(self.n_points, self.bounds.shape[0]),
                endpoint=True,
            ).astype(dtype=float),
            schema=self.variable_symbols,
        )

        self.out = self.evaluator.evaluate(self.population)
        self.notify()
        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    n_points: int,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the RandomIntegerGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population.

required
n_points int

The number of points to generate for the initial population.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
publisher Publisher

The publisher to publish the messages.

required
Source code in desdeo/emo/operators/generator.py
def __init__(
    self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
):
    """Initialize the RandomIntegerGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population.
        n_points (int): The number of points to generate for the initial population.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        publisher (Publisher): The publisher to publish the messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.n_points = n_points
    self.evaluator = evaluator
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out

    self.population = pl.from_numpy(
        self.rng.integers(
            low=self.bounds[:, 0],
            high=self.bounds[:, 1],
            size=(self.n_points, self.bounds.shape[0]),
            endpoint=True,
        ).astype(dtype=float),
        schema=self.variable_symbols,
    )

    self.out = self.evaluator.evaluate(self.population)
    self.notify()
    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

RandomMixedIntegerGenerator

Bases: BaseGenerator

Class for generating random initial population for problems with mixed-integer variables.

This class generates an initial population by randomly setting variable values to be integers or floats between the bounds of the variables.

Source code in desdeo/emo/operators/generator.py
class RandomMixedIntegerGenerator(BaseGenerator):
    """Class for generating random initial population for problems with mixed-integer variables.

    This class generates an initial population by randomly setting variable
    values to be integers or floats between the bounds of the variables.
    """

    def __init__(
        self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
    ):
        """Initialize the RandomMixedIntegerGenerator class.

        Args:
            problem (Problem): The problem to solve.
            evaluator (BaseEvaluator): The evaluator to evaluate the population.
            n_points (int): The number of points to generate for the initial population.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
                an external archive. Otherwise, a verbosity of 1 is sufficient.
            publisher (Publisher): The publisher to publish the messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.var_symbol_types = {
            VariableTypeEnum.real: [
                var.symbol for var in problem.variables if var.variable_type == VariableTypeEnum.real
            ],
            VariableTypeEnum.integer: [
                var.symbol
                for var in problem.variables
                if var.variable_type in [VariableTypeEnum.integer, VariableTypeEnum.binary]
            ],
        }
        self.n_points = n_points
        self.evaluator = evaluator
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate the initial population.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
                the corresponding objectives, the constraint violations, and the targets as the second element.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out

        tmp = {
            var.symbol: self.rng.integers(
                low=var.lowerbound, high=var.upperbound, size=self.n_points, endpoint=True
            ).astype(dtype=float)
            if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]
            else self.rng.uniform(low=var.lowerbound, high=var.upperbound, size=self.n_points).astype(dtype=float)
            for var in self.problem.variables
        }

        # combine
        # self.population
        self.population = pl.DataFrame(tmp)

        self.out = self.evaluator.evaluate(self.population)
        self.notify()
        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem: Problem,
    evaluator: EMOEvaluator,
    n_points: int,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the RandomMixedIntegerGenerator class.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
evaluator BaseEvaluator

The evaluator to evaluate the population.

required
n_points int

The number of points to generate for the initial population.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain an external archive. Otherwise, a verbosity of 1 is sufficient.

required
publisher Publisher

The publisher to publish the messages.

required
Source code in desdeo/emo/operators/generator.py
def __init__(
    self, problem: Problem, evaluator: EMOEvaluator, n_points: int, seed: int, verbosity: int, publisher: Publisher
):
    """Initialize the RandomMixedIntegerGenerator class.

    Args:
        problem (Problem): The problem to solve.
        evaluator (BaseEvaluator): The evaluator to evaluate the population.
        n_points (int): The number of points to generate for the initial population.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the generator. A verbosity of 2 is needed if you want to maintain
            an external archive. Otherwise, a verbosity of 1 is sufficient.
        publisher (Publisher): The publisher to publish the messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.var_symbol_types = {
        VariableTypeEnum.real: [
            var.symbol for var in problem.variables if var.variable_type == VariableTypeEnum.real
        ],
        VariableTypeEnum.integer: [
            var.symbol
            for var in problem.variables
            if var.variable_type in [VariableTypeEnum.integer, VariableTypeEnum.binary]
        ],
    }
    self.n_points = n_points
    self.evaluator = evaluator
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate the initial population.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element, the corresponding objectives, the constraint violations, and the targets as the second element.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate the initial population.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: The initial population as the first element,
            the corresponding objectives, the constraint violations, and the targets as the second element.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out

    tmp = {
        var.symbol: self.rng.integers(
            low=var.lowerbound, high=var.upperbound, size=self.n_points, endpoint=True
        ).astype(dtype=float)
        if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]
        else self.rng.uniform(low=var.lowerbound, high=var.upperbound, size=self.n_points).astype(dtype=float)
        for var in self.problem.variables
    }

    # combine
    # self.population
    self.population = pl.DataFrame(tmp)

    self.out = self.evaluator.evaluate(self.population)
    self.notify()
    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

SeededHybridGenerator

Bases: BaseGenerator

Generates an initial population using a mix of seeded, perturbed, and random solutions.

Source code in desdeo/emo/operators/generator.py
class SeededHybridGenerator(BaseGenerator):
    """Generates an initial population using a mix of seeded, perturbed, and random solutions."""

    def __init__(
        self,
        problem,
        evaluator,
        publisher,
        verbosity,
        seed: int,
        n_points: int,
        seed_solution: pl.DataFrame,
        perturb_fraction: float = 0.2,
        sigma: float = 0.02,
        flip_prob: float = 0.1,
    ):
        """Initialize the seeded hybrid generator.

        The generator always includes the provided seed solution in the initial
        population, fills a fraction of the population with small perturbations
        around the seed, and fills the remainder with randomly generated solutions.

        Args:
            problem (Problem): The optimization problem.
            evaluator (EMOEvaluator): Evaluator used to compute objectives and constraints.
            publisher (Publisher): Publisher used for emitting generator messages.
            verbosity (int): Verbosity level of the generator.
            seed (int): Seed used for random number generation.
            n_points (int): Total size of the initial population.
            seed_solution (pl.DataFrame): A single-row DataFrame containing a seed
                decision variable vector.
            perturb_fraction (float, optional): Fraction of the population generated
                by perturbing the seed solution. Defaults to 0.2.
            sigma (float, optional): Relative perturbation scale with respect to
                variable ranges. Defaults to 0.02.
            flip_prob (float, optional): Probability of flipping a binary variable
                when perturbing the seed. Defaults to 0.1.

        Raises:
            TypeError: If ``seed_solution`` is not a polars DataFrame.
            ValueError: If ``seed_solution`` does not contain exactly one row.
            ValueError: If ``seed_solution`` columns do not match problem variables.
            ValueError: If ``n_points`` is not positive.
            ValueError: If ``perturb_fraction`` is outside ``[0, 1]``.
            ValueError: If ``sigma`` is negative.
            ValueError: If ``flip_prob`` is outside ``[0, 1]``.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)

        if not isinstance(seed_solution, pl.DataFrame):
            raise TypeError("seed_solution must be a polars DataFrame.")
        if seed_solution.shape[0] != 1:
            raise ValueError("seed_solution must have exactly one row.")
        if set(seed_solution.columns) != set(self.variable_symbols):
            raise ValueError("seed_solution columns must match problem variables.")

        if n_points <= 0:
            raise ValueError("n_points must be > 0.")
        if not (0.0 <= perturb_fraction <= 1.0):
            raise ValueError("perturb_fraction must be in [0, 1].")
        if sigma < 0:
            raise ValueError("sigma must be >= 0.")
        if not (0.0 <= flip_prob <= 1.0):
            raise ValueError("flip_prob must be in [0, 1].")

        self.n_points = n_points
        self.seed_solution = seed_solution
        self.perturb_fraction = perturb_fraction
        self.sigma = sigma
        self.flip_prob = flip_prob

        self.evaluator = evaluator
        self.seed = seed
        self.rng = np.random.default_rng(self.seed)

        self.population = None
        self.out = None

    def _random_population(self, n: int) -> pl.DataFrame:
        tmp = {}
        for var in self.problem.variables:
            if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
                vals = self.rng.integers(var.lowerbound, var.upperbound, size=n, endpoint=True).astype(float)
            else:
                vals = self.rng.uniform(var.lowerbound, var.upperbound, size=n).astype(float)
            tmp[var.symbol] = vals
        return pl.DataFrame(tmp)

    def _perturb_seed(self, n: int) -> pl.DataFrame:
        # includes the exact seed as first row
        seed_row = self.seed_solution.select(self.variable_symbols).to_dict(as_series=False)
        seed_vals = {k: float(v[0]) for k, v in seed_row.items()}

        rows = [seed_vals]  # ensure seed present
        if n <= 1:
            return pl.DataFrame(rows)

        for _ in range(n - 1):
            x = {}
            for var in self.problem.variables:
                lb, ub = float(var.lowerbound), float(var.upperbound)
                r = ub - lb

                v0 = seed_vals[var.symbol]

                if var.variable_type == VariableTypeEnum.binary:
                    v = 1.0 - v0 if self.rng.random() < self.flip_prob else v0
                elif var.variable_type == VariableTypeEnum.integer:
                    # scales integer nose
                    step = max(1, round(self.sigma * r)) if r >= 1 else 0
                    dv = self.rng.integers(-step, step + 1) if step > 0 else 0
                    v = float(int(np.clip(round(v0 + dv), lb, ub)))
                else:
                    # continuous noise is proportional to range
                    dv = self.rng.normal(0.0, self.sigma * r if r > 0 else 0.0)
                    v = float(np.clip(v0 + dv, lb, ub))

                x[var.symbol] = v
            rows.append(x)

        return pl.DataFrame(rows)

    def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
        """Generate a population.

        Returns:
            tuple[pl.DataFrame, pl.DataFrame]: the population.
        """
        if self.population is not None and self.out is not None:
            self.notify()
            return self.population, self.out

        n_pert = max(1, round(self.perturb_fraction * self.n_points))
        n_pert = min(n_pert, self.n_points)
        n_rand = self.n_points - n_pert

        pert = self._perturb_seed(n_pert)
        rand = self._random_population(n_rand) if n_rand > 0 else pl.DataFrame({s: [] for s in self.variable_symbols})

        self.population = pl.concat([pert, rand], how="vertical")

        self.out = self.evaluator.evaluate(self.population)
        self.notify()

        return self.population, self.out

    def update(self, message) -> None:
        """Update the generator based on the message."""
__init__
__init__(
    problem,
    evaluator,
    publisher,
    verbosity,
    seed: int,
    n_points: int,
    seed_solution: DataFrame,
    perturb_fraction: float = 0.2,
    sigma: float = 0.02,
    flip_prob: float = 0.1,
)

Initialize the seeded hybrid generator.

The generator always includes the provided seed solution in the initial population, fills a fraction of the population with small perturbations around the seed, and fills the remainder with randomly generated solutions.

Parameters:

Name Type Description Default
problem Problem

The optimization problem.

required
evaluator EMOEvaluator

Evaluator used to compute objectives and constraints.

required
publisher Publisher

Publisher used for emitting generator messages.

required
verbosity int

Verbosity level of the generator.

required
seed int

Seed used for random number generation.

required
n_points int

Total size of the initial population.

required
seed_solution DataFrame

A single-row DataFrame containing a seed decision variable vector.

required
perturb_fraction float

Fraction of the population generated by perturbing the seed solution. Defaults to 0.2.

0.2
sigma float

Relative perturbation scale with respect to variable ranges. Defaults to 0.02.

0.02
flip_prob float

Probability of flipping a binary variable when perturbing the seed. Defaults to 0.1.

0.1

Raises:

Type Description
TypeError

If seed_solution is not a polars DataFrame.

ValueError

If seed_solution does not contain exactly one row.

ValueError

If seed_solution columns do not match problem variables.

ValueError

If n_points is not positive.

ValueError

If perturb_fraction is outside [0, 1].

ValueError

If sigma is negative.

ValueError

If flip_prob is outside [0, 1].

Source code in desdeo/emo/operators/generator.py
def __init__(
    self,
    problem,
    evaluator,
    publisher,
    verbosity,
    seed: int,
    n_points: int,
    seed_solution: pl.DataFrame,
    perturb_fraction: float = 0.2,
    sigma: float = 0.02,
    flip_prob: float = 0.1,
):
    """Initialize the seeded hybrid generator.

    The generator always includes the provided seed solution in the initial
    population, fills a fraction of the population with small perturbations
    around the seed, and fills the remainder with randomly generated solutions.

    Args:
        problem (Problem): The optimization problem.
        evaluator (EMOEvaluator): Evaluator used to compute objectives and constraints.
        publisher (Publisher): Publisher used for emitting generator messages.
        verbosity (int): Verbosity level of the generator.
        seed (int): Seed used for random number generation.
        n_points (int): Total size of the initial population.
        seed_solution (pl.DataFrame): A single-row DataFrame containing a seed
            decision variable vector.
        perturb_fraction (float, optional): Fraction of the population generated
            by perturbing the seed solution. Defaults to 0.2.
        sigma (float, optional): Relative perturbation scale with respect to
            variable ranges. Defaults to 0.02.
        flip_prob (float, optional): Probability of flipping a binary variable
            when perturbing the seed. Defaults to 0.1.

    Raises:
        TypeError: If ``seed_solution`` is not a polars DataFrame.
        ValueError: If ``seed_solution`` does not contain exactly one row.
        ValueError: If ``seed_solution`` columns do not match problem variables.
        ValueError: If ``n_points`` is not positive.
        ValueError: If ``perturb_fraction`` is outside ``[0, 1]``.
        ValueError: If ``sigma`` is negative.
        ValueError: If ``flip_prob`` is outside ``[0, 1]``.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)

    if not isinstance(seed_solution, pl.DataFrame):
        raise TypeError("seed_solution must be a polars DataFrame.")
    if seed_solution.shape[0] != 1:
        raise ValueError("seed_solution must have exactly one row.")
    if set(seed_solution.columns) != set(self.variable_symbols):
        raise ValueError("seed_solution columns must match problem variables.")

    if n_points <= 0:
        raise ValueError("n_points must be > 0.")
    if not (0.0 <= perturb_fraction <= 1.0):
        raise ValueError("perturb_fraction must be in [0, 1].")
    if sigma < 0:
        raise ValueError("sigma must be >= 0.")
    if not (0.0 <= flip_prob <= 1.0):
        raise ValueError("flip_prob must be in [0, 1].")

    self.n_points = n_points
    self.seed_solution = seed_solution
    self.perturb_fraction = perturb_fraction
    self.sigma = sigma
    self.flip_prob = flip_prob

    self.evaluator = evaluator
    self.seed = seed
    self.rng = np.random.default_rng(self.seed)

    self.population = None
    self.out = None
do
do() -> tuple[pl.DataFrame, pl.DataFrame]

Generate a population.

Returns:

Type Description
tuple[DataFrame, DataFrame]

tuple[pl.DataFrame, pl.DataFrame]: the population.

Source code in desdeo/emo/operators/generator.py
def do(self) -> tuple[pl.DataFrame, pl.DataFrame]:
    """Generate a population.

    Returns:
        tuple[pl.DataFrame, pl.DataFrame]: the population.
    """
    if self.population is not None and self.out is not None:
        self.notify()
        return self.population, self.out

    n_pert = max(1, round(self.perturb_fraction * self.n_points))
    n_pert = min(n_pert, self.n_points)
    n_rand = self.n_points - n_pert

    pert = self._perturb_seed(n_pert)
    rand = self._random_population(n_rand) if n_rand > 0 else pl.DataFrame({s: [] for s in self.variable_symbols})

    self.population = pl.concat([pert, rand], how="vertical")

    self.out = self.evaluator.evaluate(self.population)
    self.notify()

    return self.population, self.out
update
update(message) -> None

Update the generator based on the message.

Source code in desdeo/emo/operators/generator.py
def update(self, message) -> None:
    """Update the generator based on the message."""

Evaluator

desdeo.emo.operators.evaluator

Classes for evaluating the objectives and constraints of the individuals in the population.

EMOEvaluator

Bases: Subscriber

Base class for evaluating the objectives and constraints of the individuals in the population.

This class should be inherited by the classes that implement the evaluation of the objectives and constraints of the individuals in the population.

Source code in desdeo/emo/operators/evaluator.py
class EMOEvaluator(Subscriber):
    """Base class for evaluating the objectives and constraints of the individuals in the population.

    This class should be inherited by the classes that implement the evaluation of the objectives
    and constraints of the individuals in the population.

    """

    @property
    def provided_topics(self) -> dict[int, Sequence[EvaluatorMessageTopics]]:
        """The topics provided by the Evaluator."""
        return {
            0: [],
            1: [EvaluatorMessageTopics.NEW_EVALUATIONS],
            2: [
                EvaluatorMessageTopics.NEW_EVALUATIONS,
                EvaluatorMessageTopics.VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        """The topics that the Evaluator is interested in."""
        return []

    def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
        """Initialize the EMOEvaluator class."""
        super().__init__(
            verbosity=verbosity,
            publisher=publisher,
        )
        self.problem = problem
        # Build the underlying evaluator once. Constructing it per call re-ran its setup on every
        # generation, and for a problem with surrogate objectives that setup is a joblib.load of
        # every surrogate from disk -- roughly 0.6 s per model per generation.
        self._simulator_evaluator = SimulatorEvaluator(problem)
        self._flattened_variable_symbols = [name.symbol for name in problem.get_flattened_variables()]
        self.variable_symbols = [name.symbol for name in problem.variables]
        self.population: pl.DataFrame
        self.out: pl.DataFrame
        self.new_evals: int = 0

    def evaluate(self, population: pl.DataFrame) -> pl.DataFrame:
        """Evaluate and return the objectives.

        Args:
            population (pl.Dataframe): The set of decision variables to evaluate.

        Returns:
            pl.Dataframe: A dataframe of objective vectors, target vectors, and constraint vectors.
        """
        self.population = population
        out = self._simulator_evaluator.evaluate(
            {symbol: population[symbol].to_list() for symbol in self._flattened_variable_symbols},
            flat=True,
        )
        # remove variable_symbols from the output
        self.out = out.drop(self.variable_symbols, strict=False)
        self.new_evals = len(population)
        # merge the objectives and targets

        self.notify()
        return self.out

    def state(self) -> Sequence[Message]:
        """The state of the evaluator sent to the Publisher."""
        if self.population is None or self.out is None or self.population is None or self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                IntMessage(
                    topic=EvaluatorMessageTopics.NEW_EVALUATIONS,
                    value=self.new_evals,
                    source=self.__class__.__name__,
                )
            ]

        if isinstance(self.population, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=EvaluatorMessageTopics.VERBOSE_OUTPUTS,
                value=self.population.hstack(self.out),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=EvaluatorMessageTopics.VERBOSE_OUTPUTS,
                value=self.out,
                source=self.__class__.__name__,
            )
        return [
            IntMessage(
                topic=EvaluatorMessageTopics.NEW_EVALUATIONS,
                value=self.new_evals,
                source=self.__class__.__name__,
            ),
            message,
        ]

    def update(self, *_, **__):
        """Update the parameters of the evaluator."""
interested_topics property
interested_topics

The topics that the Evaluator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[EvaluatorMessageTopics]]

The topics provided by the Evaluator.

__init__
__init__(
    problem: Problem, verbosity: int, publisher: Publisher
)

Initialize the EMOEvaluator class.

Source code in desdeo/emo/operators/evaluator.py
def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
    """Initialize the EMOEvaluator class."""
    super().__init__(
        verbosity=verbosity,
        publisher=publisher,
    )
    self.problem = problem
    # Build the underlying evaluator once. Constructing it per call re-ran its setup on every
    # generation, and for a problem with surrogate objectives that setup is a joblib.load of
    # every surrogate from disk -- roughly 0.6 s per model per generation.
    self._simulator_evaluator = SimulatorEvaluator(problem)
    self._flattened_variable_symbols = [name.symbol for name in problem.get_flattened_variables()]
    self.variable_symbols = [name.symbol for name in problem.variables]
    self.population: pl.DataFrame
    self.out: pl.DataFrame
    self.new_evals: int = 0
evaluate
evaluate(population: DataFrame) -> pl.DataFrame

Evaluate and return the objectives.

Parameters:

Name Type Description Default
population Dataframe

The set of decision variables to evaluate.

required

Returns:

Type Description
DataFrame

pl.Dataframe: A dataframe of objective vectors, target vectors, and constraint vectors.

Source code in desdeo/emo/operators/evaluator.py
def evaluate(self, population: pl.DataFrame) -> pl.DataFrame:
    """Evaluate and return the objectives.

    Args:
        population (pl.Dataframe): The set of decision variables to evaluate.

    Returns:
        pl.Dataframe: A dataframe of objective vectors, target vectors, and constraint vectors.
    """
    self.population = population
    out = self._simulator_evaluator.evaluate(
        {symbol: population[symbol].to_list() for symbol in self._flattened_variable_symbols},
        flat=True,
    )
    # remove variable_symbols from the output
    self.out = out.drop(self.variable_symbols, strict=False)
    self.new_evals = len(population)
    # merge the objectives and targets

    self.notify()
    return self.out
state
state() -> Sequence[Message]

The state of the evaluator sent to the Publisher.

Source code in desdeo/emo/operators/evaluator.py
def state(self) -> Sequence[Message]:
    """The state of the evaluator sent to the Publisher."""
    if self.population is None or self.out is None or self.population is None or self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            IntMessage(
                topic=EvaluatorMessageTopics.NEW_EVALUATIONS,
                value=self.new_evals,
                source=self.__class__.__name__,
            )
        ]

    if isinstance(self.population, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=EvaluatorMessageTopics.VERBOSE_OUTPUTS,
            value=self.population.hstack(self.out),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=EvaluatorMessageTopics.VERBOSE_OUTPUTS,
            value=self.out,
            source=self.__class__.__name__,
        )
    return [
        IntMessage(
            topic=EvaluatorMessageTopics.NEW_EVALUATIONS,
            value=self.new_evals,
            source=self.__class__.__name__,
        ),
        message,
    ]
update
update(*_, **__)

Update the parameters of the evaluator.

Source code in desdeo/emo/operators/evaluator.py
def update(self, *_, **__):
    """Update the parameters of the evaluator."""

Crossover operators

desdeo.emo.operators.crossover

Evolutionary operators for recombination.

Various evolutionary operators for recombination in multiobjective optimization are defined here.

BaseCrossover

Bases: Subscriber

A base class for crossover operators.

Source code in desdeo/emo/operators/crossover.py
class BaseCrossover(Subscriber):
    """A base class for crossover operators."""

    def __init__(self, problem: Problem, verbosity: int, publisher: Publisher, seed: int):
        """Initialize a crossover operator."""
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.problem = problem
        self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
        self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
        self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]

        self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
        self.variable_combination: VariableDomainTypeEnum = problem.variable_domain

        # Populated by `do`. Initialized here so that `state` can be called before the first
        # crossover, e.g. by a logger that reports the operator's state up front.
        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    # TODO(@light-weaver): The row order of the offspring returned by `do` is not consistent across
    # operators. Most of them build the two children separately and `np.vstack` them, so the output is
    # all first-children followed by all second-children; SimulatedBinaryCrossover.unbounded_offsprings
    # and LocalCrossover instead write offspring[i] / offspring[i+1] in place, so their children are
    # interleaved. Row i of the output is therefore not reliably the child of parent i, which makes it
    # impossible to trace lineage generically. Worth unifying on one convention and documenting it here.
    @abstractmethod
    def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
        """Perform the crossover operation.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """

    def get_parents(self, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
        """Just get the relevant parents from the population and set the parent population.

        Note:
            `DataFrame.to_numpy` hands back an F-contiguous array, and `np.zeros_like` preserves
            that order. Every `pl.from_numpy` in this module therefore states `orient="row"`: for
            a square offspring block (as many offspring as variables) polars cannot infer the
            orientation from the shape, and would read such an array column-wise, transposing it.
        """
        pop_size = population.shape[0]
        if to_mate is None:
            shuffled_ids = list(range(pop_size))
            self.rng.shuffle(shuffled_ids)
        else:
            shuffled_ids = copy.copy(to_mate)

        if len(shuffled_ids) % 2 == 1:
            shuffled_ids.append(shuffled_ids[0])
        self.parent_population = population[shuffled_ids]
        return self.parent_population
__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
)

Initialize a crossover operator.

Source code in desdeo/emo/operators/crossover.py
def __init__(self, problem: Problem, verbosity: int, publisher: Publisher, seed: int):
    """Initialize a crossover operator."""
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.problem = problem
    self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
    self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
    self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]

    self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
    self.variable_combination: VariableDomainTypeEnum = problem.variable_domain

    # Populated by `do`. Initialized here so that `state` can be called before the first
    # crossover, e.g. by a logger that reports the operator's state up front.
    self.parent_population: pl.DataFrame | None = None
    self.offspring_population: pl.DataFrame | None = None
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do abstractmethod
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform the crossover operation.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
@abstractmethod
def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
    """Perform the crossover operation.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
get_parents
get_parents(
    population: DataFrame, to_mate: list[int] | None = None
) -> pl.DataFrame

Just get the relevant parents from the population and set the parent population.

Note

DataFrame.to_numpy hands back an F-contiguous array, and np.zeros_like preserves that order. Every pl.from_numpy in this module therefore states orient="row": for a square offspring block (as many offspring as variables) polars cannot infer the orientation from the shape, and would read such an array column-wise, transposing it.

Source code in desdeo/emo/operators/crossover.py
def get_parents(self, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
    """Just get the relevant parents from the population and set the parent population.

    Note:
        `DataFrame.to_numpy` hands back an F-contiguous array, and `np.zeros_like` preserves
        that order. Every `pl.from_numpy` in this module therefore states `orient="row"`: for
        a square offspring block (as many offspring as variables) polars cannot infer the
        orientation from the shape, and would read such an array column-wise, transposing it.
    """
    pop_size = population.shape[0]
    if to_mate is None:
        shuffled_ids = list(range(pop_size))
        self.rng.shuffle(shuffled_ids)
    else:
        shuffled_ids = copy.copy(to_mate)

    if len(shuffled_ids) % 2 == 1:
        shuffled_ids.append(shuffled_ids[0])
    self.parent_population = population[shuffled_ids]
    return self.parent_population

BlendAlphaCrossover

Bases: BaseCrossover

Blend-alpha (BLX-alpha) crossover for continuous problems.

Each offspring component is drawn uniformly from the interval spanned by the two parent components, widened on both sides by alpha times that span and clipped to the variable bounds.

References

Eshelman, L. J., & Schaffer, J. D. (1993). Real-coded genetic algorithms and interval-schemata. In L. D. Whitley (Ed.), Foundations of Genetic Algorithms (Vol. 2, pp. 187-202). Elsevier. https://doi.org/10.1016/B978-0-08-094832-4.50018-0

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948

Source code in desdeo/emo/operators/crossover.py
class BlendAlphaCrossover(BaseCrossover):
    """Blend-alpha (BLX-alpha) crossover for continuous problems.

    Each offspring component is drawn uniformly from the interval spanned by the two parent
    components, widened on both sides by `alpha` times that span and clipped to the variable bounds.

    References:
        Eshelman, L. J., & Schaffer, J. D. (1993). Real-coded genetic algorithms and
            interval-schemata. In L. D. Whitley (Ed.), Foundations of Genetic Algorithms
            (Vol. 2, pp. 187-202). Elsevier. https://doi.org/10.1016/B978-0-08-094832-4.50018-0

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the blend alpha crossover operator.

        Note:
            The operator has no crossover probability, so it does not provide that topic.
        """
        return {
            0: [],
            1: [
                CrossoverMessageTopics.ALPHA,
            ],
            2: [
                CrossoverMessageTopics.ALPHA,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics provided by the blend alpha crossover operator."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
        alpha: float = 0.5,
        repeats: int = 2,
        sample_each_component: bool = True,
    ):
        """Initialize the blend alpha crossover operator.

        Details here: Eshelman, L. J., & Schaffer, J. D. (1993). Real-Coded Genetic Algorithms and Interval-Schemata.
        In L. D. Whitley (Ed.), Foundations of Genetic Algorithms (Vol. 2, pp. 187-202). Elsevier.
        https://doi.org/10.1016/B978-0-08-094832-4.50018-0


        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            seed (int): the seed used in the random number generator for choosing the crossover point.
            alpha (float, optional): non-negative blending factor 'alpha' that controls the extent to which
                offspring may be sampled outside the interval defined by each pair of parent
                genes. alpha = 0 restricts children strictly within the
                parents range, larger alpha allows outliers. Defaults to 0.5.
            repeats (int, optional): the number of times to repeat the crossover operation for a given pair of parents.
                Defaults to 2. Note that a value of 1 means that only one child will be generated for each pair of
                parents.
            sample_each_component (bool, optional): whether to sample each component of the offspring independently.
                If `True`, a new random number is generated for each component of the offspring. If `False`, a single
                random number is generated for the entire offspring. Defaults to `True`.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("BlendAlphaCrossover only works on continuous problems.")
        if alpha < 0:
            raise ValueError("Alpha must be non-negative.")

        self.alpha = alpha
        self.repeats = repeats
        self.sample_each_component = sample_each_component

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform BLX-alpha crossover _correctly_.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy()
        mating_pop_size = mating_pop.shape[0]
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
        num_var = mating_pop.shape[1]

        offspring_size = mating_pop_size / 2 * self.repeats
        offsprings = np.zeros((int(offspring_size), num_var))

        if self.sample_each_component:
            offspring_randoms = self.rng.random((int(offspring_size), num_var))
        else:
            offspring_randoms = self.rng.random((int(offspring_size), 1))

        for i in range(0, mating_pop_size, 2):
            p1 = mating_pop[i]
            p2 = mating_pop[i + 1]

            c_min = np.minimum(p1, p2)
            c_max = np.maximum(p1, p2)
            span = c_max - c_min

            lower = c_min - self.alpha * span
            upper = c_max + self.alpha * span
            lower = np.maximum(lower, self.lower_bounds)
            upper = np.minimum(upper, self.upper_bounds)

            for j in range(self.repeats):
                idx = (i // 2) * self.repeats + j
                offsprings[idx] = lower + offspring_randoms[idx] * (upper - lower)

        # An odd sized mating pool was padded with a duplicate parent, so the final pair produced a
        # full extra set of `repeats` offspring. Keep only as many as the unpadded pool would have
        # produced. Dropping a single row unconditionally is only correct when `repeats` is 2.
        if original_pop_size % 2 == 1:
            offsprings = offsprings[: (original_pop_size * self.repeats + 1) // 2, :]

        self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the blend-alpha crossover operator."""
        if self.parent_population is None:
            return []
        msgs: list[Message] = []
        if self.verbosity >= 1:
            msgs.append(
                FloatMessage(
                    topic=CrossoverMessageTopics.ALPHA,
                    source=self.__class__.__name__,
                    value=self.alpha,
                )
            )
        if self.verbosity >= 2:  # noqa: PLR2004
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )
        return msgs
interested_topics property
interested_topics

The message topics provided by the blend alpha crossover operator.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the blend alpha crossover operator.

Note

The operator has no crossover probability, so it does not provide that topic.

__init__
__init__(
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    alpha: float = 0.5,
    repeats: int = 2,
    sample_each_component: bool = True,
)

Initialize the blend alpha crossover operator.

Details here: Eshelman, L. J., & Schaffer, J. D. (1993). Real-Coded Genetic Algorithms and Interval-Schemata. In L. D. Whitley (Ed.), Foundations of Genetic Algorithms (Vol. 2, pp. 187-202). Elsevier. https://doi.org/10.1016/B978-0-08-094832-4.50018-0

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
seed int

the seed used in the random number generator for choosing the crossover point.

required
alpha float

non-negative blending factor 'alpha' that controls the extent to which offspring may be sampled outside the interval defined by each pair of parent genes. alpha = 0 restricts children strictly within the parents range, larger alpha allows outliers. Defaults to 0.5.

0.5
repeats int

the number of times to repeat the crossover operation for a given pair of parents. Defaults to 2. Note that a value of 1 means that only one child will be generated for each pair of parents.

2
sample_each_component bool

whether to sample each component of the offspring independently. If True, a new random number is generated for each component of the offspring. If False, a single random number is generated for the entire offspring. Defaults to True.

True
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    alpha: float = 0.5,
    repeats: int = 2,
    sample_each_component: bool = True,
):
    """Initialize the blend alpha crossover operator.

    Details here: Eshelman, L. J., & Schaffer, J. D. (1993). Real-Coded Genetic Algorithms and Interval-Schemata.
    In L. D. Whitley (Ed.), Foundations of Genetic Algorithms (Vol. 2, pp. 187-202). Elsevier.
    https://doi.org/10.1016/B978-0-08-094832-4.50018-0


    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        seed (int): the seed used in the random number generator for choosing the crossover point.
        alpha (float, optional): non-negative blending factor 'alpha' that controls the extent to which
            offspring may be sampled outside the interval defined by each pair of parent
            genes. alpha = 0 restricts children strictly within the
            parents range, larger alpha allows outliers. Defaults to 0.5.
        repeats (int, optional): the number of times to repeat the crossover operation for a given pair of parents.
            Defaults to 2. Note that a value of 1 means that only one child will be generated for each pair of
            parents.
        sample_each_component (bool, optional): whether to sample each component of the offspring independently.
            If `True`, a new random number is generated for each component of the offspring. If `False`, a single
            random number is generated for the entire offspring. Defaults to `True`.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("BlendAlphaCrossover only works on continuous problems.")
    if alpha < 0:
        raise ValueError("Alpha must be non-negative.")

    self.alpha = alpha
    self.repeats = repeats
    self.sample_each_component = sample_each_component
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform BLX-alpha crossover correctly.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform BLX-alpha crossover _correctly_.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy()
    mating_pop_size = mating_pop.shape[0]
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
    num_var = mating_pop.shape[1]

    offspring_size = mating_pop_size / 2 * self.repeats
    offsprings = np.zeros((int(offspring_size), num_var))

    if self.sample_each_component:
        offspring_randoms = self.rng.random((int(offspring_size), num_var))
    else:
        offspring_randoms = self.rng.random((int(offspring_size), 1))

    for i in range(0, mating_pop_size, 2):
        p1 = mating_pop[i]
        p2 = mating_pop[i + 1]

        c_min = np.minimum(p1, p2)
        c_max = np.maximum(p1, p2)
        span = c_max - c_min

        lower = c_min - self.alpha * span
        upper = c_max + self.alpha * span
        lower = np.maximum(lower, self.lower_bounds)
        upper = np.minimum(upper, self.upper_bounds)

        for j in range(self.repeats):
            idx = (i // 2) * self.repeats + j
            offsprings[idx] = lower + offspring_randoms[idx] * (upper - lower)

    # An odd sized mating pool was padded with a duplicate parent, so the final pair produced a
    # full extra set of `repeats` offspring. Keep only as many as the unpadded pool would have
    # produced. Dropping a single row unconditionally is only correct when `repeats` is 2.
    if original_pop_size % 2 == 1:
        offsprings = offsprings[: (original_pop_size * self.repeats + 1) // 2, :]

    self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the blend-alpha crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the blend-alpha crossover operator."""
    if self.parent_population is None:
        return []
    msgs: list[Message] = []
    if self.verbosity >= 1:
        msgs.append(
            FloatMessage(
                topic=CrossoverMessageTopics.ALPHA,
                source=self.__class__.__name__,
                value=self.alpha,
            )
        )
    if self.verbosity >= 2:  # noqa: PLR2004
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )
    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

BoundedExponentialCrossover

Bases: BaseCrossover

Bounded-exponential (BEX) crossover for continuous problems.

A parent centric operator: each offspring is displaced from its own parent by a bounded exponential deviate whose scale is lambda_ times the separation of the parents, truncated so that no offspring can fall outside the variable bounds. It is the bounded refinement of the Laplace crossover (LX) of Deep and Thakur, which has no such guarantee.

The reference derives the offspring under the assumption that the first parent holds the smaller value, and leaves the mirrored case to the reader; do implements that mirrored case, since a mating pool is unordered.

References

Thakur, M., Meghwani, S. S., & Jalota, H. (2014). A modified real coded genetic algorithm for constrained optimization. Applied Mathematics and Computation, 235, 292-317. https://doi.org/10.1016/j.amc.2014.02.093

Deep, K., & Thakur, M. (2007). A new crossover operator for real coded genetic algorithms. Applied Mathematics and Computation, 188(1), 895-911. (The Laplace crossover that BEX modifies.)

Source code in desdeo/emo/operators/crossover.py
class BoundedExponentialCrossover(BaseCrossover):
    """Bounded-exponential (BEX) crossover for continuous problems.

    A parent centric operator: each offspring is displaced from its own parent by a bounded
    exponential deviate whose scale is `lambda_` times the separation of the parents, truncated so
    that no offspring can fall outside the variable bounds. It is the bounded refinement of the
    Laplace crossover (LX) of Deep and Thakur, which has no such guarantee.

    The reference derives the offspring under the assumption that the first parent holds the smaller
    value, and leaves the mirrored case to the reader; `do` implements that mirrored case, since a
    mating pool is unordered.

    References:
        Thakur, M., Meghwani, S. S., & Jalota, H. (2014). A modified real coded genetic algorithm for
            constrained optimization. Applied Mathematics and Computation, 235, 292-317.
            https://doi.org/10.1016/j.amc.2014.02.093

        Deep, K., & Thakur, M. (2007). A new crossover operator for real coded genetic algorithms.
            Applied Mathematics and Computation, 188(1), 895-911.
            (The Laplace crossover that BEX modifies.)
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the bounded exponential crossover operator."""
        return {
            0: [],
            1: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.LAMBDA,
            ],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.LAMBDA,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics provided by the bounded exponential crossover operator."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
        lambda_: float = 0.1,
        xover_probability: float = 1.0,
        uniform_xover_probability: float = 0.0,
    ):
        """Initialize the bounded-exponential crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            seed (int): random seed for the internal generator.
            lambda_ (float, optional): positive scale λ for the exponential distribution.
                Defaults to 0.1. Larger values produce more widely dispersed offspring, smaller values produce offspring
                closer to the parents.
            xover_probability (float, optional): probability of applying crossover
                to each pair. Defaults to 1.0.
            uniform_xover_probability (float, optional): per-variable probability that the two offspring
                exchange which parent they descend from. Defaults to 0.0, which is the operator's
                original behaviour: every offspring keeps its own parent's identity in every variable.

                At 0.5 the operator gains a uniform-crossover component, the same one
                `SimulatedBinaryCrossover` carries under this name. It matters there: on a 105-problem
                grid the two SBX arms that differ *only* in this parameter placed 10.9x apart in median
                IGD+ regret, 0.0061 at 0.5 against 0.0665 at 0.0. Whether BEX responds the same way is
                an open question, which is why the default preserves the existing behaviour rather than
                assuming the transfer.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("BoundedExponentialCrossover only works on continuous problems.")
        if lambda_ <= 0:
            raise ValueError("lambda_ must be positive.")
        if not 0 <= xover_probability <= 1:
            raise ValueError("xover_probability must be in [0,1].")
        if not 0 <= uniform_xover_probability <= 1:
            raise ValueError("uniform_xover_probability must be in [0,1].")

        self.lambda_ = lambda_
        self.xover_probability = xover_probability
        self.uniform_xover_probability = uniform_xover_probability

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform bounded-exponential crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy()
        mating_pop_size = mating_pop.shape[0]
        num_var = mating_pop.shape[1]
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

        parents1 = mating_pop[0::2, :]
        parents2 = mating_pop[1::2, :]

        x_lower = np.array(self.lower_bounds)
        x_upper = np.array(self.upper_bounds)

        # The absolute separation |y_i - x_i| of the parents, which sets the scale of the
        # exponential. The reference derives beta under the stated assumption x_i < y_i and leaves
        # the mirrored case to the reader, but a mating pool is unordered, so both orderings occur
        # about equally often per decision variable. Using the *signed* difference flips the sign of
        # every exponent argument whenever x_i > y_i, which inverts the exponential: the density then
        # grows towards the truncation point instead of decaying away from the parent, so this parent
        # centric operator turns into a bound seeking one for roughly half of all variables. The
        # absolute separation is exactly the reference's mirrored case, and leaves the already
        # correct x_i < y_i ordering untouched.
        span = np.abs(parents2 - parents1)

        # Where the two parents share a value the span is zero and the offspring can only take that
        # same value, since every child is parent + beta * span. The exponent arguments below would
        # then divide by zero: harmless inf when the shared value is strictly inside the bounds, but
        # 0/0 -> nan when it sits exactly *on* a bound, which used to leak NaN decision variables
        # into the population (duplicate parents and bound-hugging variables are both common). Feed
        # the exponents a dummy span of one so that beta stays finite; multiplying by the true zero
        # span afterwards restores the parent value exactly.
        zero_span = span == 0
        safe_span = np.where(zero_span, 1.0, span)

        u_i = self.rng.random((mating_pop_size // 2, num_var))
        r_i = self.rng.random((mating_pop_size // 2, num_var))

        # Both branches of each np.where below are evaluated eagerly; the unused branch can legitimately
        # overflow or divide by zero, producing inf/nan that np.where discards.
        # Silence the resulting benign numpy floating-point warnings.
        with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
            exp_lower_1 = np.exp((x_lower - parents1) / (self.lambda_ * safe_span))
            exp_upper_1 = np.exp((parents1 - x_upper) / (self.lambda_ * safe_span))

            exp_lower_2 = np.exp((x_lower - parents2) / (self.lambda_ * safe_span))
            exp_upper_2 = np.exp((parents2 - x_upper) / (self.lambda_ * safe_span))

            beta_1 = np.where(
                r_i <= 0.5,  # noqa: PLR2004
                self.lambda_ * np.log(exp_lower_1 + u_i * (1 - exp_lower_1)),
                -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_1)),
            )

            beta_2 = np.where(
                r_i <= 0.5,  # noqa: PLR2004
                self.lambda_ * np.log(exp_lower_2 + u_i * (1 - exp_lower_2)),
                -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_2)),
            )

        # beta * span is already exactly zero wherever the span
        # is, but taking the parent value directly keeps a non-finite beta from reintroducing a NaN.
        offspring1 = np.where(zero_span, parents1, parents1 + beta_1 * span)
        offspring2 = np.where(zero_span, parents2, parents2 + beta_2 * span)

        # The uniform-crossover component, applied before the per-pair crossover probability so that
        # a pair which does not cross is returned as its parents untouched.
        #
        # Each BEX offspring is displaced from *its own* parent, so identity retention is structurally
        # 1.0: offspring one descends from parent one in every variable. Exchanging the two children's
        # values for a variable is what gives that variable to the other parent's line, and it is the
        # same operation `SimulatedBinaryCrossover` performs by flipping the sign of beta -- there the
        # two children sit symmetrically about the parent midpoint, so a sign flip swaps them exactly.
        if self.uniform_xover_probability > 0:
            swap = self.rng.random((mating_pop_size // 2, num_var)) <= self.uniform_xover_probability
            offspring1, offspring2 = (
                np.where(swap, offspring2, offspring1),
                np.where(swap, offspring1, offspring2),
            )

        mask = self.rng.random(mating_pop_size // 2) > self.xover_probability
        offspring1[mask, :] = parents1[mask, :]
        offspring2[mask, :] = parents2[mask, :]

        children = np.vstack((offspring1, offspring2))
        if original_pop_size % 2 == 1:
            children = children[:-1, :]

        self.offspring_population = pl.from_numpy(children, schema=self.variable_symbols, orient="row").select(
            pl.all().cast(pl.Float64)
        )
        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the crossover operator."""
        if self.parent_population is None:
            return []
        msgs: list[Message] = []
        if self.verbosity >= 1:
            msgs.append(
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.xover_probability,
                )
            )
            msgs.append(
                FloatMessage(
                    topic=CrossoverMessageTopics.LAMBDA,
                    source=self.__class__.__name__,
                    value=self.lambda_,
                )
            )
        if self.verbosity >= 2:  # noqa: PLR2004
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )
        return msgs
interested_topics property
interested_topics

The message topics provided by the bounded exponential crossover operator.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the bounded exponential crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    lambda_: float = 0.1,
    xover_probability: float = 1.0,
    uniform_xover_probability: float = 0.0,
)

Initialize the bounded-exponential crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
seed int

random seed for the internal generator.

required
lambda_ float

positive scale λ for the exponential distribution. Defaults to 0.1. Larger values produce more widely dispersed offspring, smaller values produce offspring closer to the parents.

0.1
xover_probability float

probability of applying crossover to each pair. Defaults to 1.0.

1.0
uniform_xover_probability float

per-variable probability that the two offspring exchange which parent they descend from. Defaults to 0.0, which is the operator's original behaviour: every offspring keeps its own parent's identity in every variable.

At 0.5 the operator gains a uniform-crossover component, the same one SimulatedBinaryCrossover carries under this name. It matters there: on a 105-problem grid the two SBX arms that differ only in this parameter placed 10.9x apart in median IGD+ regret, 0.0061 at 0.5 against 0.0665 at 0.0. Whether BEX responds the same way is an open question, which is why the default preserves the existing behaviour rather than assuming the transfer.

0.0
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    lambda_: float = 0.1,
    xover_probability: float = 1.0,
    uniform_xover_probability: float = 0.0,
):
    """Initialize the bounded-exponential crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        seed (int): random seed for the internal generator.
        lambda_ (float, optional): positive scale λ for the exponential distribution.
            Defaults to 0.1. Larger values produce more widely dispersed offspring, smaller values produce offspring
            closer to the parents.
        xover_probability (float, optional): probability of applying crossover
            to each pair. Defaults to 1.0.
        uniform_xover_probability (float, optional): per-variable probability that the two offspring
            exchange which parent they descend from. Defaults to 0.0, which is the operator's
            original behaviour: every offspring keeps its own parent's identity in every variable.

            At 0.5 the operator gains a uniform-crossover component, the same one
            `SimulatedBinaryCrossover` carries under this name. It matters there: on a 105-problem
            grid the two SBX arms that differ *only* in this parameter placed 10.9x apart in median
            IGD+ regret, 0.0061 at 0.5 against 0.0665 at 0.0. Whether BEX responds the same way is
            an open question, which is why the default preserves the existing behaviour rather than
            assuming the transfer.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("BoundedExponentialCrossover only works on continuous problems.")
    if lambda_ <= 0:
        raise ValueError("lambda_ must be positive.")
    if not 0 <= xover_probability <= 1:
        raise ValueError("xover_probability must be in [0,1].")
    if not 0 <= uniform_xover_probability <= 1:
        raise ValueError("uniform_xover_probability must be in [0,1].")

    self.lambda_ = lambda_
    self.xover_probability = xover_probability
    self.uniform_xover_probability = uniform_xover_probability
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform bounded-exponential crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform bounded-exponential crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy()
    mating_pop_size = mating_pop.shape[0]
    num_var = mating_pop.shape[1]
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

    parents1 = mating_pop[0::2, :]
    parents2 = mating_pop[1::2, :]

    x_lower = np.array(self.lower_bounds)
    x_upper = np.array(self.upper_bounds)

    # The absolute separation |y_i - x_i| of the parents, which sets the scale of the
    # exponential. The reference derives beta under the stated assumption x_i < y_i and leaves
    # the mirrored case to the reader, but a mating pool is unordered, so both orderings occur
    # about equally often per decision variable. Using the *signed* difference flips the sign of
    # every exponent argument whenever x_i > y_i, which inverts the exponential: the density then
    # grows towards the truncation point instead of decaying away from the parent, so this parent
    # centric operator turns into a bound seeking one for roughly half of all variables. The
    # absolute separation is exactly the reference's mirrored case, and leaves the already
    # correct x_i < y_i ordering untouched.
    span = np.abs(parents2 - parents1)

    # Where the two parents share a value the span is zero and the offspring can only take that
    # same value, since every child is parent + beta * span. The exponent arguments below would
    # then divide by zero: harmless inf when the shared value is strictly inside the bounds, but
    # 0/0 -> nan when it sits exactly *on* a bound, which used to leak NaN decision variables
    # into the population (duplicate parents and bound-hugging variables are both common). Feed
    # the exponents a dummy span of one so that beta stays finite; multiplying by the true zero
    # span afterwards restores the parent value exactly.
    zero_span = span == 0
    safe_span = np.where(zero_span, 1.0, span)

    u_i = self.rng.random((mating_pop_size // 2, num_var))
    r_i = self.rng.random((mating_pop_size // 2, num_var))

    # Both branches of each np.where below are evaluated eagerly; the unused branch can legitimately
    # overflow or divide by zero, producing inf/nan that np.where discards.
    # Silence the resulting benign numpy floating-point warnings.
    with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
        exp_lower_1 = np.exp((x_lower - parents1) / (self.lambda_ * safe_span))
        exp_upper_1 = np.exp((parents1 - x_upper) / (self.lambda_ * safe_span))

        exp_lower_2 = np.exp((x_lower - parents2) / (self.lambda_ * safe_span))
        exp_upper_2 = np.exp((parents2 - x_upper) / (self.lambda_ * safe_span))

        beta_1 = np.where(
            r_i <= 0.5,  # noqa: PLR2004
            self.lambda_ * np.log(exp_lower_1 + u_i * (1 - exp_lower_1)),
            -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_1)),
        )

        beta_2 = np.where(
            r_i <= 0.5,  # noqa: PLR2004
            self.lambda_ * np.log(exp_lower_2 + u_i * (1 - exp_lower_2)),
            -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_2)),
        )

    # beta * span is already exactly zero wherever the span
    # is, but taking the parent value directly keeps a non-finite beta from reintroducing a NaN.
    offspring1 = np.where(zero_span, parents1, parents1 + beta_1 * span)
    offspring2 = np.where(zero_span, parents2, parents2 + beta_2 * span)

    # The uniform-crossover component, applied before the per-pair crossover probability so that
    # a pair which does not cross is returned as its parents untouched.
    #
    # Each BEX offspring is displaced from *its own* parent, so identity retention is structurally
    # 1.0: offspring one descends from parent one in every variable. Exchanging the two children's
    # values for a variable is what gives that variable to the other parent's line, and it is the
    # same operation `SimulatedBinaryCrossover` performs by flipping the sign of beta -- there the
    # two children sit symmetrically about the parent midpoint, so a sign flip swaps them exactly.
    if self.uniform_xover_probability > 0:
        swap = self.rng.random((mating_pop_size // 2, num_var)) <= self.uniform_xover_probability
        offspring1, offspring2 = (
            np.where(swap, offspring2, offspring1),
            np.where(swap, offspring1, offspring2),
        )

    mask = self.rng.random(mating_pop_size // 2) > self.xover_probability
    offspring1[mask, :] = parents1[mask, :]
    offspring2[mask, :] = parents2[mask, :]

    children = np.vstack((offspring1, offspring2))
    if original_pop_size % 2 == 1:
        children = children[:-1, :]

    self.offspring_population = pl.from_numpy(children, schema=self.variable_symbols, orient="row").select(
        pl.all().cast(pl.Float64)
    )
    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the crossover operator."""
    if self.parent_population is None:
        return []
    msgs: list[Message] = []
    if self.verbosity >= 1:
        msgs.append(
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.xover_probability,
            )
        )
        msgs.append(
            FloatMessage(
                topic=CrossoverMessageTopics.LAMBDA,
                source=self.__class__.__name__,
                value=self.lambda_,
            )
        )
    if self.verbosity >= 2:  # noqa: PLR2004
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )
    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

CompositeCrossover

Bases: BaseCrossover

Combined crossover operator that combines multiple crossover operators.

Source code in desdeo/emo/operators/crossover.py
class CompositeCrossover(BaseCrossover):
    """Combined crossover operator that combines multiple crossover operators."""

    def __init__(
        self,
        *,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        operators: list[BaseCrossover],
        seed: int,
    ):
        """Initialize the composite crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            operators (list[BaseCrossover]): a list of crossover operators to combine.
            seed (int): the random seed for reproducibility. Not actually used here.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.operators = operators
        self.turn = 0

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform crossover using the next operator in the list.

        Args:
            population (pl.DataFrame): the population to perform the crossover with.
            to_mate (list[int] | None): indices of individuals to mate. If None, all individuals are considered.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        operator = self.operators[self.turn]
        offspring = operator.do(population=population, to_mate=to_mate)
        self.turn = (self.turn + 1) % len(self.operators)
        # No need to notify here, as each operator will handle its own notifications.
        return offspring

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """This crossover operator does not provide any topics itself."""
        return {0: [], 1: [], 2: []}

    @property
    def interested_topics(self):
        """This crossover operator does not have any interested topics itself."""
        return []

    def update(self, message: Message):
        """No need to update the composite operator itself. The publisher will handle the updates for each operator."""
        return

    def state(self) -> Sequence[Message]:
        """This crossover operator does not maintain its own state. For now."""
        return []
interested_topics property
interested_topics

This crossover operator does not have any interested topics itself.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

This crossover operator does not provide any topics itself.

__init__
__init__(
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    operators: list[BaseCrossover],
    seed: int,
)

Initialize the composite crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
operators list[BaseCrossover]

a list of crossover operators to combine.

required
seed int

the random seed for reproducibility. Not actually used here.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    operators: list[BaseCrossover],
    seed: int,
):
    """Initialize the composite crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        operators (list[BaseCrossover]): a list of crossover operators to combine.
        seed (int): the random seed for reproducibility. Not actually used here.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.operators = operators
    self.turn = 0
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform crossover using the next operator in the list.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with.

required
to_mate list[int] | None

indices of individuals to mate. If None, all individuals are considered.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform crossover using the next operator in the list.

    Args:
        population (pl.DataFrame): the population to perform the crossover with.
        to_mate (list[int] | None): indices of individuals to mate. If None, all individuals are considered.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    operator = self.operators[self.turn]
    offspring = operator.do(population=population, to_mate=to_mate)
    self.turn = (self.turn + 1) % len(self.operators)
    # No need to notify here, as each operator will handle its own notifications.
    return offspring
state
state() -> Sequence[Message]

This crossover operator does not maintain its own state. For now.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """This crossover operator does not maintain its own state. For now."""
    return []
update
update(message: Message)

No need to update the composite operator itself. The publisher will handle the updates for each operator.

Source code in desdeo/emo/operators/crossover.py
def update(self, message: Message):
    """No need to update the composite operator itself. The publisher will handle the updates for each operator."""
    return

DifferentialEvolutionCrossover

Bases: BaseCrossover

Differential evolution recombination, DE/rand/1/bin.

For each target vector the operator forms a mutant v = x_r1 + F * (x_r2 - x_r3) from three distinct other population members, then mixes v with the target componentwise at rate xover_probability, forcing at least one component to come from v so that no offspring is a copy of its target.

What separates this from the other continuous operators here is which individuals set the step size. Every other operator displaces an offspring relative to the parents being recombined, so a solution far from the rest of the population gets a large step and one inside a tight cluster gets a small one. DE's donors are unrelated to the target, so its step is set by the spread of the population at large. Measured on a population of 60 holding one outlier: the outlier's offspring moves 14.3x further than a cluster member's under DE, against 2.1x under SBX.

All three of SBX, PCX and DE do contract as the population converges -- SBX's displacement is proportional to the parent difference, so it is not the fixed-versus-adaptive contrast it is sometimes described as.

Unlike the other operators in this module, the returned offspring are target aligned: row i of the output is the child of to_mate[i]. The other operators either stack all first children ahead of all second children or interleave them, so lineage is not generically traceable; see the note on BaseCrossover.do.

References

Storn, R., & Price, K. (1997). Differential Evolution - A Simple and Efficient Heuristic for Global Optimization over Continuous Spaces. Journal of Global Optimization, 11(4), 341-359. https://doi.org/10.1023/A:1008202821328

Kukkonen, S., & Lampinen, J. (2005). GDE3: The third evolution step of generalized differential evolution. In 2005 IEEE Congress on Evolutionary Computation (pp. 443-450). https://doi.org/10.1109/CEC.2005.1554717

Source code in desdeo/emo/operators/crossover.py
class DifferentialEvolutionCrossover(BaseCrossover):
    """Differential evolution recombination, DE/rand/1/bin.

    For each target vector the operator forms a mutant `v = x_r1 + F * (x_r2 - x_r3)` from three
    distinct other population members, then mixes `v` with the target componentwise at rate
    `xover_probability`, forcing at least one component to come from `v` so that no offspring is a
    copy of its target.

    What separates this from the other continuous operators here is *which* individuals set the step
    size. Every other operator displaces an offspring relative to the parents being recombined, so a
    solution far from the rest of the population gets a large step and one inside a tight cluster
    gets a small one. DE's donors are unrelated to the target, so its step is set by the spread of
    the population at large. Measured on a population of 60 holding one outlier: the outlier's
    offspring moves 14.3x further than a cluster member's under DE, against 2.1x under SBX.

    All three of SBX, PCX and DE do contract as the population converges -- SBX's displacement is
    proportional to the parent difference, so it is not the fixed-versus-adaptive contrast it is
    sometimes described as.

    Unlike the other operators in this module, the returned offspring are **target aligned**: row `i`
    of the output is the child of `to_mate[i]`. The other operators either stack all first children
    ahead of all second children or interleave them, so lineage is not generically traceable; see the
    note on `BaseCrossover.do`.

    References:
        Storn, R., & Price, K. (1997). Differential Evolution - A Simple and Efficient Heuristic for
            Global Optimization over Continuous Spaces. Journal of Global Optimization, 11(4),
            341-359. https://doi.org/10.1023/A:1008202821328

        Kukkonen, S., & Lampinen, J. (2005). GDE3: The third evolution step of generalized
            differential evolution. In 2005 IEEE Congress on Evolutionary Computation (pp. 443-450).
            https://doi.org/10.1109/CEC.2005.1554717
    """

    _MIN_POPULATION = 4
    """A target plus three distinct donors."""

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the differential evolution crossover operator."""
        return {
            0: [],
            1: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.SCALING_FACTOR,
            ],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.SCALING_FACTOR,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The differential evolution crossover operator listens to nothing."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
        scaling_factor: float = 0.5,
        xover_probability: float = 0.9,
    ):
        """Initialize the differential evolution crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
                what topics are provided by the operator at each verbosity level.
            publisher (Publisher): the publisher to which the operator will publish messages.
            seed (int): the seed used in the random number generator.
            scaling_factor (float, optional): the factor `F` applied to the difference vector.
                Defaults to 0.5, the midpoint of the 0.4-1.0 range Storn and Price recommend.
            xover_probability (float, optional): the binomial crossover rate `CR`, the per-component
                probability that the offspring takes the mutant's value rather than the target's.
                Defaults to 0.9.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("DifferentialEvolutionCrossover only works on continuous problems.")
        if scaling_factor <= 0:
            raise ValueError("scaling_factor must be positive.")
        if not 0 <= xover_probability <= 1:
            raise ValueError("xover_probability must be in [0,1].")

        self.scaling_factor = scaling_factor
        self.xover_probability = xover_probability

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform DE/rand/1/bin crossover, producing one offspring per target.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover, one row per target.
        """
        whole = population[self.variable_symbols].to_numpy()
        pop_size = whole.shape[0]
        if pop_size < self._MIN_POPULATION:
            raise ValueError(
                f"DifferentialEvolutionCrossover needs at least {self._MIN_POPULATION} individuals, "
                f"a target and three distinct donors, but the population holds {pop_size}."
            )

        # `get_parents` is not used: it pads an odd mating pool with a duplicate so that pairs come
        # out even, and DE has no pairs. The donors are drawn from the whole population, which is
        # what DE/rand/1 specifies, so `to_mate` selects targets only.
        targets = np.arange(pop_size) if to_mate is None else np.asarray(to_mate, dtype=np.int64)
        num_offspring, num_var = targets.shape[0], whole.shape[1]
        self.parent_population = population[targets.tolist()]

        donors = _distinct_indices(self.rng, targets, pop_size, k=3)
        mutant = whole[donors[:, 0]] + self.scaling_factor * (whole[donors[:, 1]] - whole[donors[:, 2]])

        take_mutant = self.rng.random((num_offspring, num_var)) < self.xover_probability
        # One component is always inherited from the mutant, so an offspring is never a copy of its
        # target even at xover_probability = 0. This is the `jrand` of the original formulation.
        forced = self.rng.integers(0, num_var, size=num_offspring)
        take_mutant[np.arange(num_offspring), forced] = True

        offsprings = np.where(take_mutant, mutant, whole[targets])
        offsprings = np.clip(offsprings, np.asarray(self.lower_bounds), np.asarray(self.upper_bounds))

        self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the differential evolution crossover operator."""
        if self.parent_population is None:
            return []
        msgs: list[Message] = []
        if self.verbosity >= 1:
            msgs.extend(
                [
                    FloatMessage(
                        topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                        source=self.__class__.__name__,
                        value=self.xover_probability,
                    ),
                    FloatMessage(
                        topic=CrossoverMessageTopics.SCALING_FACTOR,
                        source=self.__class__.__name__,
                        value=self.scaling_factor,
                    ),
                ]
            )
        if self.verbosity >= 2:  # noqa: PLR2004
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )
        return msgs
_MIN_POPULATION class-attribute instance-attribute
_MIN_POPULATION = 4

A target plus three distinct donors.

interested_topics property
interested_topics

The differential evolution crossover operator listens to nothing.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the differential evolution crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    scaling_factor: float = 0.5,
    xover_probability: float = 0.9,
)

Initialize the differential evolution crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
seed int

the seed used in the random number generator.

required
scaling_factor float

the factor F applied to the difference vector. Defaults to 0.5, the midpoint of the 0.4-1.0 range Storn and Price recommend.

0.5
xover_probability float

the binomial crossover rate CR, the per-component probability that the offspring takes the mutant's value rather than the target's. Defaults to 0.9.

0.9
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    scaling_factor: float = 0.5,
    xover_probability: float = 0.9,
):
    """Initialize the differential evolution crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
            what topics are provided by the operator at each verbosity level.
        publisher (Publisher): the publisher to which the operator will publish messages.
        seed (int): the seed used in the random number generator.
        scaling_factor (float, optional): the factor `F` applied to the difference vector.
            Defaults to 0.5, the midpoint of the 0.4-1.0 range Storn and Price recommend.
        xover_probability (float, optional): the binomial crossover rate `CR`, the per-component
            probability that the offspring takes the mutant's value rather than the target's.
            Defaults to 0.9.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("DifferentialEvolutionCrossover only works on continuous problems.")
    if scaling_factor <= 0:
        raise ValueError("scaling_factor must be positive.")
    if not 0 <= xover_probability <= 1:
        raise ValueError("xover_probability must be in [0,1].")

    self.scaling_factor = scaling_factor
    self.xover_probability = xover_probability
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform DE/rand/1/bin crossover, producing one offspring per target.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover, one row per target.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform DE/rand/1/bin crossover, producing one offspring per target.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover, one row per target.
    """
    whole = population[self.variable_symbols].to_numpy()
    pop_size = whole.shape[0]
    if pop_size < self._MIN_POPULATION:
        raise ValueError(
            f"DifferentialEvolutionCrossover needs at least {self._MIN_POPULATION} individuals, "
            f"a target and three distinct donors, but the population holds {pop_size}."
        )

    # `get_parents` is not used: it pads an odd mating pool with a duplicate so that pairs come
    # out even, and DE has no pairs. The donors are drawn from the whole population, which is
    # what DE/rand/1 specifies, so `to_mate` selects targets only.
    targets = np.arange(pop_size) if to_mate is None else np.asarray(to_mate, dtype=np.int64)
    num_offspring, num_var = targets.shape[0], whole.shape[1]
    self.parent_population = population[targets.tolist()]

    donors = _distinct_indices(self.rng, targets, pop_size, k=3)
    mutant = whole[donors[:, 0]] + self.scaling_factor * (whole[donors[:, 1]] - whole[donors[:, 2]])

    take_mutant = self.rng.random((num_offspring, num_var)) < self.xover_probability
    # One component is always inherited from the mutant, so an offspring is never a copy of its
    # target even at xover_probability = 0. This is the `jrand` of the original formulation.
    forced = self.rng.integers(0, num_var, size=num_offspring)
    take_mutant[np.arange(num_offspring), forced] = True

    offsprings = np.where(take_mutant, mutant, whole[targets])
    offsprings = np.clip(offsprings, np.asarray(self.lower_bounds), np.asarray(self.upper_bounds))

    self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the differential evolution crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the differential evolution crossover operator."""
    if self.parent_population is None:
        return []
    msgs: list[Message] = []
    if self.verbosity >= 1:
        msgs.extend(
            [
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.xover_probability,
                ),
                FloatMessage(
                    topic=CrossoverMessageTopics.SCALING_FACTOR,
                    source=self.__class__.__name__,
                    value=self.scaling_factor,
                ),
            ]
        )
    if self.verbosity >= 2:  # noqa: PLR2004
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )
    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

LocalCrossover

Bases: BaseCrossover

Local Crossover for continuous problems.

An arithmetic crossover that draws a fresh blending weight for every decision variable of every mating pair, rather than one weight for the whole vector. The two offspring use complementary weights, so each pair spans the segment between the parents component by component.

References

Dumitrescu, D., Lazzerini, B., Jain, L. C., & Dumitrescu, A. (2000). Evolutionary Computation. CRC Press, Florida, USA.

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948

Source code in desdeo/emo/operators/crossover.py
class LocalCrossover(BaseCrossover):
    """Local Crossover for continuous problems.

    An arithmetic crossover that draws a fresh blending weight for every decision variable of every
    mating pair, rather than one weight for the whole vector. The two offspring use complementary
    weights, so each pair spans the segment between the parents component by component.

    References:
        Dumitrescu, D., Lazzerini, B., Jain, L. C., & Dumitrescu, A. (2000). Evolutionary Computation.
            CRC Press, Florida, USA.

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the local crossover operator.

        Note:
            The operator has no crossover probability, so it does not provide that topic.
        """
        return {
            0: [],
            1: [],
            2: [
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the local crossover operator is interested in."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
    ):
        """Initialize the local crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            seed (int): random seed for reproducibility.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("LocalCrossover only works on continuous problems.")

    def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
        """Perform Local Crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy()
        mating_pop_size = mating_pop.shape[0]
        num_var = mating_pop.shape[1]
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

        parents1 = mating_pop[0::2]
        parents2 = mating_pop[1::2]

        offspring = np.empty((mating_pop_size, num_var))

        for i in range(mating_pop_size // 2):
            alpha = self.rng.random(num_var)

            offspring[2 * i] = alpha * parents1[i] + (1 - alpha) * parents2[i]
            offspring[2 * i + 1] = (1 - alpha) * parents1[i] + alpha * parents2[i]

        # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
        # offspring too many. Drop it, as every other crossover operator here does.
        if original_pop_size % 2 == 1:
            offspring = offspring[:-1, :]

        self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(
            pl.all().cast(pl.Float64)
        )

        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the local crossover operator."""
        if self.parent_population is None:
            return []

        msgs: list[Message] = []

        if self.verbosity >= 2:  # noqa: PLR2004
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )
        return msgs
interested_topics property
interested_topics

The message topics that the local crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the local crossover operator.

Note

The operator has no crossover probability, so it does not provide that topic.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
)

Initialize the local crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
seed int

random seed for reproducibility.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
):
    """Initialize the local crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        seed (int): random seed for reproducibility.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("LocalCrossover only works on continuous problems.")
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform Local Crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
    """Perform Local Crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy()
    mating_pop_size = mating_pop.shape[0]
    num_var = mating_pop.shape[1]
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

    parents1 = mating_pop[0::2]
    parents2 = mating_pop[1::2]

    offspring = np.empty((mating_pop_size, num_var))

    for i in range(mating_pop_size // 2):
        alpha = self.rng.random(num_var)

        offspring[2 * i] = alpha * parents1[i] + (1 - alpha) * parents2[i]
        offspring[2 * i + 1] = (1 - alpha) * parents1[i] + alpha * parents2[i]

    # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
    # offspring too many. Drop it, as every other crossover operator here does.
    if original_pop_size % 2 == 1:
        offspring = offspring[:-1, :]

    self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(
        pl.all().cast(pl.Float64)
    )

    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the local crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the local crossover operator."""
    if self.parent_population is None:
        return []

    msgs: list[Message] = []

    if self.verbosity >= 2:  # noqa: PLR2004
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )
    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

ParentCentricCrossover

Bases: BaseCrossover

Parent-centric crossover (PCX) for continuous problems.

Three parents are drawn per offspring. One of them is the index parent, and the offspring is placed near it: displaced along the direction from the parental centroid to the index parent by a normal draw of standard deviation sigma_zeta, and orthogonally to that direction by a normal draw of standard deviation sigma_eta, scaled by how far the other two parents sit from that direction. The population's own geometry therefore sets the step size in both directions.

The contrast with SBX, the other parent-centric operator here, is structural rather than a matter of scale. SBX perturbs each decision variable independently along its own axis and has no notion of a direction in decision space. PCX's displacement decomposes into a component along the centroid-to-parent direction and an isotropic component orthogonal to it, so the parental geometry decides where the offspring goes and not merely how far. With sigma_eta at zero the orthogonal part vanishes and every offspring lies exactly on the centroid-to-parent ray, which is what test_parent_centric_crossover_displaces_along_the_centroid_direction checks.

Note

Deb, Anand and Joshi write the orthogonal part as a sum over an orthonormal basis of the complement of the parent-to-centroid direction, with an independent normal coefficient on each basis vector. Building that basis costs a Gram-Schmidt pass per offspring. This implementation instead draws an isotropic normal vector in the full space and projects the parent-to-centroid component out of it, which has exactly the same distribution -- an isotropic Gaussian projected onto a subspace is an isotropic Gaussian on that subspace -- and costs one dot product.

References

Deb, K., Anand, A., & Joshi, D. (2002). A computationally efficient evolutionary algorithm for real-parameter optimization. Evolutionary Computation, 10(4), 371-395. https://doi.org/10.1162/106365602760972767

Source code in desdeo/emo/operators/crossover.py
class ParentCentricCrossover(BaseCrossover):
    """Parent-centric crossover (PCX) for continuous problems.

    Three parents are drawn per offspring. One of them is the *index* parent, and the offspring is
    placed near it: displaced along the direction from the parental centroid to the index parent by a
    normal draw of standard deviation `sigma_zeta`, and orthogonally to that direction by a normal
    draw of standard deviation `sigma_eta`, scaled by how far the other two parents sit from that
    direction. The population's own geometry therefore sets the step size in both directions.

    The contrast with SBX, the other parent-centric operator here, is structural rather than a matter
    of scale. SBX perturbs each decision variable independently along its own axis and has no notion
    of a direction in decision space. PCX's displacement decomposes into a component along the
    centroid-to-parent direction and an isotropic component orthogonal to it, so the parental
    geometry decides where the offspring goes and not merely how far. With `sigma_eta` at zero the
    orthogonal part vanishes and every offspring lies exactly on the centroid-to-parent ray, which is
    what `test_parent_centric_crossover_displaces_along_the_centroid_direction` checks.

    Note:
        Deb, Anand and Joshi write the orthogonal part as a sum over an orthonormal basis of the
        complement of the parent-to-centroid direction, with an independent normal coefficient on
        each basis vector. Building that basis costs a Gram-Schmidt pass per offspring. This
        implementation instead draws an isotropic normal vector in the full space and projects the
        parent-to-centroid component out of it, which has exactly the same distribution -- an
        isotropic Gaussian projected onto a subspace is an isotropic Gaussian on that subspace -- and
        costs one dot product.

    References:
        Deb, K., Anand, A., & Joshi, D. (2002). A computationally efficient evolutionary algorithm
            for real-parameter optimization. Evolutionary Computation, 10(4), 371-395.
            https://doi.org/10.1162/106365602760972767
    """

    _MIN_POPULATION = 3
    """An index parent and two others to set the orthogonal scale."""

    _DEGENERATE = 1e-12
    """Below this, the index parent sits on the centroid and the direction is undefined."""

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the parent-centric crossover operator.

        Note:
            The operator recombines every selected parent, so it has no crossover probability and
            does not provide that topic.
        """
        return {
            0: [],
            1: [
                CrossoverMessageTopics.SIGMA_ZETA,
                CrossoverMessageTopics.SIGMA_ETA,
            ],
            2: [
                CrossoverMessageTopics.SIGMA_ZETA,
                CrossoverMessageTopics.SIGMA_ETA,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The parent-centric crossover operator listens to nothing."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
        sigma_zeta: float = 0.1,
        sigma_eta: float = 0.1,
    ):
        """Initialize the parent-centric crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
                what topics are provided by the operator at each verbosity level.
            publisher (Publisher): the publisher to which the operator will publish messages.
            seed (int): the seed used in the random number generator.
            sigma_zeta (float, optional): standard deviation of the displacement along the
                centroid-to-index-parent direction. Defaults to 0.1, the value used throughout Deb,
                Anand and Joshi (2002).
            sigma_eta (float, optional): standard deviation of the displacement orthogonal to that
                direction, in units of the mean perpendicular distance of the other parents.
                Defaults to 0.1.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("ParentCentricCrossover only works on continuous problems.")
        if sigma_zeta <= 0:
            raise ValueError("sigma_zeta must be positive.")
        if sigma_eta <= 0:
            raise ValueError("sigma_eta must be positive.")

        self.sigma_zeta = sigma_zeta
        self.sigma_eta = sigma_eta

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform PCX, producing one offspring per index parent.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover, one row per index parent.
        """
        whole = population[self.variable_symbols].to_numpy()
        pop_size = whole.shape[0]
        if pop_size < self._MIN_POPULATION:
            raise ValueError(
                f"ParentCentricCrossover needs at least {self._MIN_POPULATION} individuals, but the "
                f"population holds {pop_size}."
            )

        # As in DE, `get_parents` is not used: PCX draws a triple rather than a pair, so padding the
        # pool to an even length would serve no purpose. `to_mate` selects the index parents.
        index_parents = np.arange(pop_size) if to_mate is None else np.asarray(to_mate, dtype=np.int64)
        num_offspring, num_var = index_parents.shape[0], whole.shape[1]
        self.parent_population = population[index_parents.tolist()]

        others = _distinct_indices(self.rng, index_parents, pop_size, k=2)
        index_x = whole[index_parents]
        other_a, other_b = whole[others[:, 0]], whole[others[:, 1]]

        centroid = (index_x + other_a + other_b) / 3.0
        direction = index_x - centroid
        norm = np.linalg.norm(direction, axis=1, keepdims=True)
        # An index parent sitting on the centroid means all three parents coincide. The direction is
        # then undefined; fall back to an unprojected isotropic step, which is the limit of the
        # operator as the triangle collapses.
        degenerate = norm[:, 0] < self._DEGENERATE
        unit = np.divide(direction, np.where(norm < self._DEGENERATE, 1.0, norm))

        # Mean perpendicular distance of the other two parents from the line through the index parent.
        perpendicular = []
        for other in (other_a, other_b):
            offset = other - index_x
            along = np.sum(offset * unit, axis=1, keepdims=True) * unit
            perpendicular.append(np.linalg.norm(offset - along, axis=1))
        spread = np.mean(perpendicular, axis=0)[:, None]

        orthogonal = self.rng.normal(0.0, self.sigma_eta, size=(num_offspring, num_var))
        projection = np.sum(orthogonal * unit, axis=1, keepdims=True) * unit
        orthogonal = np.where(degenerate[:, None], orthogonal, orthogonal - projection)

        along_direction = self.rng.normal(0.0, self.sigma_zeta, size=(num_offspring, 1))
        offsprings = index_x + along_direction * direction + spread * orthogonal
        offsprings = np.clip(offsprings, np.asarray(self.lower_bounds), np.asarray(self.upper_bounds))

        self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the parent-centric crossover operator."""
        if self.parent_population is None:
            return []
        msgs: list[Message] = []
        if self.verbosity >= 1:
            msgs.extend(
                [
                    FloatMessage(
                        topic=CrossoverMessageTopics.SIGMA_ZETA,
                        source=self.__class__.__name__,
                        value=self.sigma_zeta,
                    ),
                    FloatMessage(
                        topic=CrossoverMessageTopics.SIGMA_ETA,
                        source=self.__class__.__name__,
                        value=self.sigma_eta,
                    ),
                ]
            )
        if self.verbosity >= 2:  # noqa: PLR2004
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )
        return msgs
_DEGENERATE class-attribute instance-attribute
_DEGENERATE = 1e-12

Below this, the index parent sits on the centroid and the direction is undefined.

_MIN_POPULATION class-attribute instance-attribute
_MIN_POPULATION = 3

An index parent and two others to set the orthogonal scale.

interested_topics property
interested_topics

The parent-centric crossover operator listens to nothing.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the parent-centric crossover operator.

Note

The operator recombines every selected parent, so it has no crossover probability and does not provide that topic.

__init__
__init__(
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    sigma_zeta: float = 0.1,
    sigma_eta: float = 0.1,
)

Initialize the parent-centric crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
seed int

the seed used in the random number generator.

required
sigma_zeta float

standard deviation of the displacement along the centroid-to-index-parent direction. Defaults to 0.1, the value used throughout Deb, Anand and Joshi (2002).

0.1
sigma_eta float

standard deviation of the displacement orthogonal to that direction, in units of the mean perpendicular distance of the other parents. Defaults to 0.1.

0.1
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    sigma_zeta: float = 0.1,
    sigma_eta: float = 0.1,
):
    """Initialize the parent-centric crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
            what topics are provided by the operator at each verbosity level.
        publisher (Publisher): the publisher to which the operator will publish messages.
        seed (int): the seed used in the random number generator.
        sigma_zeta (float, optional): standard deviation of the displacement along the
            centroid-to-index-parent direction. Defaults to 0.1, the value used throughout Deb,
            Anand and Joshi (2002).
        sigma_eta (float, optional): standard deviation of the displacement orthogonal to that
            direction, in units of the mean perpendicular distance of the other parents.
            Defaults to 0.1.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("ParentCentricCrossover only works on continuous problems.")
    if sigma_zeta <= 0:
        raise ValueError("sigma_zeta must be positive.")
    if sigma_eta <= 0:
        raise ValueError("sigma_eta must be positive.")

    self.sigma_zeta = sigma_zeta
    self.sigma_eta = sigma_eta
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform PCX, producing one offspring per index parent.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover, one row per index parent.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform PCX, producing one offspring per index parent.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover, one row per index parent.
    """
    whole = population[self.variable_symbols].to_numpy()
    pop_size = whole.shape[0]
    if pop_size < self._MIN_POPULATION:
        raise ValueError(
            f"ParentCentricCrossover needs at least {self._MIN_POPULATION} individuals, but the "
            f"population holds {pop_size}."
        )

    # As in DE, `get_parents` is not used: PCX draws a triple rather than a pair, so padding the
    # pool to an even length would serve no purpose. `to_mate` selects the index parents.
    index_parents = np.arange(pop_size) if to_mate is None else np.asarray(to_mate, dtype=np.int64)
    num_offspring, num_var = index_parents.shape[0], whole.shape[1]
    self.parent_population = population[index_parents.tolist()]

    others = _distinct_indices(self.rng, index_parents, pop_size, k=2)
    index_x = whole[index_parents]
    other_a, other_b = whole[others[:, 0]], whole[others[:, 1]]

    centroid = (index_x + other_a + other_b) / 3.0
    direction = index_x - centroid
    norm = np.linalg.norm(direction, axis=1, keepdims=True)
    # An index parent sitting on the centroid means all three parents coincide. The direction is
    # then undefined; fall back to an unprojected isotropic step, which is the limit of the
    # operator as the triangle collapses.
    degenerate = norm[:, 0] < self._DEGENERATE
    unit = np.divide(direction, np.where(norm < self._DEGENERATE, 1.0, norm))

    # Mean perpendicular distance of the other two parents from the line through the index parent.
    perpendicular = []
    for other in (other_a, other_b):
        offset = other - index_x
        along = np.sum(offset * unit, axis=1, keepdims=True) * unit
        perpendicular.append(np.linalg.norm(offset - along, axis=1))
    spread = np.mean(perpendicular, axis=0)[:, None]

    orthogonal = self.rng.normal(0.0, self.sigma_eta, size=(num_offspring, num_var))
    projection = np.sum(orthogonal * unit, axis=1, keepdims=True) * unit
    orthogonal = np.where(degenerate[:, None], orthogonal, orthogonal - projection)

    along_direction = self.rng.normal(0.0, self.sigma_zeta, size=(num_offspring, 1))
    offsprings = index_x + along_direction * direction + spread * orthogonal
    offsprings = np.clip(offsprings, np.asarray(self.lower_bounds), np.asarray(self.upper_bounds))

    self.offspring_population = pl.from_numpy(offsprings, schema=self.variable_symbols, orient="row")
    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the parent-centric crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the parent-centric crossover operator."""
    if self.parent_population is None:
        return []
    msgs: list[Message] = []
    if self.verbosity >= 1:
        msgs.extend(
            [
                FloatMessage(
                    topic=CrossoverMessageTopics.SIGMA_ZETA,
                    source=self.__class__.__name__,
                    value=self.sigma_zeta,
                ),
                FloatMessage(
                    topic=CrossoverMessageTopics.SIGMA_ETA,
                    source=self.__class__.__name__,
                    value=self.sigma_eta,
                ),
            ]
        )
    if self.verbosity >= 2:  # noqa: PLR2004
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )
    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

SimulatedBinaryCrossover

Bases: BaseCrossover

A class for creating a simulated binary crossover operator.

Both the original untruncated operator and the truncated variant that keeps the offspring inside the variable bounds are available; see unbounded_offsprings and bounded_offsprings. The truncated variant is the default, as in pymoo, jMetalPy, Platypus, pagmo2 and Deb's own NSGA-II code; pass truncated=False for the untruncated formulation that PlatEMO implements.

References

Deb, K., & Agrawal, R. B. (1995). Simulated binary crossover for continuous search space. Complex Systems, 9(2), 115-148.

Deb, K., & Gulati, S. (2001). Design of truss-structures for minimum weight using genetic algorithms. Finite Elements in Analysis and Design, 37(5), 447-465. https://doi.org/10.1016/S0168-874X(00)00057-3 (The truncated variant, which is the default.)

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948 (Empirical comparison against other real-coded recombination operators.)

Source code in desdeo/emo/operators/crossover.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
class SimulatedBinaryCrossover(BaseCrossover):
    """A class for creating a simulated binary crossover operator.

    Both the original untruncated operator and the truncated variant that keeps the offspring inside
    the variable bounds are available; see `unbounded_offsprings` and `bounded_offsprings`. The
    truncated variant is the default, as in pymoo, jMetalPy, Platypus, pagmo2 and Deb's own NSGA-II
    code; pass `truncated=False` for the untruncated formulation that PlatEMO implements.

    References:
        Deb, K., & Agrawal, R. B. (1995). Simulated binary crossover for continuous search space.
            Complex Systems, 9(2), 115-148.

        Deb, K., & Gulati, S. (2001). Design of truss-structures for minimum weight using genetic
            algorithms. Finite Elements in Analysis and Design, 37(5), 447-465.
            https://doi.org/10.1016/S0168-874X(00)00057-3
            (The truncated variant, which is the default.)

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
            (Empirical comparison against other real-coded recombination operators.)
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the crossover operator."""
        return {
            0: [],
            1: [CrossoverMessageTopics.XOVER_PROBABILITY, CrossoverMessageTopics.XOVER_DISTRIBUTION],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.XOVER_DISTRIBUTION,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics the crossover operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        pair_xover_probability: float = 1.0,
        xover_probability: float = 0.5,
        uniform_xover_probability: float = 0.5,
        xover_distribution: float = 30,
        truncated: bool = True,
        swap_uncrossed_variables: bool = False,
    ):
        """Initialize a simulated binary crossover operator.

        Args:
            problem (Problem): the problem object.
            seed (int): the seed for the random number generator.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            pair_xover_probability (float, optional): the probability that a parent pair is recombined at
                all. Drawn once per pair: on failure the pair is copied to the offspring unchanged, with
                every decision variable kept together. This is the `p_c` reported in the literature
                (1.0 in the RVEA and NSGA-III papers, 0.9 in NSGA-II). Ranges between 0 and 1.0.
                Defaults to 1.0.
            xover_probability (float, optional): the per-variable crossover probability. Drawn once per
                decision variable, and decides whether the SBX operation is performed on that variable.
                Ranges between 0 and 1.0. Defaults to 0.5, following Deb and Agrawal (1995), who state
                "we choose to perform SBX in each variable with probability 0.5". Note this is a
                *separate* level from `pair_xover_probability`: the literature's `p_c = 1.0` refers to
                the pair, not to the variable, so it belongs in `pair_xover_probability`.
            uniform_xover_probability (float, optional): the uniform crossover probability parameter.
                This parameter decides whether the decision variable components of the parents are swapped for the
                offspring or not. Ranges between 0 and 1.0. Defaults to 0.5. Only operates on variables that
                have already been selected for crossover by the xover_probability parameter.
            xover_distribution (float, optional): the crossover distribution parameter. Must be positive.
                This parameter controls the distribution of the offspring. A larger value results in a distribution
                that is more concentrated around the parents, while a smaller value results in a distribution that is
                more spread out. Defaults to 30.
            truncated (bool, optional): whether to truncate the probability distribution to keep the offspring
                within the variable bounds. Defaults to True.
            swap_uncrossed_variables (bool, optional): whether a decision variable *not* selected by
                `xover_probability` is exchanged between the two offspring instead of inherited
                unchanged. Defaults to False, which is what every implementation surveyed does except
                jMetal (Java). jMetal's else-branch assigns `offspring1[i] = parent2[i]` and
                `offspring2[i] = parent1[i]`, adding a genuine uniform-crossover component on top of
                SBX: at the standard per-variable rate of 0.5, half of the genome is swapped wholesale
                every time a pair recombines. Set it to reproduce jMetal; leave it alone otherwise.
        """
        # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.problem = problem

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("SimulatedBinaryCrossover only works on continuous problems.")
        if not 0 <= pair_xover_probability <= 1:
            raise ValueError("Pair crossover probability must be between 0 and 1.")
        if not 0 <= xover_probability <= 1:
            raise ValueError("Crossover probability must be between 0 and 1.")
        if xover_distribution <= 0:
            raise ValueError("Crossover distribution must be positive.")
        self.pair_xover_probability = pair_xover_probability
        self.xover_probability = xover_probability
        self.xover_distribution = xover_distribution
        self.uniform_xover_probability = uniform_xover_probability
        self.truncated = truncated
        self.swap_uncrossed_variables = swap_uncrossed_variables

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform the simulated binary crossover operation.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        if self.truncated:
            offspring = self.bounded_offsprings(population=population, to_mate=to_mate)
        else:
            offspring = self.unbounded_offsprings(population=population, to_mate=to_mate)

        # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
        # offspring too many.
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
        if original_pop_size % 2 == 1:
            offspring = offspring.head(original_pop_size)

        self.offspring_population = offspring
        self.notify()

        return self.offspring_population

    def unbounded_offsprings(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform the unbounded simulated binary crossover operation.

        Implementation based on Deb, Kalyanmoy, and Ram Bhushan Agrawal. "Simulated binary crossover for
        continuous search space." Complex systems 9.2 (1995): 115-148. This implementation follows PlatEMO's
        `OperatorGA`. DEAP's `cxSimulatedBinary` derives the same beta, but omits the random sign and the
        per-variable mask that PlatEMO adds on top of the paper. pymoo, DEAP's `cxSimulatedBinaryBounded`,
        jMetalPy, Platypus, pagmo2 and Deb's own NSGA-II C code all implement the truncated/bounded variant
        while calling it simulated binary crossover; see `bounded_offsprings` for that one.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
        mate_size = mating_pop.shape[0]
        num_var = mating_pop.shape[1]

        offspring = np.zeros_like(mating_pop)

        HALF = 0.5  # NOQA: N806
        # TODO(@light-weaver): Extract into a numba jitted function.
        for i in range(0, mate_size, 2):
            # One draw per pair, before any per-variable draw. A pair that fails is copied whole, so
            # all of its variables stay together -- that within-pair correlation is the point, and is
            # what folding p_c into the per-variable rate would destroy.
            if self.rng.random() > self.pair_xover_probability:
                offspring[i] = mating_pop[i]
                offspring[i + 1] = mating_pop[i + 1]
                continue
            beta = np.zeros(num_var)
            miu = self.rng.random(num_var)
            # Simulated binary crossover (SBX) operator tries to mimic the behavior of single-point crossover by
            # trying to attain similar distribution of offspring as single-point crossover.
            # The distribution itself can be contracting or expanding.
            # beta is calculated such that the integral (over (0, beta)) of the distribution matches the random number
            # mu. At mu <= 0.5, the distribution is contracting, and at mu > 0.5, the distribution is expanding.
            # You can integrate equations 18 and 19 from the reference in the docstring to see how the equations below
            # are derived. Integrate 18 from 0 to beta, and set it equal to mu. Solve for beta.
            # for 19, first integrate 18 from 0 to 1 (which is equal to 0.5 so you don't actually need to integrate it)
            # Then add the integral of 19 from 1 to beta, and set it equal to mu. Solve for beta.
            beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))  # 18
            beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))  # 18 + 19
            # if beta is negative, the offspring 1 gets decision var component closer to parent 2 and vice versa.
            # In this implementation, there is an equal chance of beta being negative or positive.
            # TBH, this is more similar to uniform crossover than single-point crossover.
            binary_mask = self.rng.random(num_var) <= self.uniform_xover_probability
            binary_mask = (binary_mask * 2) - 1  # Convert to -1 or 1
            beta = beta * binary_mask
            # At beta = -1 no crossover occurs and the dec var components are copied from the parents:
            # offspring[i] = avg + diff = mating_pop[i]. (Beta = +1 would swap the parents instead,
            # which is what PlatEMO's opposite sign convention on the offspring expression means by
            # setting the sentinel to +1 there.)
            # jMetal wants exactly that swap on the uncrossed variables, so the sentinel flips sign.
            uncrossed_sentinel = 1 if self.swap_uncrossed_variables else -1
            beta[self.rng.random(num_var) > self.xover_probability] = uncrossed_sentinel
            # Note that when mu < 0.5, abs(beta) ends up being less than 1, resulting in a contracting crossover.
            # The opposite is true when mu > 0.5, resulting in an expanding crossover.
            avg = (mating_pop[i] + mating_pop[i + 1]) / 2
            diff = (mating_pop[i] - mating_pop[i + 1]) / 2
            offspring[i] = avg - beta * diff
            offspring[i + 1] = avg + beta * diff
        # Clip the offspring to the bounds
        lower_bounds = np.asarray(self.lower_bounds, dtype=float)
        upper_bounds = np.asarray(self.upper_bounds, dtype=float)
        offspring = np.clip(offspring, lower_bounds, upper_bounds)
        return pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")

    def bounded_offsprings(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform the bounded simulated binary crossover operation.

        This implementation is similar to pymoo and boundedSBX in deap. One of the first papers I can find that actually
        describes how to calculate it is [1].

        The basic idea is as follows:

        1. Take the probability distributions of the unbounded SBX operator. There are two: one for the contracting case
            (mu <= 0.5, beta <= 1) and one for the expanding case (mu > 0.5, beta > 1).
        2. Assume that we are bounded on the lower side. Calculate a maximum value of beta such that any potential
            offspring will not be below the lower bound. This is done by solving for beta in the equation:
            c = (p1+p2)/2 - beta*(p1-p2)/2, where c is the child (or in this case, the lower bound), p1 and p2
            are parents. Thus, beta_max = (p1+p2-2*c)/(p1-p2). This is the maximum value of beta such that the child
            will still be above the lower bound. In most implementations, this is called beta_q, and the equation is
            slightly rearranged to be beta_q = 1 + 2*(p1-x_L)/(p2-p1), where p1<p2.
        3. Now, integrate equations 18 + 19 from the original SBX paper. Integrating from 0 to infinity gives 1. So,
            integrate from 0 to beta_max, we get a normalization factor.
        4. The normalization factor turns out to be F = alpha / 2. where:
            alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)
        5. Now, integrate the normalized version of equation 18 from beta = 0 to 1. This used to be equal to 0.5, but
            now it equals 0.5 / F = 1 / alpha. This is now the new threshold for the contracting case. Integrate
            between 0 and beta_max and set it equal to mu, if mu <= 1 / alpha.
        6. For the expanding case, integrate the normalized version of equation 19 from beta = 1 to beta_max.
        7. Use steps 2-6 for the child: c = (p1+p2)/2 - beta*(p1-p2)/2.
        8. Repeat steps 2-6 but with the upper bound for the child: c = (p1+p2)/2 + beta*(p1-p2)/2.

        Interestingly enough, the resulting equations are are just a generalization of the unbounded case.
        If beta_max = infinity, then alpha = 2, and the equations reduce to the unbounded case. So, this piece of
        code can handle the unbounded case as well, but I have kept the unbounded case separate for clarity.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.

        References:
            [1] "Deb, K., & Gulati, S. (2001). Design of truss-structures for minimum weight
                using genetic algorithms". Finite Elements in Analysis and Design, 37(5), 447-465.
                https://doi.org/10.1016/S0168-874X(00)00057-3

        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
        mate_size = mating_pop.shape[0]
        num_var = mating_pop.shape[1]

        lower_bounds = np.asarray(self.lower_bounds, dtype=float)
        upper_bounds = np.asarray(self.upper_bounds, dtype=float)

        # The truncated distribution below is only defined for parents inside the bounds: an
        # oob parent makes beta_max negative, and raising it to a fractional power yields NaN.
        # Pull such parents onto the bound instead.
        mating_pop = np.clip(mating_pop, lower_bounds, upper_bounds)

        offspring = np.zeros_like(mating_pop)

        # TODO(@light-weaver): Extract into a numba jitted function.
        for i in range(0, mate_size, 2):
            # One draw per pair, before any per-variable draw. A pair that fails is copied whole, so
            # all of its variables stay together -- that within-pair correlation is the point, and is
            # what folding p_c into the per-variable rate would destroy.
            if self.rng.random() > self.pair_xover_probability:
                offspring[i] = mating_pop[i]
                offspring[i + 1] = mating_pop[i + 1]
                continue
            beta = np.zeros(num_var)
            miu = self.rng.random(num_var)
            # Apply crossover only for certain decision variables
            sbx_mask = self.rng.random(num_var) <= self.xover_probability
            # Apply binary crossover only for certain decision variables
            binary_mask = self.rng.random(num_var) <= self.uniform_xover_probability
            binary_mask = binary_mask & sbx_mask  # Only apply binary crossover where SBX is applied
            avg = (mating_pop[i] + mating_pop[i + 1]) / 2

            x1 = np.minimum(mating_pop[i], mating_pop[i + 1])
            x2 = np.maximum(mating_pop[i], mating_pop[i + 1])
            # The two children are derived in *sorted* order: one steps from the midpoint down towards
            # the lower bound, the other up towards the upper bound, and each uses the beta capped by
            # the distance to the bound it is stepping towards. The half-difference must therefore be
            # taken between the sorted values, not between the parents in whatever order the mating
            # pool happens to hold them. Using the unsorted difference pairs the lower-bound beta with
            # an upward step (and vice versa) whenever mating_pop[i] is the smaller parent, which lets
            # the offspring escape the variable bounds.
            diff = (x2 - x1) / 2

            # Child stepping towards the lower bound.
            with np.errstate(divide="ignore", invalid="ignore"):  # Handles x1 == x2 case
                beta_max = 1 + 2 * (x1 - lower_bounds) / (x2 - x1)
            beta_max[np.isnan(beta_max)] = np.inf  # Handles x1 == x2 == lower_bound case

            # Technically, this code can handle the unbounded case by setting alpha to an array of 2s.
            alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)

            SPLIT_POINT1 = 1 / alpha  # NOQA: N806
            beta[miu <= SPLIT_POINT1] = (alpha[miu <= SPLIT_POINT1] * miu[miu <= SPLIT_POINT1]) ** (
                1 / (self.xover_distribution + 1)
            )
            beta[miu > SPLIT_POINT1] = (2 - alpha[miu > SPLIT_POINT1] * miu[miu > SPLIT_POINT1]) ** (
                -1 / (self.xover_distribution + 1)
            )
            # Turning beta negative does not work for truncated SBX. Manually swap the offspring instead.
            child_low = avg - beta * diff

            # Child stepping towards the upper bound. The same miu is reused deliberately: every
            # reference implementation draws one uniform per variable and shares it between the two
            # children, so that the pair is perfectly correlated.
            with np.errstate(divide="ignore", invalid="ignore"):  # Handles x1 == x2 case
                beta_max = 1 + 2 * (upper_bounds - x2) / (x2 - x1)
            beta_max[np.isnan(beta_max)] = np.inf  # Handles x1 == x2 == upper_bound case
            # The error states only occur when x1==x2, which means that the parents are equal, and thus the offspring
            # will be equal to the parents. So, np.inf is fine.

            alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)

            SPLIT_POINT2 = 1 / alpha  # NOQA: N806
            beta[miu <= SPLIT_POINT2] = (alpha[miu <= SPLIT_POINT2] * miu[miu <= SPLIT_POINT2]) ** (
                1 / (self.xover_distribution + 1)
            )
            beta[miu > SPLIT_POINT2] = (2 - alpha[miu > SPLIT_POINT2] * miu[miu > SPLIT_POINT2]) ** (
                -1 / (self.xover_distribution + 1)
            )
            child_high = avg + beta * diff

            # Preserve the parent identity: the child that stepped down belongs to whichever parent
            # held the smaller value, as in Deb's reference implementation and in pymoo.
            first_is_lower = mating_pop[i] <= mating_pop[i + 1]
            offspring[i] = np.where(first_is_lower, child_low, child_high)
            offspring[i + 1] = np.where(first_is_lower, child_high, child_low)

            # Decision variables not selected for SBX are inherited unchanged. This has to be an
            # explicit copy rather than a beta = 1 sentinel: with the sorted difference above, beta = 1
            # would hand every untouched variable's smaller value to offspring i and the larger to
            # offspring i + 1, biasing the pair instead of leaving it alone.
            # jMetal exchanges them between the offspring instead; see `swap_uncrossed_variables`.
            first, second = (i + 1, i) if self.swap_uncrossed_variables else (i, i + 1)
            offspring[i, ~sbx_mask] = mating_pop[first, ~sbx_mask]
            offspring[i + 1, ~sbx_mask] = mating_pop[second, ~sbx_mask]

            # Swap the offspring for decision variables where binary crossover is applied
            offspring[i, binary_mask], offspring[i + 1, binary_mask] = (
                offspring[i + 1, binary_mask].copy(),
                offspring[i, binary_mask].copy(),
            )

        # The mathematics above already keeps the offspring feasible; this only absorbs floating point
        # drift at the bounds. Every reference implementation of truncated SBX clamps here as well.
        offspring = np.clip(offspring, lower_bounds, upper_bounds)
        return pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")

    def update(self, *_, **__):
        """Do nothing. This is just the basic SBX operator."""

    def state(self) -> Sequence[Message]:
        """Return the state of the crossover operator."""
        if self.parent_population is None or self.offspring_population is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source="SimulatedBinaryCrossover",
                    value=self.xover_probability,
                ),
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
                    source="SimulatedBinaryCrossover",
                    value=self.xover_distribution,
                ),
            ]
        # verbosity == 2 or higher
        return [
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source="SimulatedBinaryCrossover",
                value=self.xover_probability,
            ),
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
                source="SimulatedBinaryCrossover",
                value=self.xover_distribution,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.PARENTS,
                source="SimulatedBinaryCrossover",
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source="SimulatedBinaryCrossover",
                value=self.offspring_population,
            ),
        ]
interested_topics property
interested_topics

The message topics the crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    pair_xover_probability: float = 1.0,
    xover_probability: float = 0.5,
    uniform_xover_probability: float = 0.5,
    xover_distribution: float = 30,
    truncated: bool = True,
    swap_uncrossed_variables: bool = False,
)

Initialize a simulated binary crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
seed int

the seed for the random number generator.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
pair_xover_probability float

the probability that a parent pair is recombined at all. Drawn once per pair: on failure the pair is copied to the offspring unchanged, with every decision variable kept together. This is the p_c reported in the literature (1.0 in the RVEA and NSGA-III papers, 0.9 in NSGA-II). Ranges between 0 and 1.0. Defaults to 1.0.

1.0
xover_probability float

the per-variable crossover probability. Drawn once per decision variable, and decides whether the SBX operation is performed on that variable. Ranges between 0 and 1.0. Defaults to 0.5, following Deb and Agrawal (1995), who state "we choose to perform SBX in each variable with probability 0.5". Note this is a separate level from pair_xover_probability: the literature's p_c = 1.0 refers to the pair, not to the variable, so it belongs in pair_xover_probability.

0.5
uniform_xover_probability float

the uniform crossover probability parameter. This parameter decides whether the decision variable components of the parents are swapped for the offspring or not. Ranges between 0 and 1.0. Defaults to 0.5. Only operates on variables that have already been selected for crossover by the xover_probability parameter.

0.5
xover_distribution float

the crossover distribution parameter. Must be positive. This parameter controls the distribution of the offspring. A larger value results in a distribution that is more concentrated around the parents, while a smaller value results in a distribution that is more spread out. Defaults to 30.

30
truncated bool

whether to truncate the probability distribution to keep the offspring within the variable bounds. Defaults to True.

True
swap_uncrossed_variables bool

whether a decision variable not selected by xover_probability is exchanged between the two offspring instead of inherited unchanged. Defaults to False, which is what every implementation surveyed does except jMetal (Java). jMetal's else-branch assigns offspring1[i] = parent2[i] and offspring2[i] = parent1[i], adding a genuine uniform-crossover component on top of SBX: at the standard per-variable rate of 0.5, half of the genome is swapped wholesale every time a pair recombines. Set it to reproduce jMetal; leave it alone otherwise.

False
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    pair_xover_probability: float = 1.0,
    xover_probability: float = 0.5,
    uniform_xover_probability: float = 0.5,
    xover_distribution: float = 30,
    truncated: bool = True,
    swap_uncrossed_variables: bool = False,
):
    """Initialize a simulated binary crossover operator.

    Args:
        problem (Problem): the problem object.
        seed (int): the seed for the random number generator.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        pair_xover_probability (float, optional): the probability that a parent pair is recombined at
            all. Drawn once per pair: on failure the pair is copied to the offspring unchanged, with
            every decision variable kept together. This is the `p_c` reported in the literature
            (1.0 in the RVEA and NSGA-III papers, 0.9 in NSGA-II). Ranges between 0 and 1.0.
            Defaults to 1.0.
        xover_probability (float, optional): the per-variable crossover probability. Drawn once per
            decision variable, and decides whether the SBX operation is performed on that variable.
            Ranges between 0 and 1.0. Defaults to 0.5, following Deb and Agrawal (1995), who state
            "we choose to perform SBX in each variable with probability 0.5". Note this is a
            *separate* level from `pair_xover_probability`: the literature's `p_c = 1.0` refers to
            the pair, not to the variable, so it belongs in `pair_xover_probability`.
        uniform_xover_probability (float, optional): the uniform crossover probability parameter.
            This parameter decides whether the decision variable components of the parents are swapped for the
            offspring or not. Ranges between 0 and 1.0. Defaults to 0.5. Only operates on variables that
            have already been selected for crossover by the xover_probability parameter.
        xover_distribution (float, optional): the crossover distribution parameter. Must be positive.
            This parameter controls the distribution of the offspring. A larger value results in a distribution
            that is more concentrated around the parents, while a smaller value results in a distribution that is
            more spread out. Defaults to 30.
        truncated (bool, optional): whether to truncate the probability distribution to keep the offspring
            within the variable bounds. Defaults to True.
        swap_uncrossed_variables (bool, optional): whether a decision variable *not* selected by
            `xover_probability` is exchanged between the two offspring instead of inherited
            unchanged. Defaults to False, which is what every implementation surveyed does except
            jMetal (Java). jMetal's else-branch assigns `offspring1[i] = parent2[i]` and
            `offspring2[i] = parent1[i]`, adding a genuine uniform-crossover component on top of
            SBX: at the standard per-variable rate of 0.5, half of the genome is swapped wholesale
            every time a pair recombines. Set it to reproduce jMetal; leave it alone otherwise.
    """
    # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.problem = problem

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("SimulatedBinaryCrossover only works on continuous problems.")
    if not 0 <= pair_xover_probability <= 1:
        raise ValueError("Pair crossover probability must be between 0 and 1.")
    if not 0 <= xover_probability <= 1:
        raise ValueError("Crossover probability must be between 0 and 1.")
    if xover_distribution <= 0:
        raise ValueError("Crossover distribution must be positive.")
    self.pair_xover_probability = pair_xover_probability
    self.xover_probability = xover_probability
    self.xover_distribution = xover_distribution
    self.uniform_xover_probability = uniform_xover_probability
    self.truncated = truncated
    self.swap_uncrossed_variables = swap_uncrossed_variables
bounded_offsprings
bounded_offsprings(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform the bounded simulated binary crossover operation.

This implementation is similar to pymoo and boundedSBX in deap. One of the first papers I can find that actually describes how to calculate it is [1].

The basic idea is as follows:

  1. Take the probability distributions of the unbounded SBX operator. There are two: one for the contracting case (mu <= 0.5, beta <= 1) and one for the expanding case (mu > 0.5, beta > 1).
  2. Assume that we are bounded on the lower side. Calculate a maximum value of beta such that any potential offspring will not be below the lower bound. This is done by solving for beta in the equation: c = (p1+p2)/2 - beta(p1-p2)/2, where c is the child (or in this case, the lower bound), p1 and p2 are parents. Thus, beta_max = (p1+p2-2c)/(p1-p2). This is the maximum value of beta such that the child will still be above the lower bound. In most implementations, this is called beta_q, and the equation is slightly rearranged to be beta_q = 1 + 2*(p1-x_L)/(p2-p1), where p1<p2.
  3. Now, integrate equations 18 + 19 from the original SBX paper. Integrating from 0 to infinity gives 1. So, integrate from 0 to beta_max, we get a normalization factor.
  4. The normalization factor turns out to be F = alpha / 2. where: alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)
  5. Now, integrate the normalized version of equation 18 from beta = 0 to 1. This used to be equal to 0.5, but now it equals 0.5 / F = 1 / alpha. This is now the new threshold for the contracting case. Integrate between 0 and beta_max and set it equal to mu, if mu <= 1 / alpha.
  6. For the expanding case, integrate the normalized version of equation 19 from beta = 1 to beta_max.
  7. Use steps 2-6 for the child: c = (p1+p2)/2 - beta*(p1-p2)/2.
  8. Repeat steps 2-6 but with the upper bound for the child: c = (p1+p2)/2 + beta*(p1-p2)/2.

Interestingly enough, the resulting equations are are just a generalization of the unbounded case. If beta_max = infinity, then alpha = 2, and the equations reduce to the unbounded case. So, this piece of code can handle the unbounded case as well, but I have kept the unbounded case separate for clarity.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

References

[1] "Deb, K., & Gulati, S. (2001). Design of truss-structures for minimum weight using genetic algorithms". Finite Elements in Analysis and Design, 37(5), 447-465. https://doi.org/10.1016/S0168-874X(00)00057-3

Source code in desdeo/emo/operators/crossover.py
def bounded_offsprings(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform the bounded simulated binary crossover operation.

    This implementation is similar to pymoo and boundedSBX in deap. One of the first papers I can find that actually
    describes how to calculate it is [1].

    The basic idea is as follows:

    1. Take the probability distributions of the unbounded SBX operator. There are two: one for the contracting case
        (mu <= 0.5, beta <= 1) and one for the expanding case (mu > 0.5, beta > 1).
    2. Assume that we are bounded on the lower side. Calculate a maximum value of beta such that any potential
        offspring will not be below the lower bound. This is done by solving for beta in the equation:
        c = (p1+p2)/2 - beta*(p1-p2)/2, where c is the child (or in this case, the lower bound), p1 and p2
        are parents. Thus, beta_max = (p1+p2-2*c)/(p1-p2). This is the maximum value of beta such that the child
        will still be above the lower bound. In most implementations, this is called beta_q, and the equation is
        slightly rearranged to be beta_q = 1 + 2*(p1-x_L)/(p2-p1), where p1<p2.
    3. Now, integrate equations 18 + 19 from the original SBX paper. Integrating from 0 to infinity gives 1. So,
        integrate from 0 to beta_max, we get a normalization factor.
    4. The normalization factor turns out to be F = alpha / 2. where:
        alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)
    5. Now, integrate the normalized version of equation 18 from beta = 0 to 1. This used to be equal to 0.5, but
        now it equals 0.5 / F = 1 / alpha. This is now the new threshold for the contracting case. Integrate
        between 0 and beta_max and set it equal to mu, if mu <= 1 / alpha.
    6. For the expanding case, integrate the normalized version of equation 19 from beta = 1 to beta_max.
    7. Use steps 2-6 for the child: c = (p1+p2)/2 - beta*(p1-p2)/2.
    8. Repeat steps 2-6 but with the upper bound for the child: c = (p1+p2)/2 + beta*(p1-p2)/2.

    Interestingly enough, the resulting equations are are just a generalization of the unbounded case.
    If beta_max = infinity, then alpha = 2, and the equations reduce to the unbounded case. So, this piece of
    code can handle the unbounded case as well, but I have kept the unbounded case separate for clarity.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.

    References:
        [1] "Deb, K., & Gulati, S. (2001). Design of truss-structures for minimum weight
            using genetic algorithms". Finite Elements in Analysis and Design, 37(5), 447-465.
            https://doi.org/10.1016/S0168-874X(00)00057-3

    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
    mate_size = mating_pop.shape[0]
    num_var = mating_pop.shape[1]

    lower_bounds = np.asarray(self.lower_bounds, dtype=float)
    upper_bounds = np.asarray(self.upper_bounds, dtype=float)

    # The truncated distribution below is only defined for parents inside the bounds: an
    # oob parent makes beta_max negative, and raising it to a fractional power yields NaN.
    # Pull such parents onto the bound instead.
    mating_pop = np.clip(mating_pop, lower_bounds, upper_bounds)

    offspring = np.zeros_like(mating_pop)

    # TODO(@light-weaver): Extract into a numba jitted function.
    for i in range(0, mate_size, 2):
        # One draw per pair, before any per-variable draw. A pair that fails is copied whole, so
        # all of its variables stay together -- that within-pair correlation is the point, and is
        # what folding p_c into the per-variable rate would destroy.
        if self.rng.random() > self.pair_xover_probability:
            offspring[i] = mating_pop[i]
            offspring[i + 1] = mating_pop[i + 1]
            continue
        beta = np.zeros(num_var)
        miu = self.rng.random(num_var)
        # Apply crossover only for certain decision variables
        sbx_mask = self.rng.random(num_var) <= self.xover_probability
        # Apply binary crossover only for certain decision variables
        binary_mask = self.rng.random(num_var) <= self.uniform_xover_probability
        binary_mask = binary_mask & sbx_mask  # Only apply binary crossover where SBX is applied
        avg = (mating_pop[i] + mating_pop[i + 1]) / 2

        x1 = np.minimum(mating_pop[i], mating_pop[i + 1])
        x2 = np.maximum(mating_pop[i], mating_pop[i + 1])
        # The two children are derived in *sorted* order: one steps from the midpoint down towards
        # the lower bound, the other up towards the upper bound, and each uses the beta capped by
        # the distance to the bound it is stepping towards. The half-difference must therefore be
        # taken between the sorted values, not between the parents in whatever order the mating
        # pool happens to hold them. Using the unsorted difference pairs the lower-bound beta with
        # an upward step (and vice versa) whenever mating_pop[i] is the smaller parent, which lets
        # the offspring escape the variable bounds.
        diff = (x2 - x1) / 2

        # Child stepping towards the lower bound.
        with np.errstate(divide="ignore", invalid="ignore"):  # Handles x1 == x2 case
            beta_max = 1 + 2 * (x1 - lower_bounds) / (x2 - x1)
        beta_max[np.isnan(beta_max)] = np.inf  # Handles x1 == x2 == lower_bound case

        # Technically, this code can handle the unbounded case by setting alpha to an array of 2s.
        alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)

        SPLIT_POINT1 = 1 / alpha  # NOQA: N806
        beta[miu <= SPLIT_POINT1] = (alpha[miu <= SPLIT_POINT1] * miu[miu <= SPLIT_POINT1]) ** (
            1 / (self.xover_distribution + 1)
        )
        beta[miu > SPLIT_POINT1] = (2 - alpha[miu > SPLIT_POINT1] * miu[miu > SPLIT_POINT1]) ** (
            -1 / (self.xover_distribution + 1)
        )
        # Turning beta negative does not work for truncated SBX. Manually swap the offspring instead.
        child_low = avg - beta * diff

        # Child stepping towards the upper bound. The same miu is reused deliberately: every
        # reference implementation draws one uniform per variable and shares it between the two
        # children, so that the pair is perfectly correlated.
        with np.errstate(divide="ignore", invalid="ignore"):  # Handles x1 == x2 case
            beta_max = 1 + 2 * (upper_bounds - x2) / (x2 - x1)
        beta_max[np.isnan(beta_max)] = np.inf  # Handles x1 == x2 == upper_bound case
        # The error states only occur when x1==x2, which means that the parents are equal, and thus the offspring
        # will be equal to the parents. So, np.inf is fine.

        alpha = 2 - (1 / beta_max) ** (self.xover_distribution + 1)

        SPLIT_POINT2 = 1 / alpha  # NOQA: N806
        beta[miu <= SPLIT_POINT2] = (alpha[miu <= SPLIT_POINT2] * miu[miu <= SPLIT_POINT2]) ** (
            1 / (self.xover_distribution + 1)
        )
        beta[miu > SPLIT_POINT2] = (2 - alpha[miu > SPLIT_POINT2] * miu[miu > SPLIT_POINT2]) ** (
            -1 / (self.xover_distribution + 1)
        )
        child_high = avg + beta * diff

        # Preserve the parent identity: the child that stepped down belongs to whichever parent
        # held the smaller value, as in Deb's reference implementation and in pymoo.
        first_is_lower = mating_pop[i] <= mating_pop[i + 1]
        offspring[i] = np.where(first_is_lower, child_low, child_high)
        offspring[i + 1] = np.where(first_is_lower, child_high, child_low)

        # Decision variables not selected for SBX are inherited unchanged. This has to be an
        # explicit copy rather than a beta = 1 sentinel: with the sorted difference above, beta = 1
        # would hand every untouched variable's smaller value to offspring i and the larger to
        # offspring i + 1, biasing the pair instead of leaving it alone.
        # jMetal exchanges them between the offspring instead; see `swap_uncrossed_variables`.
        first, second = (i + 1, i) if self.swap_uncrossed_variables else (i, i + 1)
        offspring[i, ~sbx_mask] = mating_pop[first, ~sbx_mask]
        offspring[i + 1, ~sbx_mask] = mating_pop[second, ~sbx_mask]

        # Swap the offspring for decision variables where binary crossover is applied
        offspring[i, binary_mask], offspring[i + 1, binary_mask] = (
            offspring[i + 1, binary_mask].copy(),
            offspring[i, binary_mask].copy(),
        )

    # The mathematics above already keeps the offspring feasible; this only absorbs floating point
    # drift at the bounds. Every reference implementation of truncated SBX clamps here as well.
    offspring = np.clip(offspring, lower_bounds, upper_bounds)
    return pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform the simulated binary crossover operation.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform the simulated binary crossover operation.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    if self.truncated:
        offspring = self.bounded_offsprings(population=population, to_mate=to_mate)
    else:
        offspring = self.unbounded_offsprings(population=population, to_mate=to_mate)

    # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
    # offspring too many.
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
    if original_pop_size % 2 == 1:
        offspring = offspring.head(original_pop_size)

    self.offspring_population = offspring
    self.notify()

    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the crossover operator."""
    if self.parent_population is None or self.offspring_population is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source="SimulatedBinaryCrossover",
                value=self.xover_probability,
            ),
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
                source="SimulatedBinaryCrossover",
                value=self.xover_distribution,
            ),
        ]
    # verbosity == 2 or higher
    return [
        FloatMessage(
            topic=CrossoverMessageTopics.XOVER_PROBABILITY,
            source="SimulatedBinaryCrossover",
            value=self.xover_probability,
        ),
        FloatMessage(
            topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
            source="SimulatedBinaryCrossover",
            value=self.xover_distribution,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.PARENTS,
            source="SimulatedBinaryCrossover",
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source="SimulatedBinaryCrossover",
            value=self.offspring_population,
        ),
    ]
unbounded_offsprings
unbounded_offsprings(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform the unbounded simulated binary crossover operation.

Implementation based on Deb, Kalyanmoy, and Ram Bhushan Agrawal. "Simulated binary crossover for continuous search space." Complex systems 9.2 (1995): 115-148. This implementation follows PlatEMO's OperatorGA. DEAP's cxSimulatedBinary derives the same beta, but omits the random sign and the per-variable mask that PlatEMO adds on top of the paper. pymoo, DEAP's cxSimulatedBinaryBounded, jMetalPy, Platypus, pagmo2 and Deb's own NSGA-II C code all implement the truncated/bounded variant while calling it simulated binary crossover; see bounded_offsprings for that one.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def unbounded_offsprings(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform the unbounded simulated binary crossover operation.

    Implementation based on Deb, Kalyanmoy, and Ram Bhushan Agrawal. "Simulated binary crossover for
    continuous search space." Complex systems 9.2 (1995): 115-148. This implementation follows PlatEMO's
    `OperatorGA`. DEAP's `cxSimulatedBinary` derives the same beta, but omits the random sign and the
    per-variable mask that PlatEMO adds on top of the paper. pymoo, DEAP's `cxSimulatedBinaryBounded`,
    jMetalPy, Platypus, pagmo2 and Deb's own NSGA-II C code all implement the truncated/bounded variant
    while calling it simulated binary crossover; see `bounded_offsprings` for that one.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
    mate_size = mating_pop.shape[0]
    num_var = mating_pop.shape[1]

    offspring = np.zeros_like(mating_pop)

    HALF = 0.5  # NOQA: N806
    # TODO(@light-weaver): Extract into a numba jitted function.
    for i in range(0, mate_size, 2):
        # One draw per pair, before any per-variable draw. A pair that fails is copied whole, so
        # all of its variables stay together -- that within-pair correlation is the point, and is
        # what folding p_c into the per-variable rate would destroy.
        if self.rng.random() > self.pair_xover_probability:
            offspring[i] = mating_pop[i]
            offspring[i + 1] = mating_pop[i + 1]
            continue
        beta = np.zeros(num_var)
        miu = self.rng.random(num_var)
        # Simulated binary crossover (SBX) operator tries to mimic the behavior of single-point crossover by
        # trying to attain similar distribution of offspring as single-point crossover.
        # The distribution itself can be contracting or expanding.
        # beta is calculated such that the integral (over (0, beta)) of the distribution matches the random number
        # mu. At mu <= 0.5, the distribution is contracting, and at mu > 0.5, the distribution is expanding.
        # You can integrate equations 18 and 19 from the reference in the docstring to see how the equations below
        # are derived. Integrate 18 from 0 to beta, and set it equal to mu. Solve for beta.
        # for 19, first integrate 18 from 0 to 1 (which is equal to 0.5 so you don't actually need to integrate it)
        # Then add the integral of 19 from 1 to beta, and set it equal to mu. Solve for beta.
        beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))  # 18
        beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))  # 18 + 19
        # if beta is negative, the offspring 1 gets decision var component closer to parent 2 and vice versa.
        # In this implementation, there is an equal chance of beta being negative or positive.
        # TBH, this is more similar to uniform crossover than single-point crossover.
        binary_mask = self.rng.random(num_var) <= self.uniform_xover_probability
        binary_mask = (binary_mask * 2) - 1  # Convert to -1 or 1
        beta = beta * binary_mask
        # At beta = -1 no crossover occurs and the dec var components are copied from the parents:
        # offspring[i] = avg + diff = mating_pop[i]. (Beta = +1 would swap the parents instead,
        # which is what PlatEMO's opposite sign convention on the offspring expression means by
        # setting the sentinel to +1 there.)
        # jMetal wants exactly that swap on the uncrossed variables, so the sentinel flips sign.
        uncrossed_sentinel = 1 if self.swap_uncrossed_variables else -1
        beta[self.rng.random(num_var) > self.xover_probability] = uncrossed_sentinel
        # Note that when mu < 0.5, abs(beta) ends up being less than 1, resulting in a contracting crossover.
        # The opposite is true when mu > 0.5, resulting in an expanding crossover.
        avg = (mating_pop[i] + mating_pop[i + 1]) / 2
        diff = (mating_pop[i] - mating_pop[i + 1]) / 2
        offspring[i] = avg - beta * diff
        offspring[i + 1] = avg + beta * diff
    # Clip the offspring to the bounds
    lower_bounds = np.asarray(self.lower_bounds, dtype=float)
    upper_bounds = np.asarray(self.upper_bounds, dtype=float)
    offspring = np.clip(offspring, lower_bounds, upper_bounds)
    return pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
update
update(*_, **__)

Do nothing. This is just the basic SBX operator.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing. This is just the basic SBX operator."""

SingleArithmeticCrossover

Bases: BaseCrossover

Single Arithmetic Crossover for continuous problems.

One decision variable is picked per mating pair and replaced in both offspring by the average of the two parent values. Every other variable is inherited unchanged from the respective parent, so each offspring differs from its own parent in exactly one position.

References

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948

Source code in desdeo/emo/operators/crossover.py
class SingleArithmeticCrossover(BaseCrossover):
    """Single Arithmetic Crossover for continuous problems.

    One decision variable is picked per mating pair and replaced in both offspring by the average of
    the two parent values. Every other variable is inherited unchanged from the respective parent, so
    each offspring differs from its own parent in exactly one position.

    References:
        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the single arithmetic crossover operator."""
        return {
            0: [],  # No topics for 0
            1: [
                CrossoverMessageTopics.XOVER_PROBABILITY,  # Probability of crossover
            ],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,  # Crossover probability
                CrossoverMessageTopics.PARENTS,  # Parents involved in crossover
                CrossoverMessageTopics.OFFSPRINGS,  # Offsprings created from crossover
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the single arithmetic crossover operator is interested in."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        seed: int,
        xover_probability: float = 1.0,
    ):
        """Initialize the single arithmetic crossover operator.

        Args:
            problem (Problem): the problem object.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
            xover_probability (float): probability of performing crossover.
            seed (int): random seed for reproducibility.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("SingleArithmeticCrossover only works on continuous problems.")
        if not 0 <= xover_probability <= 1:
            raise ValueError("Crossover probability must be in [0, 1].")

        self.xover_probability = xover_probability

    def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
        """Perform Single Arithmetic Crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with. The DataFrame
                contains the decision vectors, the target vectors, and the constraint vectors.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject
                to the crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pool = self.get_parents(population=population, to_mate=to_mate)
        mating_pool = mating_pool[self.variable_symbols].to_numpy().astype(float)
        mating_pop_size = mating_pool.shape[0]
        num_vars = mating_pool.shape[1]
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

        parents1 = mating_pool[0::2, :]
        parents2 = mating_pool[1::2, :]

        mask = self.rng.random(mating_pop_size // 2) <= self.xover_probability
        gene_pos = self.rng.integers(0, num_vars, size=mating_pop_size // 2)

        # Initialize offspring as exact copies
        offspring1 = parents1.copy()
        offspring2 = parents2.copy()

        # Apply crossover only for selected pairs
        row_idx = np.arange(len(mask))[mask]
        col_idx = gene_pos[mask]

        avg = 0.5 * (parents1[row_idx, col_idx] + parents2[row_idx, col_idx])

        offspring1[row_idx, col_idx] = avg
        offspring2[row_idx, col_idx] = avg

        offspring = np.vstack((offspring1, offspring2))
        if original_pop_size % 2 == 1:
            offspring = offspring[:-1, :]

        self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(
            pl.all().cast(pl.Float64)
        )
        self.notify()
        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the single arithmetic crossover operator."""
        if self.parent_population is None:
            return []

        msgs: list[Message] = []

        # Messages for crossover probability
        if self.verbosity >= 1:
            msgs.append(
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.xover_probability,
                )
            )

        # Messages for parents and offspring
        if self.verbosity >= 2:  # noqa: PLR2004 - more detailed info
            msgs.extend(
                [
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.PARENTS,
                        source=self.__class__.__name__,
                        value=self.parent_population,
                    ),
                    PolarsDataFrameMessage(
                        topic=CrossoverMessageTopics.OFFSPRINGS,
                        source=self.__class__.__name__,
                        value=self.offspring_population,
                    ),
                ]
            )

        return msgs
interested_topics property
interested_topics

The message topics that the single arithmetic crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the single arithmetic crossover operator.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    xover_probability: float = 1.0,
)

Initialize the single arithmetic crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
xover_probability float

probability of performing crossover.

1.0
seed int

random seed for reproducibility.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    xover_probability: float = 1.0,
):
    """Initialize the single arithmetic crossover operator.

    Args:
        problem (Problem): the problem object.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
        xover_probability (float): probability of performing crossover.
        seed (int): random seed for reproducibility.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("SingleArithmeticCrossover only works on continuous problems.")
    if not 0 <= xover_probability <= 1:
        raise ValueError("Crossover probability must be in [0, 1].")

    self.xover_probability = xover_probability
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform Single Arithmetic Crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with. The DataFrame contains the decision vectors, the target vectors, and the constraint vectors.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
    """Perform Single Arithmetic Crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with. The DataFrame
            contains the decision vectors, the target vectors, and the constraint vectors.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject
            to the crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pool = self.get_parents(population=population, to_mate=to_mate)
    mating_pool = mating_pool[self.variable_symbols].to_numpy().astype(float)
    mating_pop_size = mating_pool.shape[0]
    num_vars = mating_pool.shape[1]
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]

    parents1 = mating_pool[0::2, :]
    parents2 = mating_pool[1::2, :]

    mask = self.rng.random(mating_pop_size // 2) <= self.xover_probability
    gene_pos = self.rng.integers(0, num_vars, size=mating_pop_size // 2)

    # Initialize offspring as exact copies
    offspring1 = parents1.copy()
    offspring2 = parents2.copy()

    # Apply crossover only for selected pairs
    row_idx = np.arange(len(mask))[mask]
    col_idx = gene_pos[mask]

    avg = 0.5 * (parents1[row_idx, col_idx] + parents2[row_idx, col_idx])

    offspring1[row_idx, col_idx] = avg
    offspring2[row_idx, col_idx] = avg

    offspring = np.vstack((offspring1, offspring2))
    if original_pop_size % 2 == 1:
        offspring = offspring[:-1, :]

    self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(
        pl.all().cast(pl.Float64)
    )
    self.notify()
    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the single arithmetic crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the single arithmetic crossover operator."""
    if self.parent_population is None:
        return []

    msgs: list[Message] = []

    # Messages for crossover probability
    if self.verbosity >= 1:
        msgs.append(
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.xover_probability,
            )
        )

    # Messages for parents and offspring
    if self.verbosity >= 2:  # noqa: PLR2004 - more detailed info
        msgs.extend(
            [
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.PARENTS,
                    source=self.__class__.__name__,
                    value=self.parent_population,
                ),
                PolarsDataFrameMessage(
                    topic=CrossoverMessageTopics.OFFSPRINGS,
                    source=self.__class__.__name__,
                    value=self.offspring_population,
                ),
            ]
        )

    return msgs
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing."""

SinglePointBinaryCrossover

Bases: BaseCrossover

A class that defines the single point binary crossover operation.

A crossover point is drawn uniformly from the positions that actually split the parents, and the two offspring take the genes before the point from one parent and the rest from the other.

References

Holland, J. H. (1975). Adaptation in Natural and Artificial Systems. University of Michigan Press.

Goldberg, D. E. (1989). Genetic Algorithms in Search, Optimization and Machine Learning. Addison-Wesley.

Source code in desdeo/emo/operators/crossover.py
class SinglePointBinaryCrossover(BaseCrossover):
    """A class that defines the single point binary crossover operation.

    A crossover point is drawn uniformly from the positions that actually split the parents, and the
    two offspring take the genes before the point from one parent and the rest from the other.

    References:
        Holland, J. H. (1975). Adaptation in Natural and Artificial Systems. University of Michigan
            Press.

        Goldberg, D. E. (1989). Genetic Algorithms in Search, Optimization and Machine Learning.
            Addison-Wesley.
    """

    def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
        """Initialize the single point binary crossover operator.

        Args:
            problem (Problem): the problem object.
            seed (int): the seed used in the random number generator for choosing the crossover point.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level.
            publisher (Publisher): the publisher to which the operator will publish messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the single point binary crossover operator."""
        return {
            0: [],
            1: [],
            2: [
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics the single point binary crossover operator is interested in."""
        return []

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform single point binary crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with.
            to_mate (list[int] | None, optional): indices. Defaults to None.

        Returns:
            pl.DataFrame: the offspring from the crossover.
        """
        self.parent_population = population
        pop_size = self.parent_population.shape[0]
        num_var = len(self.variable_symbols)

        if num_var < 2:  # noqa: PLR2004
            raise ValueError(
                f"Single point binary crossover needs at least two decision variables, but the problem has {num_var}."
            )

        parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(np.bool)

        if to_mate is None:
            shuffled_ids = list(range(pop_size))
            self.rng.shuffle(shuffled_ids)
        else:
            shuffled_ids = copy.copy(to_mate)

        mating_pop = parent_decision_vars[shuffled_ids]
        mating_pop_size = len(shuffled_ids)
        original_mating_pop_size = mating_pop_size

        if mating_pop_size % 2 != 0:
            # if the number of member to mate is of uneven size, copy the first member to the tail
            mating_pop = np.vstack((mating_pop, mating_pop[0]))
            mating_pop_size += 1
            shuffled_ids.append(shuffled_ids[0])

        # split the population into parents, one with members with even numbered indices, the
        # other with uneven numbered indices
        parents1 = mating_pop[0::2, :]
        parents2 = mating_pop[1::2, :]

        # The high value of rng.integers is exclusive.
        cross_over_points = self.rng.integers(1, num_var, mating_pop_size // 2)

        # create a mask where, on each row, the element is 1 before the crossover point,
        # and zero after it
        cross_over_mask = np.zeros_like(parents1, dtype=np.bool)
        cross_over_mask[np.arange(cross_over_mask.shape[1]) < cross_over_points[:, None]] = 1

        # pick genes from the first parents before the crossover point
        # pick genes from the second parents after, and including, the crossover point
        offspring1_first = cross_over_mask & parents1
        offspring1_second = (~cross_over_mask) & parents2

        # combine into a first half of the whole offspring population
        offspring1 = offspring1_first | offspring1_second

        # pick genes from the first parents after, and including, the crossover point
        # pick genes from the second parents before the crossover point
        offspring2_first = (~cross_over_mask) & parents1
        offspring2_second = cross_over_mask & parents2

        # combine into the second half of the whole offspring population
        offspring2 = offspring2_first | offspring2_second

        # combine the two offspring populations into one, drop the last member if the number of
        # indices (to_mate) is uneven
        self.offspring_population = pl.from_numpy(
            np.vstack((offspring1, offspring2))[
                : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
            ],
            schema=self.variable_symbols,
            orient="row",
        ).select(pl.all().cast(pl.Float64))
        self.notify()

        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing. This is just the basic single point binary crossover operator."""

    def state(self) -> Sequence[Message]:
        """Return the state of the single ponit binary crossover operator."""
        if self.parent_population is None or self.offspring_population is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return []
        # verbosity == 2 or higher
        return [
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring_population,
            ),
        ]
interested_topics property
interested_topics

The message topics the single point binary crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the single point binary crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the single point binary crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
seed int

the seed used in the random number generator for choosing the crossover point.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
    """Initialize the single point binary crossover operator.

    Args:
        problem (Problem): the problem object.
        seed (int): the seed used in the random number generator for choosing the crossover point.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level.
        publisher (Publisher): the publisher to which the operator will publish messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform single point binary crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with.

required
to_mate list[int] | None

indices. Defaults to None.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform single point binary crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with.
        to_mate (list[int] | None, optional): indices. Defaults to None.

    Returns:
        pl.DataFrame: the offspring from the crossover.
    """
    self.parent_population = population
    pop_size = self.parent_population.shape[0]
    num_var = len(self.variable_symbols)

    if num_var < 2:  # noqa: PLR2004
        raise ValueError(
            f"Single point binary crossover needs at least two decision variables, but the problem has {num_var}."
        )

    parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(np.bool)

    if to_mate is None:
        shuffled_ids = list(range(pop_size))
        self.rng.shuffle(shuffled_ids)
    else:
        shuffled_ids = copy.copy(to_mate)

    mating_pop = parent_decision_vars[shuffled_ids]
    mating_pop_size = len(shuffled_ids)
    original_mating_pop_size = mating_pop_size

    if mating_pop_size % 2 != 0:
        # if the number of member to mate is of uneven size, copy the first member to the tail
        mating_pop = np.vstack((mating_pop, mating_pop[0]))
        mating_pop_size += 1
        shuffled_ids.append(shuffled_ids[0])

    # split the population into parents, one with members with even numbered indices, the
    # other with uneven numbered indices
    parents1 = mating_pop[0::2, :]
    parents2 = mating_pop[1::2, :]

    # The high value of rng.integers is exclusive.
    cross_over_points = self.rng.integers(1, num_var, mating_pop_size // 2)

    # create a mask where, on each row, the element is 1 before the crossover point,
    # and zero after it
    cross_over_mask = np.zeros_like(parents1, dtype=np.bool)
    cross_over_mask[np.arange(cross_over_mask.shape[1]) < cross_over_points[:, None]] = 1

    # pick genes from the first parents before the crossover point
    # pick genes from the second parents after, and including, the crossover point
    offspring1_first = cross_over_mask & parents1
    offspring1_second = (~cross_over_mask) & parents2

    # combine into a first half of the whole offspring population
    offspring1 = offspring1_first | offspring1_second

    # pick genes from the first parents after, and including, the crossover point
    # pick genes from the second parents before the crossover point
    offspring2_first = (~cross_over_mask) & parents1
    offspring2_second = cross_over_mask & parents2

    # combine into the second half of the whole offspring population
    offspring2 = offspring2_first | offspring2_second

    # combine the two offspring populations into one, drop the last member if the number of
    # indices (to_mate) is uneven
    self.offspring_population = pl.from_numpy(
        np.vstack((offspring1, offspring2))[
            : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
        ],
        schema=self.variable_symbols,
        orient="row",
    ).select(pl.all().cast(pl.Float64))
    self.notify()

    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the single ponit binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the single ponit binary crossover operator."""
    if self.parent_population is None or self.offspring_population is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return []
    # verbosity == 2 or higher
    return [
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring_population,
        ),
    ]
update
update(*_, **__)

Do nothing. This is just the basic single point binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing. This is just the basic single point binary crossover operator."""

UniformCrossover

Bases: BaseCrossover

Uniform (discrete) crossover for continuous problems.

Every decision variable is inherited whole from one parent or the other, with the two offspring taking complementary choices.

UniformMixedIntegerCrossover performs the same recombination without a domain guard and is therefore usable on continuous problems, but it fixes the per-variable rate at 0.5, offers no per-pair probability.

References

Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948 (Empirical comparison of discrete crossover against the blending operators.)

Source code in desdeo/emo/operators/crossover.py
class UniformCrossover(BaseCrossover):
    """Uniform (discrete) crossover for continuous problems.

    Every decision variable is inherited whole from one parent or the other, with the two offspring
    taking complementary choices.

    `UniformMixedIntegerCrossover` performs the same recombination without a domain guard and is
    therefore usable on continuous problems, but it fixes the per-variable rate at 0.5, offers no
    per-pair probability.

    References:
        Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third
            International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
            (Empirical comparison of discrete crossover against the blending operators.)
    """

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        pair_xover_probability: float = 1.0,
        uniform_xover_probability: float = 0.5,
    ):
        """Initialize a uniform crossover operator.

        Args:
            problem (Problem): the problem object.
            seed (int): the seed for the random number generator.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
                what topics are provided by the operator at each verbosity level.
            publisher (Publisher): the publisher to which the operator will publish messages.
            pair_xover_probability (float, optional): the probability that a parent pair is recombined
                at all. Drawn once per pair: on failure the pair is copied to the offspring unchanged,
                with every decision variable kept together. This is the `p_c` reported in the
                literature. Ranges between 0 and 1.0. Defaults to 1.0.
            uniform_xover_probability (float, optional): the per-variable probability that the two
                parents' values for that variable are exchanged between the offspring. Ranges between
                0 and 1.0. Defaults to 0.5, the rate Syswerda (1989) describes and the only rate
                `UniformMixedIntegerCrossover` offers. Named to match the parameter of the same
                meaning on `SimulatedBinaryCrossover`, so the two can be set to the same value and
                compared. At 0.0 the offspring are copies of the parents; at 1.0 the pair is swapped
                wholesale, which is also a no-op at the population level.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.problem = problem

        if problem.variable_domain is not VariableDomainTypeEnum.continuous:
            raise ValueError("UniformCrossover only works on continuous problems.")
        if not 0 <= pair_xover_probability <= 1:
            raise ValueError("Pair crossover probability must be between 0 and 1.")
        if not 0 <= uniform_xover_probability <= 1:
            raise ValueError("Uniform crossover probability must be between 0 and 1.")

        self.pair_xover_probability = pair_xover_probability
        self.uniform_xover_probability = uniform_xover_probability
        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the uniform crossover operator."""
        return {
            0: [],
            1: [CrossoverMessageTopics.XOVER_PROBABILITY],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics the uniform crossover operator is interested in."""
        return []

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform the uniform crossover operation.

        Args:
            population (pl.DataFrame): the population to perform the crossover with.
            to_mate (list[int] | None): the indices of the population members that should
                participate in the crossover. If `None`, the whole population is subject to the
                crossover.

        Returns:
            pl.DataFrame: the offspring resulting from the crossover.
        """
        mating_pop = self.get_parents(population=population, to_mate=to_mate)
        mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
        num_var = mating_pop.shape[1]

        parents1 = mating_pop[0::2, :]
        parents2 = mating_pop[1::2, :]
        n_pairs = parents1.shape[0]

        # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast over
        # the whole mating pool, making every pair in the generation exchange exactly the same
        # variables -- a fixed column split rather than uniform crossover.
        swap = self.rng.random((n_pairs, num_var)) < self.uniform_xover_probability

        # The per-pair draw comes first and covers the whole row, so a pair that fails keeps its
        # variables together. Folding it into the per-variable rate would destroy that correlation,
        # which is the reason the two levels are separate.
        recombined = self.rng.random(n_pairs) <= self.pair_xover_probability
        swap &= recombined[:, np.newaxis]

        offspring1 = np.where(swap, parents2, parents1)
        offspring2 = np.where(swap, parents1, parents2)
        offspring = np.vstack((offspring1, offspring2))

        # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
        # offspring too many.
        original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
        offspring = offspring[:original_pop_size]

        self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
        self.notify()

        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing. The operator has no state that reacts to other components."""

    def state(self) -> Sequence[Message]:
        """Return the state of the uniform crossover operator."""
        if self.parent_population is None or self.offspring_population is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.uniform_xover_probability,
                ),
            ]
        # verbosity == 2 or higher
        return [
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.uniform_xover_probability,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring_population,
            ),
        ]
interested_topics property
interested_topics

The message topics the uniform crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the uniform crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    pair_xover_probability: float = 1.0,
    uniform_xover_probability: float = 0.5,
)

Initialize a uniform crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
seed int

the seed for the random number generator.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
pair_xover_probability float

the probability that a parent pair is recombined at all. Drawn once per pair: on failure the pair is copied to the offspring unchanged, with every decision variable kept together. This is the p_c reported in the literature. Ranges between 0 and 1.0. Defaults to 1.0.

1.0
uniform_xover_probability float

the per-variable probability that the two parents' values for that variable are exchanged between the offspring. Ranges between 0 and 1.0. Defaults to 0.5, the rate Syswerda (1989) describes and the only rate UniformMixedIntegerCrossover offers. Named to match the parameter of the same meaning on SimulatedBinaryCrossover, so the two can be set to the same value and compared. At 0.0 the offspring are copies of the parents; at 1.0 the pair is swapped wholesale, which is also a no-op at the population level.

0.5
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    pair_xover_probability: float = 1.0,
    uniform_xover_probability: float = 0.5,
):
    """Initialize a uniform crossover operator.

    Args:
        problem (Problem): the problem object.
        seed (int): the seed for the random number generator.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell
            what topics are provided by the operator at each verbosity level.
        publisher (Publisher): the publisher to which the operator will publish messages.
        pair_xover_probability (float, optional): the probability that a parent pair is recombined
            at all. Drawn once per pair: on failure the pair is copied to the offspring unchanged,
            with every decision variable kept together. This is the `p_c` reported in the
            literature. Ranges between 0 and 1.0. Defaults to 1.0.
        uniform_xover_probability (float, optional): the per-variable probability that the two
            parents' values for that variable are exchanged between the offspring. Ranges between
            0 and 1.0. Defaults to 0.5, the rate Syswerda (1989) describes and the only rate
            `UniformMixedIntegerCrossover` offers. Named to match the parameter of the same
            meaning on `SimulatedBinaryCrossover`, so the two can be set to the same value and
            compared. At 0.0 the offspring are copies of the parents; at 1.0 the pair is swapped
            wholesale, which is also a no-op at the population level.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.problem = problem

    if problem.variable_domain is not VariableDomainTypeEnum.continuous:
        raise ValueError("UniformCrossover only works on continuous problems.")
    if not 0 <= pair_xover_probability <= 1:
        raise ValueError("Pair crossover probability must be between 0 and 1.")
    if not 0 <= uniform_xover_probability <= 1:
        raise ValueError("Uniform crossover probability must be between 0 and 1.")

    self.pair_xover_probability = pair_xover_probability
    self.uniform_xover_probability = uniform_xover_probability
    self.parent_population: pl.DataFrame | None = None
    self.offspring_population: pl.DataFrame | None = None
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform the uniform crossover operation.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with.

required
to_mate list[int] | None

the indices of the population members that should participate in the crossover. If None, the whole population is subject to the crossover.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform the uniform crossover operation.

    Args:
        population (pl.DataFrame): the population to perform the crossover with.
        to_mate (list[int] | None): the indices of the population members that should
            participate in the crossover. If `None`, the whole population is subject to the
            crossover.

    Returns:
        pl.DataFrame: the offspring resulting from the crossover.
    """
    mating_pop = self.get_parents(population=population, to_mate=to_mate)
    mating_pop = mating_pop[self.variable_symbols].to_numpy().astype(float)
    num_var = mating_pop.shape[1]

    parents1 = mating_pop[0::2, :]
    parents2 = mating_pop[1::2, :]
    n_pairs = parents1.shape[0]

    # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast over
    # the whole mating pool, making every pair in the generation exchange exactly the same
    # variables -- a fixed column split rather than uniform crossover.
    swap = self.rng.random((n_pairs, num_var)) < self.uniform_xover_probability

    # The per-pair draw comes first and covers the whole row, so a pair that fails keeps its
    # variables together. Folding it into the per-variable rate would destroy that correlation,
    # which is the reason the two levels are separate.
    recombined = self.rng.random(n_pairs) <= self.pair_xover_probability
    swap &= recombined[:, np.newaxis]

    offspring1 = np.where(swap, parents2, parents1)
    offspring2 = np.where(swap, parents1, parents2)
    offspring = np.vstack((offspring1, offspring2))

    # An odd sized mating pool was padded with a duplicate parent, so the last pair produced one
    # offspring too many.
    original_pop_size = len(to_mate) if to_mate is not None else population.shape[0]
    offspring = offspring[:original_pop_size]

    self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
    self.notify()

    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the uniform crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the uniform crossover operator."""
    if self.parent_population is None or self.offspring_population is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.uniform_xover_probability,
            ),
        ]
    # verbosity == 2 or higher
    return [
        FloatMessage(
            topic=CrossoverMessageTopics.XOVER_PROBABILITY,
            source=self.__class__.__name__,
            value=self.uniform_xover_probability,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring_population,
        ),
    ]
update
update(*_, **__)

Do nothing. The operator has no state that reacts to other components.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing. The operator has no state that reacts to other components."""

UniformIntegerCrossover

Bases: BaseCrossover

A class that defines the uniform integer crossover operation.

Each mating pair draws its own mask and every decision variable is inherited independently from one parent or the other, the two offspring taking complementary choices. This is the operator known as discrete crossover in the real-coded literature.

References

Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948

Source code in desdeo/emo/operators/crossover.py
class UniformIntegerCrossover(BaseCrossover):
    """A class that defines the uniform integer crossover operation.

    Each mating pair draws its own mask and every decision variable is inherited independently from
    one parent or the other, the two offspring taking complementary choices. This is the operator
    known as discrete crossover in the real-coded literature.

    References:
        Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third
            International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
    """

    def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
        """Initialize the uniform integer crossover operator.

        Args:
            problem (Problem): the problem object.
            seed (int): the seed used in the random number generator for choosing the crossover point.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the single point binary crossover operator."""
        return {
            0: [],
            1: [],
            2: [
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics the single point binary crossover operator is interested in."""
        return []

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform single point binary crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with.
            to_mate (list[int] | None, optional): indices. Defaults to None.

        Returns:
            pl.DataFrame: the offspring from the crossover.
        """
        self.parent_population = population
        pop_size = self.parent_population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(int)

        if to_mate is None:
            shuffled_ids = list(range(pop_size))
            self.rng.shuffle(shuffled_ids)
        else:
            shuffled_ids = copy.copy(to_mate)

        mating_pop = parent_decision_vars[shuffled_ids]
        mating_pop_size = len(shuffled_ids)
        original_mating_pop_size = mating_pop_size

        if mating_pop_size % 2 != 0:
            # if the number of member to mate is of uneven size, copy the first member to the tail
            mating_pop = np.vstack((mating_pop, mating_pop[0]))
            mating_pop_size += 1
            shuffled_ids.append(shuffled_ids[0])

        # split the population into parents, one with members with even numbered indices, the
        # other with uneven numbered indices
        parents1 = mating_pop[0::2, :]
        parents2 = mating_pop[1::2, :]

        # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast
        # over the whole mating pool, making every pair in the generation swap exactly the same
        # decision variables, which is a fixed column split rather than uniform crossover.
        mask = self.rng.choice([True, False], size=(mating_pop_size // 2, num_var))

        offspring1 = np.where(mask, parents1, parents2)  # True, pick from parent1, False, pick from parent2
        offspring2 = np.where(mask, parents2, parents1)  # True, pick from parent2, False, pick from parent1

        # combine the two offspring populations into one, drop the last member if the number of
        # indices (to_mate) is uneven
        self.offspring_population = pl.from_numpy(
            np.vstack((offspring1, offspring2))[
                : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
            ],
            schema=self.variable_symbols,
            orient="row",
        ).select(pl.all().cast(pl.Float64))

        self.notify()

        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing. This is just the basic single point binary crossover operator."""

    def state(self) -> Sequence[Message]:
        """Return the state of the single ponit binary crossover operator."""
        if self.parent_population is None or self.offspring_population is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return []
        # verbosity == 2 or higher
        return [
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring_population,
            ),
        ]
interested_topics property
interested_topics

The message topics the single point binary crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the single point binary crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the uniform integer crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
seed int

the seed used in the random number generator for choosing the crossover point.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
    """Initialize the uniform integer crossover operator.

    Args:
        problem (Problem): the problem object.
        seed (int): the seed used in the random number generator for choosing the crossover point.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform single point binary crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with.

required
to_mate list[int] | None

indices. Defaults to None.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform single point binary crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with.
        to_mate (list[int] | None, optional): indices. Defaults to None.

    Returns:
        pl.DataFrame: the offspring from the crossover.
    """
    self.parent_population = population
    pop_size = self.parent_population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(int)

    if to_mate is None:
        shuffled_ids = list(range(pop_size))
        self.rng.shuffle(shuffled_ids)
    else:
        shuffled_ids = copy.copy(to_mate)

    mating_pop = parent_decision_vars[shuffled_ids]
    mating_pop_size = len(shuffled_ids)
    original_mating_pop_size = mating_pop_size

    if mating_pop_size % 2 != 0:
        # if the number of member to mate is of uneven size, copy the first member to the tail
        mating_pop = np.vstack((mating_pop, mating_pop[0]))
        mating_pop_size += 1
        shuffled_ids.append(shuffled_ids[0])

    # split the population into parents, one with members with even numbered indices, the
    # other with uneven numbered indices
    parents1 = mating_pop[0::2, :]
    parents2 = mating_pop[1::2, :]

    # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast
    # over the whole mating pool, making every pair in the generation swap exactly the same
    # decision variables, which is a fixed column split rather than uniform crossover.
    mask = self.rng.choice([True, False], size=(mating_pop_size // 2, num_var))

    offspring1 = np.where(mask, parents1, parents2)  # True, pick from parent1, False, pick from parent2
    offspring2 = np.where(mask, parents2, parents1)  # True, pick from parent2, False, pick from parent1

    # combine the two offspring populations into one, drop the last member if the number of
    # indices (to_mate) is uneven
    self.offspring_population = pl.from_numpy(
        np.vstack((offspring1, offspring2))[
            : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
        ],
        schema=self.variable_symbols,
        orient="row",
    ).select(pl.all().cast(pl.Float64))

    self.notify()

    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the single ponit binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the single ponit binary crossover operator."""
    if self.parent_population is None or self.offspring_population is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return []
    # verbosity == 2 or higher
    return [
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring_population,
        ),
    ]
update
update(*_, **__)

Do nothing. This is just the basic single point binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing. This is just the basic single point binary crossover operator."""

UniformMixedIntegerCrossover

Bases: BaseCrossover

A class that defines the uniform mixed-integer crossover operation.

Each mating pair draws its own mask and every decision variable is inherited whole from one parent or the other, so integer valued variables keep integer values without any rounding.

TODO: This is virtually identical to UniformIntegerCrossover. The only difference is that the parent_decision_vars in do are not casted to int. This is not an ideal way to implement crossover for mixed-integer stuff...

References

Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110). https://doi.org/10.1109/CEC.2013.6557948

Source code in desdeo/emo/operators/crossover.py
class UniformMixedIntegerCrossover(BaseCrossover):
    """A class that defines the uniform mixed-integer crossover operation.

    Each mating pair draws its own mask and every decision variable is inherited whole from one parent
    or the other, so integer valued variables keep integer values without any rounding.

    TODO: This is virtually identical to `UniformIntegerCrossover`. The only
    difference is that the `parent_decision_vars` in `do` are not casted to
    `int`. This is not an ideal way to implement crossover for mixed-integer
    stuff...

    References:
        Syswerda, G. (1989). Uniform crossover in genetic algorithms. In Proceedings of the Third
            International Conference on Genetic Algorithms (pp. 2-9). Morgan Kaufmann.

        Picek, S., Jakobovic, D., & Golub, M. (2013). On the recombination operator in the real-coded
            genetic algorithms. In 2013 IEEE Congress on Evolutionary Computation (pp. 3103-3110).
            https://doi.org/10.1109/CEC.2013.6557948
    """

    def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
        """Initialize the uniform integer crossover operator.

        Args:
            problem (Problem): the problem object.
            seed (int): the seed used in the random number generator for choosing the crossover point.
            verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
                topics are provided by the operator at each verbosity level. Recommended to be set to 1.
            publisher (Publisher): the publisher to which the operator will publish messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the single point binary crossover operator."""
        return {
            0: [],
            1: [],
            2: [
                CrossoverMessageTopics.PARENTS,
                CrossoverMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics the single point binary crossover operator is interested in."""
        return []

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform single point binary crossover.

        Args:
            population (pl.DataFrame): the population to perform the crossover with.
            to_mate (list[int] | None, optional): indices. Defaults to None.

        Returns:
            pl.DataFrame: the offspring from the crossover.
        """
        self.parent_population = population
        pop_size = self.parent_population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(float)

        if to_mate is None:
            shuffled_ids = list(range(pop_size))
            self.rng.shuffle(shuffled_ids)
        else:
            shuffled_ids = copy.copy(to_mate)

        mating_pop = parent_decision_vars[shuffled_ids]
        mating_pop_size = len(shuffled_ids)
        original_mating_pop_size = mating_pop_size

        if mating_pop_size % 2 != 0:
            # if the number of member to mate is of uneven size, copy the first member to the tail
            mating_pop = np.vstack((mating_pop, mating_pop[0]))
            mating_pop_size += 1
            shuffled_ids.append(shuffled_ids[0])

        # split the population into parents, one with members with even numbered indices, the
        # other with uneven numbered indices
        parents1 = mating_pop[0::2, :]
        parents2 = mating_pop[1::2, :]

        # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast
        # over the whole mating pool, making every pair in the generation swap exactly the same
        # decision variables, which is a fixed column split rather than uniform crossover.
        mask = self.rng.choice([True, False], size=(mating_pop_size // 2, num_var))

        offspring1 = np.where(mask, parents1, parents2)  # True, pick from parent1, False, pick from parent2
        offspring2 = np.where(mask, parents2, parents1)  # True, pick from parent2, False, pick from parent1

        # combine the two offspring populations into one, drop the last member if the number of
        # indices (to_mate) is uneven
        self.offspring_population = pl.from_numpy(
            np.vstack((offspring1, offspring2))[
                : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
            ],
            schema=self.variable_symbols,
            orient="row",
        ).select(pl.all().cast(pl.Float64))

        self.notify()

        return self.offspring_population

    def update(self, *_, **__):
        """Do nothing. This is just the basic single point binary crossover operator."""

    def state(self) -> Sequence[Message]:
        """Return the state of the single point binary crossover operator."""
        if self.parent_population is None or self.offspring_population is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return []
        # verbosity == 2 or higher
        return [
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring_population,
            ),
        ]
interested_topics property
interested_topics

The message topics the single point binary crossover operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[CrossoverMessageTopics]]

The message topics provided by the single point binary crossover operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
)

Initialize the uniform integer crossover operator.

Parameters:

Name Type Description Default
problem Problem

the problem object.

required
seed int

the seed used in the random number generator for choosing the crossover point.

required
verbosity int

the verbosity level of the component. The keys in provided_topics tell what topics are provided by the operator at each verbosity level. Recommended to be set to 1.

required
publisher Publisher

the publisher to which the operator will publish messages.

required
Source code in desdeo/emo/operators/crossover.py
def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
    """Initialize the uniform integer crossover operator.

    Args:
        problem (Problem): the problem object.
        seed (int): the seed used in the random number generator for choosing the crossover point.
        verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
            topics are provided by the operator at each verbosity level. Recommended to be set to 1.
        publisher (Publisher): the publisher to which the operator will publish messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
do
do(
    *,
    population: DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame

Perform single point binary crossover.

Parameters:

Name Type Description Default
population DataFrame

the population to perform the crossover with.

required
to_mate list[int] | None

indices. Defaults to None.

None

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring from the crossover.

Source code in desdeo/emo/operators/crossover.py
def do(
    self,
    *,
    population: pl.DataFrame,
    to_mate: list[int] | None = None,
) -> pl.DataFrame:
    """Perform single point binary crossover.

    Args:
        population (pl.DataFrame): the population to perform the crossover with.
        to_mate (list[int] | None, optional): indices. Defaults to None.

    Returns:
        pl.DataFrame: the offspring from the crossover.
    """
    self.parent_population = population
    pop_size = self.parent_population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(float)

    if to_mate is None:
        shuffled_ids = list(range(pop_size))
        self.rng.shuffle(shuffled_ids)
    else:
        shuffled_ids = copy.copy(to_mate)

    mating_pop = parent_decision_vars[shuffled_ids]
    mating_pop_size = len(shuffled_ids)
    original_mating_pop_size = mating_pop_size

    if mating_pop_size % 2 != 0:
        # if the number of member to mate is of uneven size, copy the first member to the tail
        mating_pop = np.vstack((mating_pop, mating_pop[0]))
        mating_pop_size += 1
        shuffled_ids.append(shuffled_ids[0])

    # split the population into parents, one with members with even numbered indices, the
    # other with uneven numbered indices
    parents1 = mating_pop[0::2, :]
    parents2 = mating_pop[1::2, :]

    # One independent mask per mating pair. A single mask of shape (num_var,) would broadcast
    # over the whole mating pool, making every pair in the generation swap exactly the same
    # decision variables, which is a fixed column split rather than uniform crossover.
    mask = self.rng.choice([True, False], size=(mating_pop_size // 2, num_var))

    offspring1 = np.where(mask, parents1, parents2)  # True, pick from parent1, False, pick from parent2
    offspring2 = np.where(mask, parents2, parents1)  # True, pick from parent2, False, pick from parent1

    # combine the two offspring populations into one, drop the last member if the number of
    # indices (to_mate) is uneven
    self.offspring_population = pl.from_numpy(
        np.vstack((offspring1, offspring2))[
            : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
        ],
        schema=self.variable_symbols,
        orient="row",
    ).select(pl.all().cast(pl.Float64))

    self.notify()

    return self.offspring_population
state
state() -> Sequence[Message]

Return the state of the single point binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def state(self) -> Sequence[Message]:
    """Return the state of the single point binary crossover operator."""
    if self.parent_population is None or self.offspring_population is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return []
    # verbosity == 2 or higher
    return [
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring_population,
        ),
    ]
update
update(*_, **__)

Do nothing. This is just the basic single point binary crossover operator.

Source code in desdeo/emo/operators/crossover.py
def update(self, *_, **__):
    """Do nothing. This is just the basic single point binary crossover operator."""

_distinct_indices

_distinct_indices(
    rng: Generator, targets: ndarray, pop_size: int, k: int
) -> np.ndarray

Draw k indices per row, distinct from each other and from that row's target.

Uses order-statistic shifting rather than rejection sampling: a value drawn from a range shortened by the number of already-excluded indices is shifted past each of them in sorted order, which lands uniformly on the admissible set in one pass. Rejection sampling would be simpler but its cost is data dependent, and a bounded retry loop that gives up leaves r2 == r3 -- a zero difference vector, which silently turns differential evolution into a copy operator.

Parameters:

Name Type Description Default
rng Generator

the generator to draw from.

required
targets ndarray

the index each row must avoid, shape (n,).

required
pop_size int

the size of the population being indexed into.

required
k int

how many distinct indices to draw per row.

required

Returns:

Type Description
ndarray

np.ndarray: indices of shape (n, k).

Source code in desdeo/emo/operators/crossover.py
def _distinct_indices(rng: np.random.Generator, targets: np.ndarray, pop_size: int, k: int) -> np.ndarray:
    """Draw `k` indices per row, distinct from each other and from that row's target.

    Uses order-statistic shifting rather than rejection sampling: a value drawn from a range shortened
    by the number of already-excluded indices is shifted past each of them in sorted order, which lands
    uniformly on the admissible set in one pass. Rejection sampling would be simpler but its cost is
    data dependent, and a bounded retry loop that gives up leaves `r2 == r3` -- a zero difference
    vector, which silently turns differential evolution into a copy operator.

    Args:
        rng: the generator to draw from.
        targets: the index each row must avoid, shape `(n,)`.
        pop_size: the size of the population being indexed into.
        k: how many distinct indices to draw per row.

    Returns:
        np.ndarray: indices of shape `(n, k)`.
    """
    n = targets.shape[0]
    chosen = np.empty((n, k), dtype=np.int64)
    # Excluded indices per row, kept sorted so the shifts below apply in increasing order.
    excluded = targets[:, None].copy()
    for j in range(k):
        drawn = rng.integers(0, pop_size - (j + 1), size=n)
        for column in range(excluded.shape[1]):
            drawn += drawn >= excluded[:, column]
        chosen[:, j] = drawn
        excluded = np.sort(np.concatenate([excluded, drawn[:, None]], axis=1), axis=1)
    return chosen

Mutation operators

desdeo.emo.operators.mutation

Evolutionary operators for mutation.

Various evolutionary operators for mutation in multiobjective optimization are defined here.

BaseMutation

Bases: Subscriber

A base class for mutation operators.

Source code in desdeo/emo/operators/mutation.py
class BaseMutation(Subscriber):
    """A base class for mutation operators."""

    @abstractmethod
    def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
        """Initialize a mutation operator."""
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.problem = problem
        self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
        self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
        self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]
        self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
        self.variable_combination: VariableDomainTypeEnum = problem.variable_domain
        # Populated by `do`. Initialized here so that `state` can be called before the first
        # mutation, e.g. by a logger that reports the operator's state up front.
        self.offspring_original: pl.DataFrame | None = None
        self.parents: pl.DataFrame | None = None
        self.offspring: pl.DataFrame | None = None

    @property
    def is_discrete(self) -> list[bool]:
        """Whether each (flattened) variable is restricted to integer values.

        Returns:
            list[bool]: one flag per variable, in the order of `variable_symbols`.
        """
        return [var_type in (VariableTypeEnum.binary, VariableTypeEnum.integer) for var_type in self.variable_types]

    @abstractmethod
    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover).

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
is_discrete property
is_discrete: list[bool]

Whether each (flattened) variable is restricted to integer values.

Returns:

Type Description
list[bool]

list[bool]: one flag per variable, in the order of variable_symbols.

__init__ abstractmethod
__init__(
    problem: Problem, verbosity: int, publisher: Publisher
)

Initialize a mutation operator.

Source code in desdeo/emo/operators/mutation.py
@abstractmethod
def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
    """Initialize a mutation operator."""
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.problem = problem
    self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
    self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
    self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]
    self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
    self.variable_combination: VariableDomainTypeEnum = problem.variable_domain
    # Populated by `do`. Initialized here so that `state` can be called before the first
    # mutation, e.g. by a logger that reports the operator's state up front.
    self.offspring_original: pl.DataFrame | None = None
    self.parents: pl.DataFrame | None = None
    self.offspring: pl.DataFrame | None = None
do abstractmethod
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover).

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
@abstractmethod
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover).

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """

BinaryFlipMutation

Bases: BaseMutation

Implements the bit flip mutation operator for binary variables.

The binary flip mutation will mutate each binary decision variable, by flipping it (0 to 1, 1 to 0) with a provided probability.

Source code in desdeo/emo/operators/mutation.py
class BinaryFlipMutation(BaseMutation):
    """Implements the bit flip mutation operator for binary variables.

    The binary flip mutation will mutate each binary decision variable,
    by flipping it (0 to 1, 1 to 0) with a provided probability.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [
                MutationMessageTopics.MUTATION_PROBABILITY,
            ],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
    ):
        """Initialize a binary flip mutation operator.

        Args:
            problem (Problem): The problem object.
            seed (int): The seed for the random number generator.
            mutation_probability (float | None, optional): The probability of mutation. If None,
                the probability will be set to be 1/n, where n is the number of decision variables
                in the problem. Defaults to None.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)

        if self.variable_combination != VariableDomainTypeEnum.binary:
            raise ValueError("This mutation operator only works with binary variables.")
        if mutation_probability is None:
            self.mutation_probability = 1 / len(self.variable_symbols)
        else:
            self.mutation_probability = mutation_probability

        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the binary flip mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover). Not used in the mutation operator.

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents  # Not used, but kept for consistency
        offspring = offsprings.to_numpy(writable=True).astype(dtype=np.bool)

        # create a boolean mask based on the mutation probability
        flip_mask = self.rng.random(offspring.shape) < self.mutation_probability

        # using XOR (^), flip the bits in the offspring when the mask is True
        # otherwise leave the bit's value as it is
        offspring = offspring ^ flip_mask

        self.offspring = (
            pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
        )
        self.notify()

        return self.offspring

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
)

Initialize a binary flip mutation operator.

Parameters:

Name Type Description Default
problem Problem

The problem object.

required
seed int

The seed for the random number generator.

required
mutation_probability float | None

The probability of mutation. If None, the probability will be set to be 1/n, where n is the number of decision variables in the problem. Defaults to None.

None
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
):
    """Initialize a binary flip mutation operator.

    Args:
        problem (Problem): The problem object.
        seed (int): The seed for the random number generator.
        mutation_probability (float | None, optional): The probability of mutation. If None,
            the probability will be set to be 1/n, where n is the number of decision variables
            in the problem. Defaults to None.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)

    if self.variable_combination != VariableDomainTypeEnum.binary:
        raise ValueError("This mutation operator only works with binary variables.")
    if mutation_probability is None:
        self.mutation_probability = 1 / len(self.variable_symbols)
    else:
        self.mutation_probability = mutation_probability

    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the binary flip mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover). Not used in the mutation operator.

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the binary flip mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover). Not used in the mutation operator.

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents  # Not used, but kept for consistency
    offspring = offsprings.to_numpy(writable=True).astype(dtype=np.bool)

    # create a boolean mask based on the mutation probability
    flip_mask = self.rng.random(offspring.shape) < self.mutation_probability

    # using XOR (^), flip the bits in the offspring when the mask is True
    # otherwise leave the bit's value as it is
    offspring = offspring ^ flip_mask

    self.offspring = (
        pl.from_numpy(offspring, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
    )
    self.notify()

    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing."""

BoundedPolynomialMutation

Bases: BaseMutation

Implements the bounded polynomial mutation operator.

Reference

Deb, K., & Goyal, M. (1996). A combined genetic adaptive search (GeneAS) for engineering design. Computer Science and informatics, 26(4), 30-45, 1996.

Source code in desdeo/emo/operators/mutation.py
class BoundedPolynomialMutation(BaseMutation):
    """Implements the bounded polynomial mutation operator.

    Reference:
        Deb, K., & Goyal, M. (1996). A combined genetic adaptive search (GeneAS) for
        engineering design. Computer Science and informatics, 26(4), 30-45, 1996.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.MUTATION_DISTRIBUTION,
            ],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.MUTATION_DISTRIBUTION,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
        distribution_index: float = 20,
    ):
        """Initialize a bounded polynomial mutation operator.

        Args:
            problem (Problem): The problem object.
            seed (int): The seed for the random number generator.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
            mutation_probability (float | None, optional): The probability of mutation. Defaults to None.
            distribution_index (float, optional): The distribution index for polynomial mutation. Defaults to 20.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        if self.variable_combination != VariableDomainTypeEnum.continuous:
            raise ValueError("This mutation operator only works with continuous variables.")
        if mutation_probability is None:
            self.mutation_probability = 1 / len(self.lower_bounds)
        else:
            self.mutation_probability = mutation_probability
        self.distribution_index = distribution_index
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover).

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
        # TODO(@light-weaver): Extract to a numba jitted function
        self.offspring_original = offsprings
        self.parents = parents  # Not used, but kept for consistency
        # Note: `to_numpy` hands back an F-contiguous array. Every `pl.from_numpy` in this module
        # therefore states `orient="row"`: for a square population (as many individuals as
        # variables) polars cannot infer the orientation from the shape, and would read such an
        # array column-wise, transposing the population.
        offspring = offsprings.to_numpy(writable=True)
        min_val = np.ones_like(offspring) * self.lower_bounds
        max_val = np.ones_like(offspring) * self.upper_bounds
        k = self.rng.random(size=offspring.shape)
        miu = self.rng.random(size=offspring.shape)
        # A fixed variable has a single feasible value, and scaling by the width of an empty
        # interval would divide by zero. The resulting nan would survive the clipping below,
        # because every comparison against nan is False, so leave those genes alone instead.
        mutatable = np.logical_and(k <= self.mutation_probability, max_val > min_val)
        temp = np.logical_and(mutatable, (miu < 0.5))  # noqa: PLR2004
        # The polynomial mutation formula can still raise negative scaled values to fractional
        # powers; the offspring are clipped to the bounds afterwards, so the intermediate inf is
        # discarded. Silence the resulting benign numpy warnings.
        with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
            offspring_scaled = (offspring - min_val) / (max_val - min_val)
            offspring[temp] = offspring[temp] + (
                (max_val[temp] - min_val[temp])
                * (
                    (
                        2 * miu[temp]
                        + (1 - 2 * miu[temp]) * (1 - offspring_scaled[temp]) ** (self.distribution_index + 1)
                    )
                    ** (1 / (self.distribution_index + 1))
                    - 1
                )
            )
            temp = np.logical_and(mutatable, (miu >= 0.5))  # noqa: PLR2004
            offspring[temp] = offspring[temp] + (
                (max_val[temp] - min_val[temp])
                * (
                    1
                    - (
                        2 * (1 - miu[temp])
                        + 2 * (miu[temp] - 0.5) * offspring_scaled[temp] ** (self.distribution_index + 1)
                    )
                    ** (1 / (self.distribution_index + 1))
                )
            )
        offspring[offspring > max_val] = max_val[offspring > max_val]
        offspring[offspring < min_val] = min_val[offspring < min_val]
        self.offspring = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
        self.notify()
        return self.offspring

    def update(self, *_, **__):
        """Do nothing. This is just the basic polynomial mutation operator."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
                    source=self.__class__.__name__,
                    value=self.distribution_index,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
                source=self.__class__.__name__,
                value=self.distribution_index,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
    distribution_index: float = 20,
)

Initialize a bounded polynomial mutation operator.

Parameters:

Name Type Description Default
problem Problem

The problem object.

required
seed int

The seed for the random number generator.

required
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
mutation_probability float | None

The probability of mutation. Defaults to None.

None
distribution_index float

The distribution index for polynomial mutation. Defaults to 20.

20
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
    distribution_index: float = 20,
):
    """Initialize a bounded polynomial mutation operator.

    Args:
        problem (Problem): The problem object.
        seed (int): The seed for the random number generator.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
        mutation_probability (float | None, optional): The probability of mutation. Defaults to None.
        distribution_index (float, optional): The distribution index for polynomial mutation. Defaults to 20.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    if self.variable_combination != VariableDomainTypeEnum.continuous:
        raise ValueError("This mutation operator only works with continuous variables.")
    if mutation_probability is None:
        self.mutation_probability = 1 / len(self.lower_bounds)
    else:
        self.mutation_probability = mutation_probability
    self.distribution_index = distribution_index
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover).

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover).

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """
    # TODO(@light-weaver): Extract to a numba jitted function
    self.offspring_original = offsprings
    self.parents = parents  # Not used, but kept for consistency
    # Note: `to_numpy` hands back an F-contiguous array. Every `pl.from_numpy` in this module
    # therefore states `orient="row"`: for a square population (as many individuals as
    # variables) polars cannot infer the orientation from the shape, and would read such an
    # array column-wise, transposing the population.
    offspring = offsprings.to_numpy(writable=True)
    min_val = np.ones_like(offspring) * self.lower_bounds
    max_val = np.ones_like(offspring) * self.upper_bounds
    k = self.rng.random(size=offspring.shape)
    miu = self.rng.random(size=offspring.shape)
    # A fixed variable has a single feasible value, and scaling by the width of an empty
    # interval would divide by zero. The resulting nan would survive the clipping below,
    # because every comparison against nan is False, so leave those genes alone instead.
    mutatable = np.logical_and(k <= self.mutation_probability, max_val > min_val)
    temp = np.logical_and(mutatable, (miu < 0.5))  # noqa: PLR2004
    # The polynomial mutation formula can still raise negative scaled values to fractional
    # powers; the offspring are clipped to the bounds afterwards, so the intermediate inf is
    # discarded. Silence the resulting benign numpy warnings.
    with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
        offspring_scaled = (offspring - min_val) / (max_val - min_val)
        offspring[temp] = offspring[temp] + (
            (max_val[temp] - min_val[temp])
            * (
                (
                    2 * miu[temp]
                    + (1 - 2 * miu[temp]) * (1 - offspring_scaled[temp]) ** (self.distribution_index + 1)
                )
                ** (1 / (self.distribution_index + 1))
                - 1
            )
        )
        temp = np.logical_and(mutatable, (miu >= 0.5))  # noqa: PLR2004
        offspring[temp] = offspring[temp] + (
            (max_val[temp] - min_val[temp])
            * (
                1
                - (
                    2 * (1 - miu[temp])
                    + 2 * (miu[temp] - 0.5) * offspring_scaled[temp] ** (self.distribution_index + 1)
                )
                ** (1 / (self.distribution_index + 1))
            )
        )
    offspring[offspring > max_val] = max_val[offspring > max_val]
    offspring[offspring < min_val] = min_val[offspring < min_val]
    self.offspring = pl.from_numpy(offspring, schema=self.variable_symbols, orient="row")
    self.notify()
    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
                source=self.__class__.__name__,
                value=self.distribution_index,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
            source=self.__class__.__name__,
            value=self.distribution_index,
        ),
    ]
update
update(*_, **__)

Do nothing. This is just the basic polynomial mutation operator.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing. This is just the basic polynomial mutation operator."""

IntegerRandomMutation

Bases: BaseMutation

Implements a random mutation operator for integer variables.

The mutation will mutate each binary integer variable, by changing its value to a random value bounded by the variable's bounds with a provided probability.

Source code in desdeo/emo/operators/mutation.py
class IntegerRandomMutation(BaseMutation):
    """Implements a random mutation operator for integer variables.

    The mutation will mutate each binary integer variable,
    by changing its value to a random value bounded by the
    variable's bounds with a provided probability.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [
                MutationMessageTopics.MUTATION_PROBABILITY,
            ],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
    ):
        """Initialize a random integer mutation operator.

        Args:
            problem (Problem): The problem object.
            seed (int): The seed for the random number generator.
            mutation_probability (float | None, optional): The probability of mutation. If None,
                the probability will be set to be 1/n, where n is the number of decision variables
                in the problem. Defaults to None.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)

        if self.variable_combination != VariableDomainTypeEnum.integer:
            raise ValueError("This mutation operator only works with integer variables.")
        if mutation_probability is None:
            self.mutation_probability = 1 / len(self.variable_symbols)
        else:
            self.mutation_probability = mutation_probability

        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the random integer mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover). Not used in the mutation operator.

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents  # Not used, but kept for consistency

        population = offsprings.to_numpy(writable=True).astype(int)

        # create a boolean mask based on the mutation probability
        mutation_mask = self.rng.random(population.shape) < self.mutation_probability

        mutated = np.where(
            mutation_mask,
            self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
            population,
        )

        self.offspring = (
            pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
        )
        self.notify()

        return self.offspring

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
)

Initialize a random integer mutation operator.

Parameters:

Name Type Description Default
problem Problem

The problem object.

required
seed int

The seed for the random number generator.

required
mutation_probability float | None

The probability of mutation. If None, the probability will be set to be 1/n, where n is the number of decision variables in the problem. Defaults to None.

None
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
):
    """Initialize a random integer mutation operator.

    Args:
        problem (Problem): The problem object.
        seed (int): The seed for the random number generator.
        mutation_probability (float | None, optional): The probability of mutation. If None,
            the probability will be set to be 1/n, where n is the number of decision variables
            in the problem. Defaults to None.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)

    if self.variable_combination != VariableDomainTypeEnum.integer:
        raise ValueError("This mutation operator only works with integer variables.")
    if mutation_probability is None:
        self.mutation_probability = 1 / len(self.variable_symbols)
    else:
        self.mutation_probability = mutation_probability

    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the random integer mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover). Not used in the mutation operator.

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the random integer mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover). Not used in the mutation operator.

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents  # Not used, but kept for consistency

    population = offsprings.to_numpy(writable=True).astype(int)

    # create a boolean mask based on the mutation probability
    mutation_mask = self.rng.random(population.shape) < self.mutation_probability

    mutated = np.where(
        mutation_mask,
        self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
        population,
    )

    self.offspring = (
        pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
    )
    self.notify()

    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing."""

MPTMutation

Bases: BaseMutation

Makinen, Periaux and Toivanen (MTP) mutation.

Applies small mutations to mixed-integer variables using a mutation exponent strategy.

Source code in desdeo/emo/operators/mutation.py
class MPTMutation(BaseMutation):
    """Makinen, Periaux and Toivanen (MTP) mutation.

    Applies small mutations to mixed-integer variables using a mutation exponent strategy.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [MutationMessageTopics.MUTATION_PROBABILITY],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
        mutation_exponent: float = 2.0,
    ):
        """Initialize a small mutation operator.

        Args:
            problem (Problem): Optimization problem.
            seed (int): RNG seed.
            mutation_probability (float | None): Probability of mutation per gene.
            mutation_exponent (float): Controls strength of small mutation (larger means smaller mutations).
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
                publisher must be passed. See the Subscriber class for more information.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.rng = np.random.default_rng(seed)
        self.seed = seed
        self.mutation_exponent = mutation_exponent
        self.mutation_probability = (
            1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
        )

    def _mutate_value(self, x, lower_bound, upper_bound):
        """Apply small mutation to a single float value using mutation exponent."""
        if upper_bound == lower_bound:
            # A fixed variable has a single feasible value; scaling by the width would divide by zero.
            return lower_bound
        t = (x - lower_bound) / (upper_bound - lower_bound)
        rnd = self.rng.uniform(0, 1)

        if rnd < t:
            tm = t - t * ((t - rnd) / t) ** self.mutation_exponent
        elif rnd > t:
            tm = t + (1 - t) * ((rnd - t) / (1 - t)) ** self.mutation_exponent
        else:
            tm = t

        return (1 - tm) * lower_bound + tm * upper_bound

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the MPT mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover). Not used in the mutation operator.

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents

        population = offsprings.to_numpy(writable=True).astype(float)

        bounds = list(zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True))
        for i in range(population.shape[0]):
            for j, (lower_bound, upper_bound, discrete) in enumerate(bounds):
                if self.rng.random() < self.mutation_probability:
                    mutated = self._mutate_value(population[i, j], lower_bound, upper_bound)
                    # Round after float mutation to keep integer domain. `np.round` rather than
                    # `round`, which raises on the NaN some crossover operators produce.
                    population[i, j] = np.round(mutated) if discrete else mutated

        self.offspring = (
            pl.from_numpy(population, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
        )
        self.notify()
        return self.offspring

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
    mutation_exponent: float = 2.0,
)

Initialize a small mutation operator.

Parameters:

Name Type Description Default
problem Problem

Optimization problem.

required
seed int

RNG seed.

required
mutation_probability float | None

Probability of mutation per gene.

None
mutation_exponent float

Controls strength of small mutation (larger means smaller mutations).

2.0
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages. publisher must be passed. See the Subscriber class for more information.

required
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
    mutation_exponent: float = 2.0,
):
    """Initialize a small mutation operator.

    Args:
        problem (Problem): Optimization problem.
        seed (int): RNG seed.
        mutation_probability (float | None): Probability of mutation per gene.
        mutation_exponent (float): Controls strength of small mutation (larger means smaller mutations).
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
            publisher must be passed. See the Subscriber class for more information.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.rng = np.random.default_rng(seed)
    self.seed = seed
    self.mutation_exponent = mutation_exponent
    self.mutation_probability = (
        1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
    )
_mutate_value
_mutate_value(x, lower_bound, upper_bound)

Apply small mutation to a single float value using mutation exponent.

Source code in desdeo/emo/operators/mutation.py
def _mutate_value(self, x, lower_bound, upper_bound):
    """Apply small mutation to a single float value using mutation exponent."""
    if upper_bound == lower_bound:
        # A fixed variable has a single feasible value; scaling by the width would divide by zero.
        return lower_bound
    t = (x - lower_bound) / (upper_bound - lower_bound)
    rnd = self.rng.uniform(0, 1)

    if rnd < t:
        tm = t - t * ((t - rnd) / t) ** self.mutation_exponent
    elif rnd > t:
        tm = t + (1 - t) * ((rnd - t) / (1 - t)) ** self.mutation_exponent
    else:
        tm = t

    return (1 - tm) * lower_bound + tm * upper_bound
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the MPT mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover). Not used in the mutation operator.

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the MPT mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover). Not used in the mutation operator.

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents

    population = offsprings.to_numpy(writable=True).astype(float)

    bounds = list(zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True))
    for i in range(population.shape[0]):
        for j, (lower_bound, upper_bound, discrete) in enumerate(bounds):
            if self.rng.random() < self.mutation_probability:
                mutated = self._mutate_value(population[i, j], lower_bound, upper_bound)
                # Round after float mutation to keep integer domain. `np.round` rather than
                # `round`, which raises on the NaN some crossover operators produce.
                population[i, j] = np.round(mutated) if discrete else mutated

    self.offspring = (
        pl.from_numpy(population, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
    )
    self.notify()
    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing."""

MixedIntegerRandomMutation

Bases: BaseMutation

Implements a random mutation operator for mixed-integer variables.

The mutation will mutate each mixed-integer variable, by changing its value to a random value bounded by the variable's bounds with a provided probability.

Source code in desdeo/emo/operators/mutation.py
class MixedIntegerRandomMutation(BaseMutation):
    """Implements a random mutation operator for mixed-integer variables.

    The mutation will mutate each mixed-integer variable,
    by changing its value to a random value bounded by the
    variable's bounds with a provided probability.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [
                MutationMessageTopics.MUTATION_PROBABILITY,
            ],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
    ):
        """Initialize a random mixed_integer mutation operator.

        Args:
            problem (Problem): The problem object.
            seed (int): The seed for the random number generator.
            mutation_probability (float | None, optional): The probability of mutation. If None,
                the probability will be set to be 1/n, where n is the number of decision variables
                in the problem. Defaults to None.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)

        if mutation_probability is None:
            self.mutation_probability = 1 / len(self.variable_symbols)
        else:
            self.mutation_probability = mutation_probability

        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform the random integer mutation operation.

        Args:
            offsprings (pl.DataFrame): the offspring population to mutate.
            parents (pl.DataFrame): the parent population from which the offspring
                was generated (via crossover). Not used in the mutation operator.

        Returns:
            pl.DataFrame: the offspring resulting from the mutation.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents  # Not used, but kept for consistency

        population = offsprings.to_numpy(writable=True).astype(float)

        # create a boolean mask based on the mutation probability
        mutation_mask = self.rng.random(population.shape) < self.mutation_probability

        mutation_pool = np.array(
            [
                self.rng.integers(low=int(lower), high=int(upper), size=population.shape[0], endpoint=True).astype(
                    dtype=float
                )
                if discrete
                else self.rng.uniform(low=lower, high=upper, size=population.shape[0]).astype(dtype=float)
                for lower, upper, discrete in zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True)
            ]
        ).T

        mutated = np.where(
            mutation_mask,
            # self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
            mutation_pool,
            population,
        )

        self.offspring = (
            pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
        )
        self.notify()

        return self.offspring

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
)

Initialize a random mixed_integer mutation operator.

Parameters:

Name Type Description Default
problem Problem

The problem object.

required
seed int

The seed for the random number generator.

required
mutation_probability float | None

The probability of mutation. If None, the probability will be set to be 1/n, where n is the number of decision variables in the problem. Defaults to None.

None
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
):
    """Initialize a random mixed_integer mutation operator.

    Args:
        problem (Problem): The problem object.
        seed (int): The seed for the random number generator.
        mutation_probability (float | None, optional): The probability of mutation. If None,
            the probability will be set to be 1/n, where n is the number of decision variables
            in the problem. Defaults to None.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)

    if mutation_probability is None:
        self.mutation_probability = 1 / len(self.variable_symbols)
    else:
        self.mutation_probability = mutation_probability

    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform the random integer mutation operation.

Parameters:

Name Type Description Default
offsprings DataFrame

the offspring population to mutate.

required
parents DataFrame

the parent population from which the offspring was generated (via crossover). Not used in the mutation operator.

required

Returns:

Type Description
DataFrame

pl.DataFrame: the offspring resulting from the mutation.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform the random integer mutation operation.

    Args:
        offsprings (pl.DataFrame): the offspring population to mutate.
        parents (pl.DataFrame): the parent population from which the offspring
            was generated (via crossover). Not used in the mutation operator.

    Returns:
        pl.DataFrame: the offspring resulting from the mutation.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents  # Not used, but kept for consistency

    population = offsprings.to_numpy(writable=True).astype(float)

    # create a boolean mask based on the mutation probability
    mutation_mask = self.rng.random(population.shape) < self.mutation_probability

    mutation_pool = np.array(
        [
            self.rng.integers(low=int(lower), high=int(upper), size=population.shape[0], endpoint=True).astype(
                dtype=float
            )
            if discrete
            else self.rng.uniform(low=lower, high=upper, size=population.shape[0]).astype(dtype=float)
            for lower, upper, discrete in zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True)
        ]
    ).T

    mutated = np.where(
        mutation_mask,
        # self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
        mutation_pool,
        population,
    )

    self.offspring = (
        pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
    )
    self.notify()

    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing."""

NonUniformMutation

Bases: BaseMutation

Non-uniform mutation operator.

The mutation strength decays over generations.

The decay is driven by how far the run has progressed towards its budget. The budget is taken from the terminator's messages, so the operator stays in step with the terminator even when the number of generations is not known in advance (for example when the run is terminated by a maximum number of function evaluations). A budget passed explicitly via max_generations takes precedence over the messages.

Source code in desdeo/emo/operators/mutation.py
class NonUniformMutation(BaseMutation):
    """Non-uniform mutation operator.

    The mutation strength decays over generations.

    The decay is driven by how far the run has progressed towards its budget. The budget is
    taken from the terminator's messages, so the operator stays in step with the terminator
    even when the number of generations is not known in advance (for example when the run is
    terminated by a maximum number of function evaluations). A budget passed explicitly via
    `max_generations` takes precedence over the messages.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [MutationMessageTopics.MUTATION_PROBABILITY],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return [
            TerminatorMessageTopics.GENERATION,
            TerminatorMessageTopics.MAX_GENERATIONS,
            TerminatorMessageTopics.EVALUATION,
            TerminatorMessageTopics.MAX_EVALUATIONS,
        ]

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        max_generations: int | None = None,
        mutation_probability: float | None = None,
        b: float = 5.0,  # decay parameter
    ):
        """Initialize a Non-uniform mutation operator.

        Args:
            problem (Problem): The optimization problem definition.
            seed (int): Random number generator seed for reproducibility.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
            max_generations (int | None): Maximum number of generations in the evolutionary run, used to scale
                mutation decay. Defaults to None, in which case the budget reported by the terminator is used,
                falling back to the number of function evaluations if the terminator does not bound the number
                of generations. Prefer leaving this as None: a value that disagrees with the terminator makes the
                decay schedule finish too early or not at all.
            mutation_probability (float | None): Probability of mutating each
                gene. If None, defaults to 1 / number of variables.
            b (float): Non-uniform mutation decay parameter. Higher values cause
                faster reduction in mutation strength over generations.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        if max_generations is not None and max_generations <= 0:
            raise ValueError(f"max_generations must be a positive integer, got {max_generations}.")
        self.rng = np.random.default_rng(seed)
        self.seed = seed
        self.b = b
        self.current_generation = 0
        self.max_generations = max_generations
        self.current_evaluations = 0
        self.max_evaluations: int | None = None
        # Budget reported by the terminator, used when max_generations was not given explicitly.
        self.reported_max_generations: int | None = None
        self._warned_past_budget = False
        self.mutation_probability = (
            1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
        )

    @property
    def decay_progress(self) -> float:
        """The fraction of the run that has elapsed, in [0, 1].

        A value of 0 means full mutation strength and a value of 1 means no mutation at all. The
        ratio must never leave [0, 1]: a negative `1 - progress` would be raised to the power `b`
        below, which yields a complex number for a fractional `b` and overflows the float range
        for an integral one.

        Returns:
            float: the elapsed fraction of the run, clamped to [0, 1]. Zero if no budget is known.
        """
        max_generations = self.max_generations if self.max_generations is not None else self.reported_max_generations

        if max_generations is not None and max_generations > 0:
            progress = self.current_generation / max_generations
            if progress > 1.0 and self.max_generations is not None and not self._warned_past_budget:
                self._warned_past_budget = True
                warnings.warn(
                    f"{self.__class__.__name__} was given max_generations={self.max_generations}, but the run has "
                    f"reached generation {self.current_generation}. The mutation strength has already decayed to "
                    "zero and stays there for the rest of the run. Leave max_generations as None to let the "
                    "operator follow the terminator's budget instead.",
                    stacklevel=2,
                )
            return min(progress, 1.0)

        if self.max_evaluations is not None and self.max_evaluations > 0:
            return min(self.current_evaluations / self.max_evaluations, 1.0)

        # Nothing bounds the run (e.g. a time based terminator), so keep the mutation at full strength.
        return 0.0

    def _mutate_value(self, x: float, lower_bound: float, upper_bound: float, mutation_threshold: float = 0.5) -> float:
        """Apply non-uniform mutation to a single float value.

        Args:
            x (float): The current value of the gene to be mutated.
            lower_bound (float): The lower bound of the gene.
            upper_bound (float): The upper bound of the gene.
            mutation_threshold (float): The mutation threshold. Defaults to 0.5.

        Returns:
            float: The mutated gene value, clipped within the bounds [l, u].
        """
        r = self.rng.uniform(0, 1)  # Random number to choose direction
        b = self.b

        u_rand = self.rng.uniform(0, 1)  # Random number for mutation strength
        tau = (1 - self.decay_progress) ** b

        if r <= mutation_threshold:
            y = upper_bound - x
            delta = y * (1 - u_rand**tau)
            xm = x + delta
        else:
            y = x - lower_bound
            delta = y * (1 - u_rand**tau)
            xm = x - delta

        return np.clip(xm, lower_bound, upper_bound)

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Perform non-uniform mutation.

        Args:
            offsprings (pl.DataFrame): The current offspring population to
                mutate. Each row corresponds to one individual.
            parents (pl.DataFrame): The parent population (not used in mutation but passed for interface consistency).

        Returns:
            pl.DataFrame: A new offspring population with mutated values applied. Returned as a Polars DataFrame.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents

        population = offsprings.to_numpy(writable=True).astype(float)

        bounds = list(zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True))
        for i in range(population.shape[0]):
            for j, (lower_bound, upper_bound, discrete) in enumerate(bounds):
                if self.rng.random() < self.mutation_probability:
                    mutated = self._mutate_value(population[i, j], lower_bound, upper_bound)
                    # Round to keep the integer domain. `np.round` rather than `round`, which
                    # raises on the NaN some crossover operators produce.
                    population[i, j] = np.round(mutated) if discrete else mutated

        self.offspring = pl.from_numpy(population, schema=self.variable_symbols, orient="row").cast(pl.Float64)
        self.notify()

        return self.offspring

    def update(self, message: Message):
        """Track the progress of the run (used to reduce mutation strength over time)."""
        if not isinstance(message.topic, TerminatorMessageTopics):
            return
        if not isinstance(message.value, int):
            return
        match message.topic:
            case TerminatorMessageTopics.GENERATION:
                self.current_generation = message.value
            case TerminatorMessageTopics.MAX_GENERATIONS:
                self.reported_max_generations = message.value
            case TerminatorMessageTopics.EVALUATION:
                self.current_evaluations = message.value
            case TerminatorMessageTopics.MAX_EVALUATIONS:
                self.max_evaluations = message.value
            case _:
                return

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
decay_progress property
decay_progress: float

The fraction of the run that has elapsed, in [0, 1].

A value of 0 means full mutation strength and a value of 1 means no mutation at all. The ratio must never leave [0, 1]: a negative 1 - progress would be raised to the power b below, which yields a complex number for a fractional b and overflows the float range for an integral one.

Returns:

Name Type Description
float float

the elapsed fraction of the run, clamped to [0, 1]. Zero if no budget is known.

interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    max_generations: int | None = None,
    mutation_probability: float | None = None,
    b: float = 5.0,
)

Initialize a Non-uniform mutation operator.

Parameters:

Name Type Description Default
problem Problem

The optimization problem definition.

required
seed int

Random number generator seed for reproducibility.

required
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
max_generations int | None

Maximum number of generations in the evolutionary run, used to scale mutation decay. Defaults to None, in which case the budget reported by the terminator is used, falling back to the number of function evaluations if the terminator does not bound the number of generations. Prefer leaving this as None: a value that disagrees with the terminator makes the decay schedule finish too early or not at all.

None
mutation_probability float | None

Probability of mutating each gene. If None, defaults to 1 / number of variables.

None
b float

Non-uniform mutation decay parameter. Higher values cause faster reduction in mutation strength over generations.

5.0
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    max_generations: int | None = None,
    mutation_probability: float | None = None,
    b: float = 5.0,  # decay parameter
):
    """Initialize a Non-uniform mutation operator.

    Args:
        problem (Problem): The optimization problem definition.
        seed (int): Random number generator seed for reproducibility.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
        max_generations (int | None): Maximum number of generations in the evolutionary run, used to scale
            mutation decay. Defaults to None, in which case the budget reported by the terminator is used,
            falling back to the number of function evaluations if the terminator does not bound the number
            of generations. Prefer leaving this as None: a value that disagrees with the terminator makes the
            decay schedule finish too early or not at all.
        mutation_probability (float | None): Probability of mutating each
            gene. If None, defaults to 1 / number of variables.
        b (float): Non-uniform mutation decay parameter. Higher values cause
            faster reduction in mutation strength over generations.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    if max_generations is not None and max_generations <= 0:
        raise ValueError(f"max_generations must be a positive integer, got {max_generations}.")
    self.rng = np.random.default_rng(seed)
    self.seed = seed
    self.b = b
    self.current_generation = 0
    self.max_generations = max_generations
    self.current_evaluations = 0
    self.max_evaluations: int | None = None
    # Budget reported by the terminator, used when max_generations was not given explicitly.
    self.reported_max_generations: int | None = None
    self._warned_past_budget = False
    self.mutation_probability = (
        1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
    )
_mutate_value
_mutate_value(
    x: float,
    lower_bound: float,
    upper_bound: float,
    mutation_threshold: float = 0.5,
) -> float

Apply non-uniform mutation to a single float value.

Parameters:

Name Type Description Default
x float

The current value of the gene to be mutated.

required
lower_bound float

The lower bound of the gene.

required
upper_bound float

The upper bound of the gene.

required
mutation_threshold float

The mutation threshold. Defaults to 0.5.

0.5

Returns:

Name Type Description
float float

The mutated gene value, clipped within the bounds [l, u].

Source code in desdeo/emo/operators/mutation.py
def _mutate_value(self, x: float, lower_bound: float, upper_bound: float, mutation_threshold: float = 0.5) -> float:
    """Apply non-uniform mutation to a single float value.

    Args:
        x (float): The current value of the gene to be mutated.
        lower_bound (float): The lower bound of the gene.
        upper_bound (float): The upper bound of the gene.
        mutation_threshold (float): The mutation threshold. Defaults to 0.5.

    Returns:
        float: The mutated gene value, clipped within the bounds [l, u].
    """
    r = self.rng.uniform(0, 1)  # Random number to choose direction
    b = self.b

    u_rand = self.rng.uniform(0, 1)  # Random number for mutation strength
    tau = (1 - self.decay_progress) ** b

    if r <= mutation_threshold:
        y = upper_bound - x
        delta = y * (1 - u_rand**tau)
        xm = x + delta
    else:
        y = x - lower_bound
        delta = y * (1 - u_rand**tau)
        xm = x - delta

    return np.clip(xm, lower_bound, upper_bound)
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Perform non-uniform mutation.

Parameters:

Name Type Description Default
offsprings DataFrame

The current offspring population to mutate. Each row corresponds to one individual.

required
parents DataFrame

The parent population (not used in mutation but passed for interface consistency).

required

Returns:

Type Description
DataFrame

pl.DataFrame: A new offspring population with mutated values applied. Returned as a Polars DataFrame.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Perform non-uniform mutation.

    Args:
        offsprings (pl.DataFrame): The current offspring population to
            mutate. Each row corresponds to one individual.
        parents (pl.DataFrame): The parent population (not used in mutation but passed for interface consistency).

    Returns:
        pl.DataFrame: A new offspring population with mutated values applied. Returned as a Polars DataFrame.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents

    population = offsprings.to_numpy(writable=True).astype(float)

    bounds = list(zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True))
    for i in range(population.shape[0]):
        for j, (lower_bound, upper_bound, discrete) in enumerate(bounds):
            if self.rng.random() < self.mutation_probability:
                mutated = self._mutate_value(population[i, j], lower_bound, upper_bound)
                # Round to keep the integer domain. `np.round` rather than `round`, which
                # raises on the NaN some crossover operators produce.
                population[i, j] = np.round(mutated) if discrete else mutated

    self.offspring = pl.from_numpy(population, schema=self.variable_symbols, orient="row").cast(pl.Float64)
    self.notify()

    return self.offspring
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(message: Message)

Track the progress of the run (used to reduce mutation strength over time).

Source code in desdeo/emo/operators/mutation.py
def update(self, message: Message):
    """Track the progress of the run (used to reduce mutation strength over time)."""
    if not isinstance(message.topic, TerminatorMessageTopics):
        return
    if not isinstance(message.value, int):
        return
    match message.topic:
        case TerminatorMessageTopics.GENERATION:
            self.current_generation = message.value
        case TerminatorMessageTopics.MAX_GENERATIONS:
            self.reported_max_generations = message.value
        case TerminatorMessageTopics.EVALUATION:
            self.current_evaluations = message.value
        case TerminatorMessageTopics.MAX_EVALUATIONS:
            self.max_evaluations = message.value
        case _:
            return

PowerMutation

Bases: BaseMutation

Implements the Power Mutation (PM) operator for real and integer variables.

Source code in desdeo/emo/operators/mutation.py
class PowerMutation(BaseMutation):
    """Implements the Power Mutation (PM) operator for real and integer variables."""

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [MutationMessageTopics.MUTATION_PROBABILITY],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator listens to (none in this case)."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        p: float = 1.5,
        mutation_probability: float | None = None,
    ):
        """Initialize the PowerMutation operator.

        Args:
            problem (Problem): The problem definition containing variable bounds and types.
            seed (int): Random seed for reproducibility.
            p (float): Power distribution parameter. Controls the perturbation magnitude. Default is 1.5.
            mutation_probability (float | None): Per-variable mutation probability. Defaults to 1/n.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.p = p
        self.mutation_probability = (
            mutation_probability if mutation_probability is not None else 1 / len(self.variable_symbols)
        )
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
        """Apply Power Mutation to the given offspring population.

        Args:
            offsprings (pl.DataFrame): The offspring population to mutate.
            parents (pl.DataFrame): The parent population

        Returns:
            pl.DataFrame: Mutated offspring population.
        """
        self.offspring_original = copy.copy(offsprings)
        self.parents = parents

        if self.mutation_probability == 0.0:
            self.offspring = offsprings.clone()
            self.notify()
            return self.offspring

        population = offsprings.to_numpy(writable=True).astype(float)
        mutation_mask = self.rng.random(population.shape) < self.mutation_probability
        mutated = population.copy()

        bounds = zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True)
        for i, (lower_bound, upper_bound, discrete) in enumerate(bounds):
            if upper_bound == lower_bound:
                # A fixed variable has a single feasible value, and scaling by the width of an
                # empty interval would divide by zero. Leave the column as it is.
                continue
            x_i = population[:, i]

            u_i = self.rng.random(len(x_i))  # uniform random number
            s_i = u_i ** (1 / self.p)  # random number that follows the power distribution

            r_i = self.rng.random(len(x_i))  # another uniform random number
            direction = ((x_i - lower_bound) / (upper_bound - lower_bound)) < r_i  # used as condition

            xi_mutated = np.where(direction, x_i - s_i * (x_i - lower_bound), x_i + s_i * (upper_bound - x_i))
            if discrete:
                # Round after float mutation to keep the integer domain.
                xi_mutated = np.round(xi_mutated)

            # Apply mutation based on mask
            mutated[:, i] = np.where(mutation_mask[:, i], xi_mutated, x_i)

        # Convert back to DataFrame
        self.offspring = (
            pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
        )
        self.notify()

        return self.offspring

    def update(self, *_, **__):
        """No update logic needed."""

    def state(self) -> Sequence[Message]:
        """Return mutation-related state messages based on verbosity level.

        Returns:
            List of messages reporting mutation probability, input, and output (at higher verbosity).
        """
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []

        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]

        # Verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator listens to (none in this case).

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    p: float = 1.5,
    mutation_probability: float | None = None,
)

Initialize the PowerMutation operator.

Parameters:

Name Type Description Default
problem Problem

The problem definition containing variable bounds and types.

required
seed int

Random seed for reproducibility.

required
p float

Power distribution parameter. Controls the perturbation magnitude. Default is 1.5.

1.5
mutation_probability float | None

Per-variable mutation probability. Defaults to 1/n.

None
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    p: float = 1.5,
    mutation_probability: float | None = None,
):
    """Initialize the PowerMutation operator.

    Args:
        problem (Problem): The problem definition containing variable bounds and types.
        seed (int): Random seed for reproducibility.
        p (float): Power distribution parameter. Controls the perturbation magnitude. Default is 1.5.
        mutation_probability (float | None): Per-variable mutation probability. Defaults to 1/n.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.p = p
    self.mutation_probability = (
        mutation_probability if mutation_probability is not None else 1 / len(self.variable_symbols)
    )
    self.rng = np.random.default_rng(seed)
    self.seed = seed
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Apply Power Mutation to the given offspring population.

Parameters:

Name Type Description Default
offsprings DataFrame

The offspring population to mutate.

required
parents DataFrame

The parent population

required

Returns:

Type Description
DataFrame

pl.DataFrame: Mutated offspring population.

Source code in desdeo/emo/operators/mutation.py
def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
    """Apply Power Mutation to the given offspring population.

    Args:
        offsprings (pl.DataFrame): The offspring population to mutate.
        parents (pl.DataFrame): The parent population

    Returns:
        pl.DataFrame: Mutated offspring population.
    """
    self.offspring_original = copy.copy(offsprings)
    self.parents = parents

    if self.mutation_probability == 0.0:
        self.offspring = offsprings.clone()
        self.notify()
        return self.offspring

    population = offsprings.to_numpy(writable=True).astype(float)
    mutation_mask = self.rng.random(population.shape) < self.mutation_probability
    mutated = population.copy()

    bounds = zip(self.lower_bounds, self.upper_bounds, self.is_discrete, strict=True)
    for i, (lower_bound, upper_bound, discrete) in enumerate(bounds):
        if upper_bound == lower_bound:
            # A fixed variable has a single feasible value, and scaling by the width of an
            # empty interval would divide by zero. Leave the column as it is.
            continue
        x_i = population[:, i]

        u_i = self.rng.random(len(x_i))  # uniform random number
        s_i = u_i ** (1 / self.p)  # random number that follows the power distribution

        r_i = self.rng.random(len(x_i))  # another uniform random number
        direction = ((x_i - lower_bound) / (upper_bound - lower_bound)) < r_i  # used as condition

        xi_mutated = np.where(direction, x_i - s_i * (x_i - lower_bound), x_i + s_i * (upper_bound - x_i))
        if discrete:
            # Round after float mutation to keep the integer domain.
            xi_mutated = np.round(xi_mutated)

        # Apply mutation based on mask
        mutated[:, i] = np.where(mutation_mask[:, i], xi_mutated, x_i)

    # Convert back to DataFrame
    self.offspring = (
        pl.from_numpy(mutated, schema=self.variable_symbols, orient="row").select(pl.all()).cast(pl.Float64)
    )
    self.notify()

    return self.offspring
state
state() -> Sequence[Message]

Return mutation-related state messages based on verbosity level.

Returns:

Type Description
Sequence[Message]

List of messages reporting mutation probability, input, and output (at higher verbosity).

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return mutation-related state messages based on verbosity level.

    Returns:
        List of messages reporting mutation probability, input, and output (at higher verbosity).
    """
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []

    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]

    # Verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

No update logic needed.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """No update logic needed."""

SelfAdaptiveGaussianMutation

Bases: BaseMutation

Self-adaptive Gaussian mutation for real-coded evolutionary algorithms.

Evolves both solution vector and mutation step sizes (strategy parameters).

Source code in desdeo/emo/operators/mutation.py
class SelfAdaptiveGaussianMutation(BaseMutation):
    """Self-adaptive Gaussian mutation for real-coded evolutionary algorithms.

    Evolves both solution vector and mutation step sizes (strategy parameters).
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
        """The message topics provided by the mutation operator."""
        return {
            0: [],
            1: [
                MutationMessageTopics.MUTATION_PROBABILITY,
            ],
            2: [
                MutationMessageTopics.MUTATION_PROBABILITY,
                MutationMessageTopics.OFFSPRING_ORIGINAL,
                MutationMessageTopics.PARENTS,
                MutationMessageTopics.OFFSPRINGS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics that the mutation operator is interested in."""
        return []

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        verbosity: int,
        publisher: Publisher,
        mutation_probability: float | None = None,
    ):
        """Initialize the self-adaptive Gaussian mutation operator.

        Args:
            problem (Problem): The optimization problem definition, including variable bounds and types.
            seed (int): Seed for the random number generator to ensure reproducibility.
            mutation_probability (float | None): Probability of mutating each gene.
                If None, it defaults to 1 divided by the number of variables.
            verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
                messages are provided at each verbosity level. Recommended value is 1.
            publisher (Publisher): The publisher to which the operator will send messages.

        Attributes:
            rng (Generator): NumPy random number generator initialized with the given seed.
            seed (int): The seed used for reproducibility.
            num_vars (int): Number of variables in the problem.
            mutation_probability (float): Probability of mutating each gene.
            tau_prime (float): Global learning rate, used in step size adaptation.
            tau (float): Local learning rate, used in step size adaptation.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

        self.rng = np.random.default_rng(seed)
        self.seed = seed
        self.num_vars = len(self.variable_symbols)

        self.mutation_probability = 1 / self.num_vars if mutation_probability is None else mutation_probability

        self.tau_prime = 1 / np.sqrt(2 * self.num_vars)
        self.tau = 1 / np.sqrt(2 * np.sqrt(self.num_vars))

        # Per-gene step sizes, adapted on each call and carried over across generations.
        self.step_sizes: np.ndarray | None = None

    def do(
        self,
        offsprings: pl.DataFrame,
        parents: pl.DataFrame,
    ) -> pl.DataFrame:
        """Apply self-adaptive Gaussian mutation.

        The per-gene step sizes are adapted on every call and stored in `self.step_sizes`,
        so that the adaptation carries over across generations.

        Args:
            offsprings (pl.DataFrame): Current offspring population.
            parents (pl.DataFrame): Parent population.

        Returns:
            pl.DataFrame: The mutated offspring population.
        """
        self.offspring_original = offsprings
        self.parents = parents

        offspring_array = offsprings.to_numpy(writable=True).astype(float)

        if self.step_sizes is None or self.step_sizes.shape != offspring_array.shape:
            self.step_sizes = np.full_like(offspring_array, fill_value=0.1)

        new_offspring, self.step_sizes = self._mutation(offspring_array, self.step_sizes)

        mutated_df = pl.from_numpy(new_offspring, schema=self.variable_symbols, orient="row").cast(pl.Float64)
        self.offspring = mutated_df
        self.notify()

        return mutated_df

    def _mutation(self, variables: np.ndarray, eta: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
        """Perform the self-adaptive mutation.

        Args:
            variables (np.ndarray): Current offspring population as a NumPy array.
            eta (np.ndarray): Current step sizes for mutation.

        Returns:
            tuple[np.ndarray, np.ndarray]: Mutated population and updated step sizes.
        """
        new_variables = variables.copy()
        new_eta = eta.copy()

        for i in range(variables.shape[0]):
            common_noise = self.rng.normal()
            for j in range(variables.shape[1]):
                if self.rng.random() < self.mutation_probability:
                    rnd_number = self.rng.normal()  # random number in the interval [0, 1]
                    new_eta[i, j] *= np.exp(self.tau_prime * common_noise + self.tau * rnd_number)
                    new_variables[i, j] += new_eta[i, j] * rnd_number

        # Gaussian noise is unbounded, so keep the offspring inside the feasible box. Without this
        # the operator relies on a repair function that the templates only apply afterwards.
        new_variables = np.clip(new_variables, self.lower_bounds, self.upper_bounds)

        return new_variables, new_eta

    def update(self, *_, **__):
        """Do nothing."""

    def state(self) -> Sequence[Message]:
        """Return the state of the mutation operator."""
        if self.offspring_original is None or self.parents is None or self.offspring is None:
            return []
        if self.verbosity == 0:
            return []
        if self.verbosity == 1:
            return [
                FloatMessage(
                    topic=MutationMessageTopics.MUTATION_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.mutation_probability,
                ),
            ]
        # verbosity == 2
        return [
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
                source=self.__class__.__name__,
                value=self.offspring_original,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.PARENTS,
                source=self.__class__.__name__,
                value=self.parents,
            ),
            PolarsDataFrameMessage(
                topic=MutationMessageTopics.OFFSPRINGS,
                source=self.__class__.__name__,
                value=self.offspring,
            ),
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
interested_topics property
interested_topics

The message topics that the mutation operator is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MutationMessageTopics]]

The message topics provided by the mutation operator.

__init__
__init__(
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
)

Initialize the self-adaptive Gaussian mutation operator.

Parameters:

Name Type Description Default
problem Problem

The optimization problem definition, including variable bounds and types.

required
seed int

Seed for the random number generator to ensure reproducibility.

required
mutation_probability float | None

Probability of mutating each gene. If None, it defaults to 1 divided by the number of variables.

None
verbosity int

The verbosity level of the operator. See the provided_topics attribute to see what messages are provided at each verbosity level. Recommended value is 1.

required
publisher Publisher

The publisher to which the operator will send messages.

required

Attributes:

Name Type Description
rng Generator

NumPy random number generator initialized with the given seed.

seed int

The seed used for reproducibility.

num_vars int

Number of variables in the problem.

mutation_probability float

Probability of mutating each gene.

tau_prime float

Global learning rate, used in step size adaptation.

tau float

Local learning rate, used in step size adaptation.

Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    mutation_probability: float | None = None,
):
    """Initialize the self-adaptive Gaussian mutation operator.

    Args:
        problem (Problem): The optimization problem definition, including variable bounds and types.
        seed (int): Seed for the random number generator to ensure reproducibility.
        mutation_probability (float | None): Probability of mutating each gene.
            If None, it defaults to 1 divided by the number of variables.
        verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
            messages are provided at each verbosity level. Recommended value is 1.
        publisher (Publisher): The publisher to which the operator will send messages.

    Attributes:
        rng (Generator): NumPy random number generator initialized with the given seed.
        seed (int): The seed used for reproducibility.
        num_vars (int): Number of variables in the problem.
        mutation_probability (float): Probability of mutating each gene.
        tau_prime (float): Global learning rate, used in step size adaptation.
        tau (float): Local learning rate, used in step size adaptation.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

    self.rng = np.random.default_rng(seed)
    self.seed = seed
    self.num_vars = len(self.variable_symbols)

    self.mutation_probability = 1 / self.num_vars if mutation_probability is None else mutation_probability

    self.tau_prime = 1 / np.sqrt(2 * self.num_vars)
    self.tau = 1 / np.sqrt(2 * np.sqrt(self.num_vars))

    # Per-gene step sizes, adapted on each call and carried over across generations.
    self.step_sizes: np.ndarray | None = None
_mutation
_mutation(
    variables: ndarray, eta: ndarray
) -> tuple[np.ndarray, np.ndarray]

Perform the self-adaptive mutation.

Parameters:

Name Type Description Default
variables ndarray

Current offspring population as a NumPy array.

required
eta ndarray

Current step sizes for mutation.

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: Mutated population and updated step sizes.

Source code in desdeo/emo/operators/mutation.py
def _mutation(self, variables: np.ndarray, eta: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Perform the self-adaptive mutation.

    Args:
        variables (np.ndarray): Current offspring population as a NumPy array.
        eta (np.ndarray): Current step sizes for mutation.

    Returns:
        tuple[np.ndarray, np.ndarray]: Mutated population and updated step sizes.
    """
    new_variables = variables.copy()
    new_eta = eta.copy()

    for i in range(variables.shape[0]):
        common_noise = self.rng.normal()
        for j in range(variables.shape[1]):
            if self.rng.random() < self.mutation_probability:
                rnd_number = self.rng.normal()  # random number in the interval [0, 1]
                new_eta[i, j] *= np.exp(self.tau_prime * common_noise + self.tau * rnd_number)
                new_variables[i, j] += new_eta[i, j] * rnd_number

    # Gaussian noise is unbounded, so keep the offspring inside the feasible box. Without this
    # the operator relies on a repair function that the templates only apply afterwards.
    new_variables = np.clip(new_variables, self.lower_bounds, self.upper_bounds)

    return new_variables, new_eta
do
do(
    offsprings: DataFrame, parents: DataFrame
) -> pl.DataFrame

Apply self-adaptive Gaussian mutation.

The per-gene step sizes are adapted on every call and stored in self.step_sizes, so that the adaptation carries over across generations.

Parameters:

Name Type Description Default
offsprings DataFrame

Current offspring population.

required
parents DataFrame

Parent population.

required

Returns:

Type Description
DataFrame

pl.DataFrame: The mutated offspring population.

Source code in desdeo/emo/operators/mutation.py
def do(
    self,
    offsprings: pl.DataFrame,
    parents: pl.DataFrame,
) -> pl.DataFrame:
    """Apply self-adaptive Gaussian mutation.

    The per-gene step sizes are adapted on every call and stored in `self.step_sizes`,
    so that the adaptation carries over across generations.

    Args:
        offsprings (pl.DataFrame): Current offspring population.
        parents (pl.DataFrame): Parent population.

    Returns:
        pl.DataFrame: The mutated offspring population.
    """
    self.offspring_original = offsprings
    self.parents = parents

    offspring_array = offsprings.to_numpy(writable=True).astype(float)

    if self.step_sizes is None or self.step_sizes.shape != offspring_array.shape:
        self.step_sizes = np.full_like(offspring_array, fill_value=0.1)

    new_offspring, self.step_sizes = self._mutation(offspring_array, self.step_sizes)

    mutated_df = pl.from_numpy(new_offspring, schema=self.variable_symbols, orient="row").cast(pl.Float64)
    self.offspring = mutated_df
    self.notify()

    return mutated_df
state
state() -> Sequence[Message]

Return the state of the mutation operator.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return the state of the mutation operator."""
    if self.offspring_original is None or self.parents is None or self.offspring is None:
        return []
    if self.verbosity == 0:
        return []
    if self.verbosity == 1:
        return [
            FloatMessage(
                topic=MutationMessageTopics.MUTATION_PROBABILITY,
                source=self.__class__.__name__,
                value=self.mutation_probability,
            ),
        ]
    # verbosity == 2
    return [
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
            source=self.__class__.__name__,
            value=self.offspring_original,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.PARENTS,
            source=self.__class__.__name__,
            value=self.parents,
        ),
        PolarsDataFrameMessage(
            topic=MutationMessageTopics.OFFSPRINGS,
            source=self.__class__.__name__,
            value=self.offspring,
        ),
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(*_, **__)

Do nothing.

Source code in desdeo/emo/operators/mutation.py
def update(self, *_, **__):
    """Do nothing."""

Selection operators

desdeo.emo.operators.selection

The base class for selection operators.

Some operators should be rewritten. TODO:@light-weaver

BaseDecompositionSelector

Bases: BaseSelector

Base class for decomposition based selection operators.

Source code in desdeo/emo/operators/selection.py
class BaseDecompositionSelector(BaseSelector):
    """Base class for decomposition based selection operators."""

    def __init__(
        self,
        problem: Problem,
        reference_vector_options: ReferenceVectorOptions,
        verbosity: int,
        publisher: Publisher,
        invert_reference_vectors: bool = False,
        seed: int = 0,
    ):
        """Initialize the base decomposition based selector.

        Args:
            problem (Problem): the problem being solved.
            reference_vector_options (ReferenceVectorOptions): options for creating and adapting the reference vectors.
            verbosity (int): the verbosity level of the operator.
            publisher (Publisher): the publisher used to communicate with other operators.
            invert_reference_vectors (bool, optional): whether to invert the reference vectors. Defaults to False.
            seed (int, optional): the random seed. Defaults to 0.
        """
        super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.reference_vector_options = reference_vector_options
        self.invert_reference_vectors = invert_reference_vectors
        self.reference_vectors: np.ndarray
        self.reference_vectors_initial: np.ndarray

        if self.reference_vector_options.creation_type == "s_energy":
            self._create_s_energy()
        else:
            self._create_simplex()

        if self.reference_vector_options.reference_point:
            corrected_rp = np.array(
                [
                    self.reference_vector_options.reference_point[x] * self.maximization_mult[x]
                    for x in self.objective_symbols
                ]
            )
            self.interactive_adapt_3(
                corrected_rp,
                translation_param=self.reference_vector_options.adaptation_distance,
            )
        elif self.reference_vector_options.preferred_solutions:
            corrected_sols = np.array(
                [
                    np.array(self.reference_vector_options.preferred_solutions[x]) * self.maximization_mult[x]
                    for x in self.objective_symbols
                ]
            ).T
            self.interactive_adapt_1(
                corrected_sols,
                translation_param=self.reference_vector_options.adaptation_distance,
            )
        elif self.reference_vector_options.non_preferred_solutions:
            corrected_sols = np.array(
                [
                    np.array(self.reference_vector_options.non_preferred_solutions[x]) * self.maximization_mult[x]
                    for x in self.objective_symbols
                ]
            ).T
            self.interactive_adapt_2(
                corrected_sols,
                predefined_distance=self.reference_vector_options.adaptation_distance,
                norm_order=2 if self.reference_vector_options.vector_type == "spherical" else 1,
            )
        elif self.reference_vector_options.preferred_ranges:
            corrected_ranges = np.array(
                [
                    np.array(self.reference_vector_options.preferred_ranges[x]) * self.maximization_mult[x]
                    for x in self.objective_symbols
                ]
            ).T
            self.interactive_adapt_4(
                corrected_ranges,
            )

    def _create_s_energy(self):
        """Create the reference vectors by minimizing the Riesz s-energy.

        Unlike the simplex lattice, this honours `number_of_vectors` exactly, so the population size
        of a decomposition-based algorithm can be chosen freely rather than being rounded down to the
        nearest binomial count. `lattice_resolution` is not meaningful here and is left untouched.
        """
        vectors = create_s_energy(
            number_of_objectives=self.num_dims,
            number_of_vectors=self.reference_vector_options.number_of_vectors,
            seed=self.seed,
        )
        # `invert_reference_vectors` mirrors the simplex branch, where it exists for NSGA-III.
        self.reference_vectors = vectors if not self.invert_reference_vectors else 1 - vectors
        self.reference_vectors_initial = np.copy(self.reference_vectors)
        self._normalize_rvs()

    def _create_simplex(self):
        """Create the reference vectors using simplex lattice design."""

        def approx_lattice_resolution(number_of_vectors: int, num_dims: int) -> int:
            """Approximate the lattice resolution based on the number of vectors."""
            temp_lattice_resolution = 0
            while True:
                temp_lattice_resolution += 1
                temp_number_of_vectors = comb(
                    temp_lattice_resolution + num_dims - 1,
                    num_dims - 1,
                    exact=True,
                )
                if temp_number_of_vectors > number_of_vectors:
                    break
            return temp_lattice_resolution - 1

        if self.reference_vector_options.lattice_resolution:
            lattice_resolution = self.reference_vector_options.lattice_resolution
        else:
            lattice_resolution = approx_lattice_resolution(
                self.reference_vector_options.number_of_vectors, num_dims=self.num_dims
            )

        number_of_vectors: int = comb(
            lattice_resolution + self.num_dims - 1,
            self.num_dims - 1,
            exact=True,
        )

        self.reference_vector_options.number_of_vectors = number_of_vectors
        self.reference_vector_options.lattice_resolution = lattice_resolution

        temp1 = range(1, self.num_dims + lattice_resolution)
        temp1 = np.array(list(combinations(temp1, self.num_dims - 1)))
        temp2 = np.array([range(self.num_dims - 1)] * number_of_vectors)
        temp = temp1 - temp2 - 1
        weight = np.zeros((number_of_vectors, self.num_dims), dtype=int)
        weight[:, 0] = temp[:, 0]
        for i in range(1, self.num_dims - 1):
            weight[:, i] = temp[:, i] - temp[:, i - 1]
        weight[:, -1] = lattice_resolution - temp[:, -1]
        if not self.invert_reference_vectors:  # todo, this currently only exists for nsga3
            self.reference_vectors = weight / lattice_resolution
        else:
            self.reference_vectors = 1 - (weight / lattice_resolution)
        self.reference_vectors_initial = np.copy(self.reference_vectors)
        self._normalize_rvs()

    def _normalize_rvs(self):
        """Normalize the reference vectors to a unit hypersphere."""
        if self.reference_vector_options.vector_type == "spherical":
            norm = np.linalg.norm(self.reference_vectors, axis=1).reshape(-1, 1)
            norm[norm == 0] = np.finfo(float).eps
            self.reference_vectors = np.divide(self.reference_vectors, norm)
            return
        if self.reference_vector_options.vector_type == "planar":
            if not self.invert_reference_vectors:
                norm = np.sum(self.reference_vectors, axis=1).reshape(-1, 1)
                self.reference_vectors = np.divide(self.reference_vectors, norm)
                return
            norm = np.sum(1 - self.reference_vectors, axis=1).reshape(-1, 1)
            self.reference_vectors = 1 - np.divide(1 - self.reference_vectors, norm)
            return
        # Not needed due to pydantic validation
        raise ValueError("Invalid vector type. Must be either 'spherical' or 'planar'.")

    def interactive_adapt_1(self, z: np.ndarray, translation_param: float) -> None:
        """Adapt reference vectors using the information about prefererred solution(s) selected by the Decision maker.

        Args:
            z (np.ndarray): Preferred solution(s).
            translation_param (float): Parameter determining how close the reference vectors are to the central vector
                **v** defined by using the selected solution(s) z.
        """
        if z.shape[0] == 1:
            # single preferred solution
            # calculate new reference vectors
            self.reference_vectors = translation_param * self.reference_vectors_initial + ((1 - translation_param) * z)

        else:
            # multiple preferred solutions
            # calculate new reference vectors for each preferred solution
            values = [translation_param * self.reference_vectors_initial + ((1 - translation_param) * z_i) for z_i in z]

            # combine arrays of reference vectors into a single array and update reference vectors
            self.reference_vectors = np.concatenate(values)

        self._normalize_rvs()
        self.add_edge_vectors()

    def interactive_adapt_2(self, z: np.ndarray, predefined_distance: float, norm_order: int) -> None:
        """Adapt reference vectors using information about non-preferred solution(s) from the Decision maker.

        After the Decision maker has specified non-preferred solution(s), Euclidian distance between normalized solution
        vector(s) and each of the reference vectors are calculated. Those reference vectors that are **closer** than a
        predefined distance are either **removed** or **re-positioned** somewhere else.

        Note:
            At the moment, only the **removal** of reference vectors is supported. Repositioning of the reference
            vectors is **not** supported.

        Note:
            In case the Decision maker specifies multiple non-preferred solutions, the reference vector(s) for which the
            distance to **any** of the non-preferred solutions is less than predefined distance are removed.

        Note:
            Future developer should implement a way for a user to say: "Remove some percentage of
            objecive space/reference vectors" rather than giving a predefined distance value.

        Args:
            z (np.ndarray): Non-preferred solution(s).
            predefined_distance (float): The reference vectors that are closer than this distance are either removed or
                re-positioned somewhere else. Default value: 0.2
            norm_order (int): Order of the norm. Default is 2, i.e., Euclidian distance.
        """
        # calculate L1 norm of non-preferred solution(s)
        z = np.atleast_2d(z)
        norm = np.linalg.norm(z, ord=norm_order, axis=1).reshape(np.shape(z)[0], 1)

        # non-preferred solutions normalized
        v_c = np.divide(z, norm)

        # distances from non-preferred solution(s) to each reference vector
        distances = np.array(
            [[np.linalg.norm(solution - value, ord=2) for solution in v_c] for value in self.reference_vectors]
        )

        # find out reference vectors that are not closer than threshold value to any non-preferred solution
        mask = [all(d >= predefined_distance) for d in distances]

        # set those reference vectors that met previous condition as new reference vectors, drop others
        self.reference_vectors = self.reference_vectors[mask]

        self._normalize_rvs()
        self.add_edge_vectors()

    def interactive_adapt_3(self, ref_point, translation_param):
        """Adapt reference vectors linearly towards a reference point. Then normalize.

        The details can be found in the following paper: Hakanen, Jussi &
        Chugh, Tinkle & Sindhya, Karthik & Jin, Yaochu & Miettinen, Kaisa.
        (2016). Connections of Reference Vectors and Different Types of
        Preference Information in Interactive Multiobjective Evolutionary
        Algorithms.

        Parameters
        ----------
        ref_point :

        translation_param :
            (Default value = 0.2)

        """
        self.reference_vectors = self.reference_vectors_initial * translation_param + (
            (1 - translation_param) * ref_point
        )
        self._normalize_rvs()
        self.add_edge_vectors()

    def interactive_adapt_4(self, preferred_ranges: np.ndarray) -> None:
        """Adapt reference vectors using the Decision maker's preferred range for each objective.

        Using these ranges, Latin hypercube sampling is applied to generate m number of samples between
        within these ranges, where m is the number of reference vectors. Normalized vectors constructed of these samples
        are then set as new reference vectors.

        Args:
            preferred_ranges (np.ndarray): Preferred lower and upper bound for each of the objective function values.
        """
        # bounds
        lower_limits = np.min(preferred_ranges, axis=0)
        upper_limits = np.max(preferred_ranges, axis=0)

        # generate samples using Latin hypercube sampling
        lhs = LatinHypercube(d=self.num_dims, seed=self.rng)
        w = lhs.random(n=self.reference_vectors_initial.shape[0])

        # scale between bounds
        w = w * (upper_limits - lower_limits) + lower_limits

        # set new reference vectors and normalize them
        self.reference_vectors = w
        self._normalize_rvs()
        self.add_edge_vectors()

    def add_edge_vectors(self):
        """Add edge vectors to the list of reference vectors.

        Used to cover the entire orthant when preference information is
        provided.

        """
        edge_vectors = np.eye(self.reference_vectors.shape[1])
        self.reference_vectors = np.vstack([self.reference_vectors, edge_vectors])
        self._normalize_rvs()
__init__
__init__(
    problem: Problem,
    reference_vector_options: ReferenceVectorOptions,
    verbosity: int,
    publisher: Publisher,
    invert_reference_vectors: bool = False,
    seed: int = 0,
)

Initialize the base decomposition based selector.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
reference_vector_options ReferenceVectorOptions

options for creating and adapting the reference vectors.

required
verbosity int

the verbosity level of the operator.

required
publisher Publisher

the publisher used to communicate with other operators.

required
invert_reference_vectors bool

whether to invert the reference vectors. Defaults to False.

False
seed int

the random seed. Defaults to 0.

0
Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    reference_vector_options: ReferenceVectorOptions,
    verbosity: int,
    publisher: Publisher,
    invert_reference_vectors: bool = False,
    seed: int = 0,
):
    """Initialize the base decomposition based selector.

    Args:
        problem (Problem): the problem being solved.
        reference_vector_options (ReferenceVectorOptions): options for creating and adapting the reference vectors.
        verbosity (int): the verbosity level of the operator.
        publisher (Publisher): the publisher used to communicate with other operators.
        invert_reference_vectors (bool, optional): whether to invert the reference vectors. Defaults to False.
        seed (int, optional): the random seed. Defaults to 0.
    """
    super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.reference_vector_options = reference_vector_options
    self.invert_reference_vectors = invert_reference_vectors
    self.reference_vectors: np.ndarray
    self.reference_vectors_initial: np.ndarray

    if self.reference_vector_options.creation_type == "s_energy":
        self._create_s_energy()
    else:
        self._create_simplex()

    if self.reference_vector_options.reference_point:
        corrected_rp = np.array(
            [
                self.reference_vector_options.reference_point[x] * self.maximization_mult[x]
                for x in self.objective_symbols
            ]
        )
        self.interactive_adapt_3(
            corrected_rp,
            translation_param=self.reference_vector_options.adaptation_distance,
        )
    elif self.reference_vector_options.preferred_solutions:
        corrected_sols = np.array(
            [
                np.array(self.reference_vector_options.preferred_solutions[x]) * self.maximization_mult[x]
                for x in self.objective_symbols
            ]
        ).T
        self.interactive_adapt_1(
            corrected_sols,
            translation_param=self.reference_vector_options.adaptation_distance,
        )
    elif self.reference_vector_options.non_preferred_solutions:
        corrected_sols = np.array(
            [
                np.array(self.reference_vector_options.non_preferred_solutions[x]) * self.maximization_mult[x]
                for x in self.objective_symbols
            ]
        ).T
        self.interactive_adapt_2(
            corrected_sols,
            predefined_distance=self.reference_vector_options.adaptation_distance,
            norm_order=2 if self.reference_vector_options.vector_type == "spherical" else 1,
        )
    elif self.reference_vector_options.preferred_ranges:
        corrected_ranges = np.array(
            [
                np.array(self.reference_vector_options.preferred_ranges[x]) * self.maximization_mult[x]
                for x in self.objective_symbols
            ]
        ).T
        self.interactive_adapt_4(
            corrected_ranges,
        )
_create_s_energy
_create_s_energy()

Create the reference vectors by minimizing the Riesz s-energy.

Unlike the simplex lattice, this honours number_of_vectors exactly, so the population size of a decomposition-based algorithm can be chosen freely rather than being rounded down to the nearest binomial count. lattice_resolution is not meaningful here and is left untouched.

Source code in desdeo/emo/operators/selection.py
def _create_s_energy(self):
    """Create the reference vectors by minimizing the Riesz s-energy.

    Unlike the simplex lattice, this honours `number_of_vectors` exactly, so the population size
    of a decomposition-based algorithm can be chosen freely rather than being rounded down to the
    nearest binomial count. `lattice_resolution` is not meaningful here and is left untouched.
    """
    vectors = create_s_energy(
        number_of_objectives=self.num_dims,
        number_of_vectors=self.reference_vector_options.number_of_vectors,
        seed=self.seed,
    )
    # `invert_reference_vectors` mirrors the simplex branch, where it exists for NSGA-III.
    self.reference_vectors = vectors if not self.invert_reference_vectors else 1 - vectors
    self.reference_vectors_initial = np.copy(self.reference_vectors)
    self._normalize_rvs()
_create_simplex
_create_simplex()

Create the reference vectors using simplex lattice design.

Source code in desdeo/emo/operators/selection.py
def _create_simplex(self):
    """Create the reference vectors using simplex lattice design."""

    def approx_lattice_resolution(number_of_vectors: int, num_dims: int) -> int:
        """Approximate the lattice resolution based on the number of vectors."""
        temp_lattice_resolution = 0
        while True:
            temp_lattice_resolution += 1
            temp_number_of_vectors = comb(
                temp_lattice_resolution + num_dims - 1,
                num_dims - 1,
                exact=True,
            )
            if temp_number_of_vectors > number_of_vectors:
                break
        return temp_lattice_resolution - 1

    if self.reference_vector_options.lattice_resolution:
        lattice_resolution = self.reference_vector_options.lattice_resolution
    else:
        lattice_resolution = approx_lattice_resolution(
            self.reference_vector_options.number_of_vectors, num_dims=self.num_dims
        )

    number_of_vectors: int = comb(
        lattice_resolution + self.num_dims - 1,
        self.num_dims - 1,
        exact=True,
    )

    self.reference_vector_options.number_of_vectors = number_of_vectors
    self.reference_vector_options.lattice_resolution = lattice_resolution

    temp1 = range(1, self.num_dims + lattice_resolution)
    temp1 = np.array(list(combinations(temp1, self.num_dims - 1)))
    temp2 = np.array([range(self.num_dims - 1)] * number_of_vectors)
    temp = temp1 - temp2 - 1
    weight = np.zeros((number_of_vectors, self.num_dims), dtype=int)
    weight[:, 0] = temp[:, 0]
    for i in range(1, self.num_dims - 1):
        weight[:, i] = temp[:, i] - temp[:, i - 1]
    weight[:, -1] = lattice_resolution - temp[:, -1]
    if not self.invert_reference_vectors:  # todo, this currently only exists for nsga3
        self.reference_vectors = weight / lattice_resolution
    else:
        self.reference_vectors = 1 - (weight / lattice_resolution)
    self.reference_vectors_initial = np.copy(self.reference_vectors)
    self._normalize_rvs()
_normalize_rvs
_normalize_rvs()

Normalize the reference vectors to a unit hypersphere.

Source code in desdeo/emo/operators/selection.py
def _normalize_rvs(self):
    """Normalize the reference vectors to a unit hypersphere."""
    if self.reference_vector_options.vector_type == "spherical":
        norm = np.linalg.norm(self.reference_vectors, axis=1).reshape(-1, 1)
        norm[norm == 0] = np.finfo(float).eps
        self.reference_vectors = np.divide(self.reference_vectors, norm)
        return
    if self.reference_vector_options.vector_type == "planar":
        if not self.invert_reference_vectors:
            norm = np.sum(self.reference_vectors, axis=1).reshape(-1, 1)
            self.reference_vectors = np.divide(self.reference_vectors, norm)
            return
        norm = np.sum(1 - self.reference_vectors, axis=1).reshape(-1, 1)
        self.reference_vectors = 1 - np.divide(1 - self.reference_vectors, norm)
        return
    # Not needed due to pydantic validation
    raise ValueError("Invalid vector type. Must be either 'spherical' or 'planar'.")
add_edge_vectors
add_edge_vectors()

Add edge vectors to the list of reference vectors.

Used to cover the entire orthant when preference information is provided.

Source code in desdeo/emo/operators/selection.py
def add_edge_vectors(self):
    """Add edge vectors to the list of reference vectors.

    Used to cover the entire orthant when preference information is
    provided.

    """
    edge_vectors = np.eye(self.reference_vectors.shape[1])
    self.reference_vectors = np.vstack([self.reference_vectors, edge_vectors])
    self._normalize_rvs()
interactive_adapt_1
interactive_adapt_1(
    z: ndarray, translation_param: float
) -> None

Adapt reference vectors using the information about prefererred solution(s) selected by the Decision maker.

Parameters:

Name Type Description Default
z ndarray

Preferred solution(s).

required
translation_param float

Parameter determining how close the reference vectors are to the central vector v defined by using the selected solution(s) z.

required
Source code in desdeo/emo/operators/selection.py
def interactive_adapt_1(self, z: np.ndarray, translation_param: float) -> None:
    """Adapt reference vectors using the information about prefererred solution(s) selected by the Decision maker.

    Args:
        z (np.ndarray): Preferred solution(s).
        translation_param (float): Parameter determining how close the reference vectors are to the central vector
            **v** defined by using the selected solution(s) z.
    """
    if z.shape[0] == 1:
        # single preferred solution
        # calculate new reference vectors
        self.reference_vectors = translation_param * self.reference_vectors_initial + ((1 - translation_param) * z)

    else:
        # multiple preferred solutions
        # calculate new reference vectors for each preferred solution
        values = [translation_param * self.reference_vectors_initial + ((1 - translation_param) * z_i) for z_i in z]

        # combine arrays of reference vectors into a single array and update reference vectors
        self.reference_vectors = np.concatenate(values)

    self._normalize_rvs()
    self.add_edge_vectors()
interactive_adapt_2
interactive_adapt_2(
    z: ndarray, predefined_distance: float, norm_order: int
) -> None

Adapt reference vectors using information about non-preferred solution(s) from the Decision maker.

After the Decision maker has specified non-preferred solution(s), Euclidian distance between normalized solution vector(s) and each of the reference vectors are calculated. Those reference vectors that are closer than a predefined distance are either removed or re-positioned somewhere else.

Note

At the moment, only the removal of reference vectors is supported. Repositioning of the reference vectors is not supported.

Note

In case the Decision maker specifies multiple non-preferred solutions, the reference vector(s) for which the distance to any of the non-preferred solutions is less than predefined distance are removed.

Note

Future developer should implement a way for a user to say: "Remove some percentage of objecive space/reference vectors" rather than giving a predefined distance value.

Parameters:

Name Type Description Default
z ndarray

Non-preferred solution(s).

required
predefined_distance float

The reference vectors that are closer than this distance are either removed or re-positioned somewhere else. Default value: 0.2

required
norm_order int

Order of the norm. Default is 2, i.e., Euclidian distance.

required
Source code in desdeo/emo/operators/selection.py
def interactive_adapt_2(self, z: np.ndarray, predefined_distance: float, norm_order: int) -> None:
    """Adapt reference vectors using information about non-preferred solution(s) from the Decision maker.

    After the Decision maker has specified non-preferred solution(s), Euclidian distance between normalized solution
    vector(s) and each of the reference vectors are calculated. Those reference vectors that are **closer** than a
    predefined distance are either **removed** or **re-positioned** somewhere else.

    Note:
        At the moment, only the **removal** of reference vectors is supported. Repositioning of the reference
        vectors is **not** supported.

    Note:
        In case the Decision maker specifies multiple non-preferred solutions, the reference vector(s) for which the
        distance to **any** of the non-preferred solutions is less than predefined distance are removed.

    Note:
        Future developer should implement a way for a user to say: "Remove some percentage of
        objecive space/reference vectors" rather than giving a predefined distance value.

    Args:
        z (np.ndarray): Non-preferred solution(s).
        predefined_distance (float): The reference vectors that are closer than this distance are either removed or
            re-positioned somewhere else. Default value: 0.2
        norm_order (int): Order of the norm. Default is 2, i.e., Euclidian distance.
    """
    # calculate L1 norm of non-preferred solution(s)
    z = np.atleast_2d(z)
    norm = np.linalg.norm(z, ord=norm_order, axis=1).reshape(np.shape(z)[0], 1)

    # non-preferred solutions normalized
    v_c = np.divide(z, norm)

    # distances from non-preferred solution(s) to each reference vector
    distances = np.array(
        [[np.linalg.norm(solution - value, ord=2) for solution in v_c] for value in self.reference_vectors]
    )

    # find out reference vectors that are not closer than threshold value to any non-preferred solution
    mask = [all(d >= predefined_distance) for d in distances]

    # set those reference vectors that met previous condition as new reference vectors, drop others
    self.reference_vectors = self.reference_vectors[mask]

    self._normalize_rvs()
    self.add_edge_vectors()
interactive_adapt_3
interactive_adapt_3(ref_point, translation_param)

Adapt reference vectors linearly towards a reference point. Then normalize.

The details can be found in the following paper: Hakanen, Jussi & Chugh, Tinkle & Sindhya, Karthik & Jin, Yaochu & Miettinen, Kaisa. (2016). Connections of Reference Vectors and Different Types of Preference Information in Interactive Multiobjective Evolutionary Algorithms.

Parameters

ref_point :

translation_param

(Default value = 0.2)

Source code in desdeo/emo/operators/selection.py
def interactive_adapt_3(self, ref_point, translation_param):
    """Adapt reference vectors linearly towards a reference point. Then normalize.

    The details can be found in the following paper: Hakanen, Jussi &
    Chugh, Tinkle & Sindhya, Karthik & Jin, Yaochu & Miettinen, Kaisa.
    (2016). Connections of Reference Vectors and Different Types of
    Preference Information in Interactive Multiobjective Evolutionary
    Algorithms.

    Parameters
    ----------
    ref_point :

    translation_param :
        (Default value = 0.2)

    """
    self.reference_vectors = self.reference_vectors_initial * translation_param + (
        (1 - translation_param) * ref_point
    )
    self._normalize_rvs()
    self.add_edge_vectors()
interactive_adapt_4
interactive_adapt_4(preferred_ranges: ndarray) -> None

Adapt reference vectors using the Decision maker's preferred range for each objective.

Using these ranges, Latin hypercube sampling is applied to generate m number of samples between within these ranges, where m is the number of reference vectors. Normalized vectors constructed of these samples are then set as new reference vectors.

Parameters:

Name Type Description Default
preferred_ranges ndarray

Preferred lower and upper bound for each of the objective function values.

required
Source code in desdeo/emo/operators/selection.py
def interactive_adapt_4(self, preferred_ranges: np.ndarray) -> None:
    """Adapt reference vectors using the Decision maker's preferred range for each objective.

    Using these ranges, Latin hypercube sampling is applied to generate m number of samples between
    within these ranges, where m is the number of reference vectors. Normalized vectors constructed of these samples
    are then set as new reference vectors.

    Args:
        preferred_ranges (np.ndarray): Preferred lower and upper bound for each of the objective function values.
    """
    # bounds
    lower_limits = np.min(preferred_ranges, axis=0)
    upper_limits = np.max(preferred_ranges, axis=0)

    # generate samples using Latin hypercube sampling
    lhs = LatinHypercube(d=self.num_dims, seed=self.rng)
    w = lhs.random(n=self.reference_vectors_initial.shape[0])

    # scale between bounds
    w = w * (upper_limits - lower_limits) + lower_limits

    # set new reference vectors and normalize them
    self.reference_vectors = w
    self._normalize_rvs()
    self.add_edge_vectors()

BaseSelector

Bases: Subscriber

A base class for selection operators.

Source code in desdeo/emo/operators/selection.py
class BaseSelector(Subscriber):
    """A base class for selection operators."""

    def __init__(self, problem: Problem, verbosity: int, publisher: Publisher, seed: int = 0):
        """Initialize a selection operator."""
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.problem = problem
        self.variable_symbols = [x.symbol for x in problem.get_flattened_variables()]
        self.objective_symbols = [x.symbol for x in problem.objectives]
        self.maximization_mult = {x.symbol: -1 if x.maximize else 1 for x in problem.objectives}

        if problem.scalarization_funcs is None:
            self.target_symbols = [f"{x.symbol}_min" for x in problem.objectives]
            try:
                ideal, nadir = get_corrected_ideal_and_nadir(problem)  # This is for the minimized problem
                self.ideal = np.array([ideal[x.symbol] for x in problem.objectives])
                self.nadir = np.array([nadir[x.symbol] for x in problem.objectives]) if nadir is not None else None
            except ValueError:  # in case the ideal and nadir are not provided
                self.ideal = None
                self.nadir = None
        else:
            self.target_symbols = [x.symbol for x in problem.scalarization_funcs if x.symbol is not None]
            self.ideal: np.ndarray | None = None
            self.nadir: np.ndarray | None = None
        if problem.constraints is None:
            self.constraints_symbols = None
        else:
            self.constraints_symbols = [x.symbol for x in problem.constraints]
        self.num_dims = len(self.target_symbols)
        self.seed = seed
        self.rng = np.random.default_rng(seed)

    @abstractmethod
    def do(
        self,
        parents: tuple[SolutionType, pl.DataFrame],
        offsprings: tuple[SolutionType, pl.DataFrame],
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
                targets, and constraint violations.
        """
__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int = 0,
)

Initialize a selection operator.

Source code in desdeo/emo/operators/selection.py
def __init__(self, problem: Problem, verbosity: int, publisher: Publisher, seed: int = 0):
    """Initialize a selection operator."""
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.problem = problem
    self.variable_symbols = [x.symbol for x in problem.get_flattened_variables()]
    self.objective_symbols = [x.symbol for x in problem.objectives]
    self.maximization_mult = {x.symbol: -1 if x.maximize else 1 for x in problem.objectives}

    if problem.scalarization_funcs is None:
        self.target_symbols = [f"{x.symbol}_min" for x in problem.objectives]
        try:
            ideal, nadir = get_corrected_ideal_and_nadir(problem)  # This is for the minimized problem
            self.ideal = np.array([ideal[x.symbol] for x in problem.objectives])
            self.nadir = np.array([nadir[x.symbol] for x in problem.objectives]) if nadir is not None else None
        except ValueError:  # in case the ideal and nadir are not provided
            self.ideal = None
            self.nadir = None
    else:
        self.target_symbols = [x.symbol for x in problem.scalarization_funcs if x.symbol is not None]
        self.ideal: np.ndarray | None = None
        self.nadir: np.ndarray | None = None
    if problem.constraints is None:
        self.constraints_symbols = None
    else:
        self.constraints_symbols = [x.symbol for x in problem.constraints]
    self.num_dims = len(self.target_symbols)
    self.seed = seed
    self.rng = np.random.default_rng(seed)
do abstractmethod
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
parents tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
offsprings tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values, targets, and constraint violations.

Source code in desdeo/emo/operators/selection.py
@abstractmethod
def do(
    self,
    parents: tuple[SolutionType, pl.DataFrame],
    offsprings: tuple[SolutionType, pl.DataFrame],
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
            targets, and constraint violations.
    """

IBEASelector

Bases: BaseSelector

The adaptive IBEA selection operator.

Constraints are handled feasibility-first, as in NSGA3Selector: while feasible solutions alone can fill the population the infeasible ones are discarded and the indicator selection runs unchanged, and while they cannot, every feasible solution survives and the remaining places go to the least infeasible. The binary indicator itself is left untouched, since it compares objective vectors and has no reading of an infeasible one.

Reference: Zitzler, E., Künzli, S. (2004). Indicator-Based Selection in Multiobjective Search. In: Yao, X., et al. Parallel Problem Solving from Nature - PPSN VIII. PPSN 2004. Lecture Notes in Computer Science, vol 3242. Springer, Berlin, Heidelberg. https://doi.org/10.1007/978-3-540-30217-9_84

Source code in desdeo/emo/operators/selection.py
class IBEASelector(BaseSelector):
    """The adaptive IBEA selection operator.

    Constraints are handled feasibility-first, as in `NSGA3Selector`: while feasible solutions alone
    can fill the population the infeasible ones are discarded and the indicator selection runs
    unchanged, and while they cannot, every feasible solution survives and the remaining places go to
    the least infeasible. The binary indicator itself is left untouched, since it compares objective
    vectors and has no reading of an infeasible one.

    Reference: Zitzler, E., Künzli, S. (2004). Indicator-Based Selection in Multiobjective Search. In: Yao, X., et al.
    Parallel Problem Solving from Nature - PPSN VIII. PPSN 2004. Lecture Notes in Computer Science, vol 3242.
    Springer, Berlin, Heidelberg. https://doi.org/10.1007/978-3-540-30217-9_84
    """

    @property
    def provided_topics(self):
        """The message topics this operator publishes, keyed by verbosity level."""
        return {
            0: [],
            1: [SelectorMessageTopics.STATE],
            2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
        }

    @property
    def interested_topics(self):
        """The message topics this operator subscribes to."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        population_size: int,
        kappa: float = 0.05,
        binary_indicator: Callable[[np.ndarray], np.ndarray] = self_epsilon,
        seed: int = 0,
    ):
        """Initialize the IBEA selector.

        Args:
            problem (Problem): The problem to solve.
            verbosity (int): The verbosity level of the selector.
            publisher (Publisher): The publisher to send messages to.
            population_size (int): The size of the population to select.
            kappa (float, optional): The kappa value for the IBEA selection. Defaults to 0.05.
            binary_indicator (Callable[[np.ndarray], np.ndarray], optional): The binary indicator function to use.
                Defaults to self_epsilon with uses binary addaptive epsilon indicator.
            seed (int, optional): The random seed to use. Defaults to 0.
        """
        # TODO(@light-weaver): IBEA doesn't perform as good as expected
        # The distribution of solutions found isn't very uniform
        # Update 21st August, tested against jmetalpy IBEA. Our version is both faster and better
        # What is happening???
        # Results are similar to this https://github.com/Xavier-MaYiMing/IBEA/
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.selection: list[int] | None = None
        self.selected_individuals: SolutionType | None = None
        self.selected_targets: pl.DataFrame | None = None
        self.binary_indicator = binary_indicator
        self.kappa = kappa
        self.population_size = population_size

    def _indicator_components(self, targets: np.ndarray) -> np.ndarray:
        """Scale the targets to the unit box and return the pairwise binary indicator matrix."""
        target_min = np.min(targets, axis=0)
        target_max = np.max(targets, axis=0)
        span = target_max - target_min
        # A target that is constant across the population carries no information; dividing by its
        # zero span would turn the whole indicator matrix into NaN.
        span[span == 0] = 1.0
        return self.binary_indicator((targets - target_min) / span)

    def _adaptive_kappa(self, components: np.ndarray) -> float:
        """Adaptive IBEA's kappa: the configured kappa scaled by the largest indicator magnitude.

        The scaling makes kappa independent of the objectives' range. When the set has collapsed onto a
        single objective vector every indicator value is zero, and so is the scale: dividing by it
        raised ZeroDivisionError in the fitness kernels and would turn the vectorised survivor
        selection into NaN. A set with no spread has nothing to scale, so the configured kappa is used
        unscaled. Every exp(-0 / kappa) is then 1 and each member's fitness is -(n - 1), a tie, which is
        the honest reading of a set the indicator cannot separate. Wherever the scale is positive the
        value is exactly what it was before.
        """
        scale = float(np.abs(components).max())
        return self.kappa * scale if scale > 0 else self.kappa

    def _infeasible_fitness(self, targets: np.ndarray, violations: np.ndarray) -> np.ndarray:
        """Fitness for a set that is partly or wholly infeasible, feasible solutions first.

        The binary indicator only orders solutions sensibly when they are all feasible, so the
        indicator fitness is kept for the feasible members and every infeasible member is placed
        strictly below all of them, ordered among themselves by total violation. Fitness stays
        higher-is-better, which is what the mating tournament expects.
        """
        components = self._indicator_components(targets)
        fitness = _ibea_fitness(components, kappa=self._adaptive_kappa(components))

        infeasible = violations > 0
        if not np.any(infeasible):
            return fitness
        feasible_worst = fitness[~infeasible].min() if np.any(~infeasible) else 0.0
        fitness[infeasible] = feasible_worst - violations[infeasible] / violations[infeasible].max()
        return fitness

    def do(
        self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
                targets, and constraint violations.
        """
        if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
            solutions = parents[0].vstack(offsprings[0])
        elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
            solutions = parents[0] + offsprings[0]
        else:
            raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
        if len(parents[0]) < self.population_size:
            return parents[0], parents[1]
        alltargets = parents[1].vstack(offsprings[1])

        violations = total_constraint_violation(alltargets, self.constraints_symbols)
        if violations is not None:
            feasible = np.flatnonzero(violations <= 0)
            if len(feasible) <= self.population_size:
                # Too few feasible solutions to fill the population, so the indicator has nothing to
                # choose between: keep every feasible solution and top up with the least infeasible.
                self.selection = np.argsort(violations, kind="stable")[: self.population_size].tolist()
                self.selected_individuals = solutions[self.selection]
                self.selected_targets = alltargets[self.selection]
                self.fitness = self._infeasible_fitness(
                    self.selected_targets[self.target_symbols].to_numpy(), violations[self.selection]
                )

                self.notify()
                return self.selected_individuals, self.selected_targets
            # Enough feasible solutions to fill the population, so the infeasible ones are simply
            # dropped and the indicator selection proceeds unchanged on the feasible ones.
            solutions = solutions[feasible]
            alltargets = alltargets[feasible]

        # Adaptation
        fitness_components = self._indicator_components(alltargets[self.target_symbols].to_numpy())

        chosen = _ibea_select_all(
            fitness_components, population_size=self.population_size, kappa=self._adaptive_kappa(fitness_components)
        )
        self.selected_individuals = solutions.filter(chosen)
        self.selected_targets = alltargets.filter(chosen)
        self.selection = chosen

        fitness_components = fitness_components[chosen][:, chosen]
        self.fitness = _ibea_fitness(fitness_components, kappa=self._adaptive_kappa(fitness_components))

        self.notify()
        return self.selected_individuals, self.selected_targets

    def state(self) -> Sequence[Message]:
        """Return the state of the selector."""
        if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
            return []
        if self.verbosity == 1:
            return [
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "population_size": self.population_size,
                        "selected_individuals": self.selection,
                    },
                    source=self.__class__.__name__,
                )
            ]
        # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "population_size": self.population_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            ),
            message,
            NumpyArrayMessage(
                topic=SelectorMessageTopics.SELECTED_FITNESS,
                value=self.fitness,
                source=self.__class__.__name__,
            ),
        ]

    def update(self, message: Message) -> None:
        """Handle an incoming message. This operator does not react to messages."""
interested_topics property
interested_topics

The message topics this operator subscribes to.

provided_topics property
provided_topics

The message topics this operator publishes, keyed by verbosity level.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    population_size: int,
    kappa: float = 0.05,
    binary_indicator: Callable[
        [ndarray], ndarray
    ] = self_epsilon,
    seed: int = 0,
)

Initialize the IBEA selector.

Parameters:

Name Type Description Default
problem Problem

The problem to solve.

required
verbosity int

The verbosity level of the selector.

required
publisher Publisher

The publisher to send messages to.

required
population_size int

The size of the population to select.

required
kappa float

The kappa value for the IBEA selection. Defaults to 0.05.

0.05
binary_indicator Callable[[ndarray], ndarray]

The binary indicator function to use. Defaults to self_epsilon with uses binary addaptive epsilon indicator.

self_epsilon
seed int

The random seed to use. Defaults to 0.

0
Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    population_size: int,
    kappa: float = 0.05,
    binary_indicator: Callable[[np.ndarray], np.ndarray] = self_epsilon,
    seed: int = 0,
):
    """Initialize the IBEA selector.

    Args:
        problem (Problem): The problem to solve.
        verbosity (int): The verbosity level of the selector.
        publisher (Publisher): The publisher to send messages to.
        population_size (int): The size of the population to select.
        kappa (float, optional): The kappa value for the IBEA selection. Defaults to 0.05.
        binary_indicator (Callable[[np.ndarray], np.ndarray], optional): The binary indicator function to use.
            Defaults to self_epsilon with uses binary addaptive epsilon indicator.
        seed (int, optional): The random seed to use. Defaults to 0.
    """
    # TODO(@light-weaver): IBEA doesn't perform as good as expected
    # The distribution of solutions found isn't very uniform
    # Update 21st August, tested against jmetalpy IBEA. Our version is both faster and better
    # What is happening???
    # Results are similar to this https://github.com/Xavier-MaYiMing/IBEA/
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.selection: list[int] | None = None
    self.selected_individuals: SolutionType | None = None
    self.selected_targets: pl.DataFrame | None = None
    self.binary_indicator = binary_indicator
    self.kappa = kappa
    self.population_size = population_size
_adaptive_kappa
_adaptive_kappa(components: ndarray) -> float

Adaptive IBEA's kappa: the configured kappa scaled by the largest indicator magnitude.

The scaling makes kappa independent of the objectives' range. When the set has collapsed onto a single objective vector every indicator value is zero, and so is the scale: dividing by it raised ZeroDivisionError in the fitness kernels and would turn the vectorised survivor selection into NaN. A set with no spread has nothing to scale, so the configured kappa is used unscaled. Every exp(-0 / kappa) is then 1 and each member's fitness is -(n - 1), a tie, which is the honest reading of a set the indicator cannot separate. Wherever the scale is positive the value is exactly what it was before.

Source code in desdeo/emo/operators/selection.py
def _adaptive_kappa(self, components: np.ndarray) -> float:
    """Adaptive IBEA's kappa: the configured kappa scaled by the largest indicator magnitude.

    The scaling makes kappa independent of the objectives' range. When the set has collapsed onto a
    single objective vector every indicator value is zero, and so is the scale: dividing by it
    raised ZeroDivisionError in the fitness kernels and would turn the vectorised survivor
    selection into NaN. A set with no spread has nothing to scale, so the configured kappa is used
    unscaled. Every exp(-0 / kappa) is then 1 and each member's fitness is -(n - 1), a tie, which is
    the honest reading of a set the indicator cannot separate. Wherever the scale is positive the
    value is exactly what it was before.
    """
    scale = float(np.abs(components).max())
    return self.kappa * scale if scale > 0 else self.kappa
_indicator_components
_indicator_components(targets: ndarray) -> np.ndarray

Scale the targets to the unit box and return the pairwise binary indicator matrix.

Source code in desdeo/emo/operators/selection.py
def _indicator_components(self, targets: np.ndarray) -> np.ndarray:
    """Scale the targets to the unit box and return the pairwise binary indicator matrix."""
    target_min = np.min(targets, axis=0)
    target_max = np.max(targets, axis=0)
    span = target_max - target_min
    # A target that is constant across the population carries no information; dividing by its
    # zero span would turn the whole indicator matrix into NaN.
    span[span == 0] = 1.0
    return self.binary_indicator((targets - target_min) / span)
_infeasible_fitness
_infeasible_fitness(
    targets: ndarray, violations: ndarray
) -> np.ndarray

Fitness for a set that is partly or wholly infeasible, feasible solutions first.

The binary indicator only orders solutions sensibly when they are all feasible, so the indicator fitness is kept for the feasible members and every infeasible member is placed strictly below all of them, ordered among themselves by total violation. Fitness stays higher-is-better, which is what the mating tournament expects.

Source code in desdeo/emo/operators/selection.py
def _infeasible_fitness(self, targets: np.ndarray, violations: np.ndarray) -> np.ndarray:
    """Fitness for a set that is partly or wholly infeasible, feasible solutions first.

    The binary indicator only orders solutions sensibly when they are all feasible, so the
    indicator fitness is kept for the feasible members and every infeasible member is placed
    strictly below all of them, ordered among themselves by total violation. Fitness stays
    higher-is-better, which is what the mating tournament expects.
    """
    components = self._indicator_components(targets)
    fitness = _ibea_fitness(components, kappa=self._adaptive_kappa(components))

    infeasible = violations > 0
    if not np.any(infeasible):
        return fitness
    feasible_worst = fitness[~infeasible].min() if np.any(~infeasible) else 0.0
    fitness[infeasible] = feasible_worst - violations[infeasible] / violations[infeasible].max()
    return fitness
do
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
parents tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
offsprings tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values, targets, and constraint violations.

Source code in desdeo/emo/operators/selection.py
def do(
    self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
            targets, and constraint violations.
    """
    if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
        solutions = parents[0].vstack(offsprings[0])
    elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
        solutions = parents[0] + offsprings[0]
    else:
        raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
    if len(parents[0]) < self.population_size:
        return parents[0], parents[1]
    alltargets = parents[1].vstack(offsprings[1])

    violations = total_constraint_violation(alltargets, self.constraints_symbols)
    if violations is not None:
        feasible = np.flatnonzero(violations <= 0)
        if len(feasible) <= self.population_size:
            # Too few feasible solutions to fill the population, so the indicator has nothing to
            # choose between: keep every feasible solution and top up with the least infeasible.
            self.selection = np.argsort(violations, kind="stable")[: self.population_size].tolist()
            self.selected_individuals = solutions[self.selection]
            self.selected_targets = alltargets[self.selection]
            self.fitness = self._infeasible_fitness(
                self.selected_targets[self.target_symbols].to_numpy(), violations[self.selection]
            )

            self.notify()
            return self.selected_individuals, self.selected_targets
        # Enough feasible solutions to fill the population, so the infeasible ones are simply
        # dropped and the indicator selection proceeds unchanged on the feasible ones.
        solutions = solutions[feasible]
        alltargets = alltargets[feasible]

    # Adaptation
    fitness_components = self._indicator_components(alltargets[self.target_symbols].to_numpy())

    chosen = _ibea_select_all(
        fitness_components, population_size=self.population_size, kappa=self._adaptive_kappa(fitness_components)
    )
    self.selected_individuals = solutions.filter(chosen)
    self.selected_targets = alltargets.filter(chosen)
    self.selection = chosen

    fitness_components = fitness_components[chosen][:, chosen]
    self.fitness = _ibea_fitness(fitness_components, kappa=self._adaptive_kappa(fitness_components))

    self.notify()
    return self.selected_individuals, self.selected_targets
state
state() -> Sequence[Message]

Return the state of the selector.

Source code in desdeo/emo/operators/selection.py
def state(self) -> Sequence[Message]:
    """Return the state of the selector."""
    if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
        return []
    if self.verbosity == 1:
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "population_size": self.population_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            )
        ]
    # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "population_size": self.population_size,
                "selected_individuals": self.selection,
            },
            source=self.__class__.__name__,
        ),
        message,
        NumpyArrayMessage(
            topic=SelectorMessageTopics.SELECTED_FITNESS,
            value=self.fitness,
            source=self.__class__.__name__,
        ),
    ]
update
update(message: Message) -> None

Handle an incoming message. This operator does not react to messages.

Source code in desdeo/emo/operators/selection.py
def update(self, message: Message) -> None:
    """Handle an incoming message. This operator does not react to messages."""

NSGA2Selector

Bases: BaseSelector

Implements the selection operator defined for NSGA2.

Implements the selection operator defined for NSGA2, which included the crowding distance calculation.

On a constrained problem the ranking switches to the constrained domination of the same paper: a feasible solution beats an infeasible one, two infeasible solutions are compared by total constraint violation, and two feasible solutions are compared by ordinary Pareto dominance. Nothing else changes, since that is where NSGA-II confines its constraint handling.

Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.

(2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE transactions on evolutionary computation, 6(2), 182-197.

Source code in desdeo/emo/operators/selection.py
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
class NSGA2Selector(BaseSelector):
    """Implements the selection operator defined for NSGA2.

    Implements the selection operator defined for NSGA2, which included the crowding
    distance calculation.

    On a constrained problem the ranking switches to the constrained domination of the same paper:
    a feasible solution beats an infeasible one, two infeasible solutions are compared by total
    constraint violation, and two feasible solutions are compared by ordinary Pareto dominance.
    Nothing else changes, since that is where NSGA-II confines its constraint handling.

    Reference: Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.
        (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE
        transactions on evolutionary computation, 6(2), 182-197.
    """

    @property
    def provided_topics(self):
        """The topics provided for the NSGA2 method."""
        return {
            0: [],
            1: [SelectorMessageTopics.STATE],
            2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
        }

    @property
    def interested_topics(self):
        """The topics the NSGA2 method is interested in."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        population_size: int,
        seed: int = 0,
    ):
        """Initialize the NSGA-II selection operator.

        Args:
            problem (Problem): The optimization problem to be solved.
            verbosity (int): The verbosity level of the operator.
            publisher (Publisher): The publisher to use for communication.
            population_size (int): The number of individuals to select each generation.
            seed (int, optional): The random seed to use. Defaults to 0.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
        self.population_size = population_size
        self.seed = seed
        self.selection: list[int] | None = None
        self.selected_individuals: SolutionType | None = None
        self.selected_targets: pl.DataFrame | None = None

    def _sort(self, targets: np.ndarray, violations: np.ndarray | None) -> np.ndarray:
        """Rank a population into fronts, applying constrained domination when the problem has constraints.

        Constrained domination never compares a feasible solution with an infeasible one on
        objectives, so the two groups can be ranked separately and concatenated instead of a
        dominance relation being redefined. The feasible group gets the ordinary non-dominated
        sort; the infeasible group is stratified by total violation, one front per distinct value,
        which is what "the one with the smaller violation wins" means as a ranking.

        Everything downstream -- crowding distance, the partial-front trim, the fitness -- is left
        alone, exactly as in NSGA-II, where constraint handling enters only through the ranking.
        """
        if violations is None:
            return fast_non_dominated_sort(targets)

        feasible = np.flatnonzero(violations <= 0)
        infeasible = np.flatnonzero(violations > 0)
        fronts = []

        if feasible.size > 0:
            for front in fast_non_dominated_sort(targets[feasible]):
                row = np.zeros(len(targets), dtype=np.bool_)
                row[feasible[front]] = True
                fronts.append(row)

        for violation in np.unique(violations[infeasible]):
            row = np.zeros(len(targets), dtype=np.bool_)
            row[infeasible[violations[infeasible] == violation]] = True
            fronts.append(row)

        return np.array(fronts)

    def do(
        self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation."""
        # First iteration, offspring is empty
        # Do basic binary tournament selection, recombination, and mutation
        # In practice, just compute the non-dom ranks and provide them as fitness

        # Off-spring empty (first iteration, compute only non-dominated ranks and provide them as fitness)
        if offsprings[0].is_empty() and offsprings[1].is_empty():
            # just compute non-dominated ranks of population and be done
            parents_a = parents[1][self.target_symbols].to_numpy()
            fronts = self._sort(parents_a, total_constraint_violation(parents[1], self.constraints_symbols))

            # assign fitness according to non-dom rank, flipped to higher-is-better and kept
            # strictly positive for the consumers of SELECTED_FITNESS; see the main branch below
            scores = np.arange(len(fronts))
            self.fitness = len(fronts) - scores @ fronts

            # all selected in first iteration
            self.selection = list(range(len(parents[1])))
            self.selected_individuals = parents[0]
            self.selected_targets = parents[1]

            self.notify()

            return self.selected_individuals, self.selected_targets

        # #Actual selection operator for NSGA2

        # Combine parent and offspring R_t = P_t U Q_t
        r_solutions = parents[0].vstack(offsprings[0])
        r_population = parents[1].vstack(offsprings[1])
        r_targets_arr = r_population[self.target_symbols].to_numpy()

        # the minimum and maximum target values in the whole current population
        f_mins, f_maxs = np.min(r_targets_arr, axis=0), np.max(r_targets_arr, axis=0)

        # Do fast non-dominated sorting on R_t -> F
        fronts = self._sort(r_targets_arr, total_constraint_violation(r_population, self.constraints_symbols))
        crowding_distances = np.ones(self.population_size) * np.nan
        rankings = np.ones(self.population_size) * np.nan
        fitness_values = np.ones(self.population_size) * np.nan

        # Set the new parent population to P_t+1 = empty and i=1
        new_parents = np.ones((self.population_size, parents[1].shape[1])) * np.nan
        new_parents_solutions = np.ones((self.population_size, parents[0].shape[1])) * np.nan
        parents_ptr = 0  # keep track where stuff was last added

        # the -1 is here because searchsorted returns the index where we can insert the population size to preserve the
        # order, hence, the previous index of this will be the last element in the cumsum that is less than
        # the population size
        last_whole_front_idx = (
            np.searchsorted(np.cumsum(np.sum(fronts, axis=1)), self.population_size, side="right") - 1
        )

        last_ranking = 0  # in case first front is larger th population size
        for i in range(last_whole_front_idx + 1):  # inclusive
            # The looped front here will result in a new population with size <= 100.

            # Compute the crowding distances for F_i
            distances = _nsga2_crowding_distance_assignment(r_targets_arr[fronts[i]], f_mins, f_maxs)
            crowding_distances[parents_ptr : parents_ptr + distances.shape[0]] = (
                distances  # distances will have same number of elements as in front[i]
            )

            # keep track of the rankings as well (best = 0, larger worse). First
            # non-dom front will have a rank fitness of 0.
            rankings[parents_ptr : parents_ptr + distances.shape[0]] = i

            #   P_t+1 = P_t+1 U F_i
            new_parents[parents_ptr : parents_ptr + distances.shape[0]] = r_population.filter(fronts[i])
            new_parents_solutions[parents_ptr : parents_ptr + distances.shape[0]] = r_solutions.filter(fronts[i])

            # compute fitness
            # infs are checked since boundary points are assigned this value when computing the crowding distance
            finite_distances = distances[distances != np.inf]
            max_no_inf = np.nanmax(finite_distances) if finite_distances.size > 0 else np.ones(fronts[i].sum())
            distances_no_inf = np.nan_to_num(distances, posinf=max_no_inf * 1.1)

            # Distances for the current front normalized between 0 and 1.
            # The small scalar we add in the nominator and denominator is to
            # ensure that no distance value would result in exactly 0 after
            # normalizing, which would increase the corresponding solution
            # ranking, once reversed, which we do not want to.
            normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
                distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
            )

            # since higher is better for the crowded distance, we substract the normalized distances from 1 so that
            # lower is better, which allows us to combine them with the ranking
            # No value here should be 1.0 or greater.
            reversed_distances = 1.0 - normalized_distances

            front_fitness = reversed_distances + rankings[parents_ptr : parents_ptr + distances.shape[0]]
            fitness_values[parents_ptr : parents_ptr + distances.shape[0]] = front_fitness

            # increment parent pointer
            parents_ptr += distances.shape[0]

            # keep track of last given rank
            last_ranking = i

        # deal with last (partial) front, if needed
        trimmed_and_sorted_indices = None
        if parents_ptr < self.population_size:
            distances = _nsga2_crowding_distance_assignment(
                r_targets_arr[fronts[last_whole_front_idx + 1]], f_mins, f_maxs
            )

            # Sort F_i in descending order according to crowding distance
            # This makes picking the selected part of the partial front easier
            trimmed_and_sorted_indices = distances.argsort()[::-1][: self.population_size - parents_ptr]

            crowding_distances[parents_ptr : self.population_size] = distances[trimmed_and_sorted_indices]
            rankings[parents_ptr : self.population_size] = last_ranking + 1

            # P_t+1 = P_t+1 U F_i[1: (N - |P_t+1|)]
            new_parents[parents_ptr : self.population_size] = r_population.filter(fronts[last_whole_front_idx + 1])[
                trimmed_and_sorted_indices
            ]
            new_parents_solutions[parents_ptr : self.population_size] = r_solutions.filter(
                fronts[last_whole_front_idx + 1]
            )[trimmed_and_sorted_indices]

            # compute fitness (see above for details)
            finite_distances = distances[trimmed_and_sorted_indices][distances[trimmed_and_sorted_indices] != np.inf]
            max_no_inf = (
                np.nanmax(finite_distances)
                if finite_distances.size > 0
                else np.ones(len(trimmed_and_sorted_indices))  # we have only boundary points
            )
            distances_no_inf = np.nan_to_num(distances[trimmed_and_sorted_indices], posinf=max_no_inf * 1.1)

            normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
                distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
            )

            reversed_distances = 1.0 - normalized_distances

            front_fitness = reversed_distances + rankings[parents_ptr : self.population_size]
            fitness_values[parents_ptr : parents_ptr + self.population_size] = front_fitness

        # back to polars, return values
        solutions = pl.DataFrame(new_parents_solutions, schema=parents[0].schema)
        outputs = pl.DataFrame(new_parents, schema=parents[1].schema)

        # Everything downstream of SELECTED_FITNESS reads it as higher-is-better: the mating
        # tournament takes an argmax, and roulette-wheel selection treats it as a weight, which also
        # requires it to be positive. The NSGA-II quantity built above is the opposite -- front rank
        # plus a reversed crowding distance, lower is better -- so publishing it directly made the
        # binary tournament pick the loser of every pair. Reflecting it about the worst attainable
        # value flips the direction, keeps every value strictly positive (a front's fitness lies in
        # `(n_fronts - rank - 1, n_fronts - rank]`, since the reversed distance never reaches 1) and
        # preserves the ordering exactly.
        self.fitness = (np.nanmax(rankings) + 1.0) - fitness_values

        whole_fronts = fronts[: last_whole_front_idx + 1]
        whole_indices = [np.where(row)[0].tolist() for row in whole_fronts]

        if trimmed_and_sorted_indices is not None:
            # partial front considered
            partial_front = fronts[last_whole_front_idx + 1]
            partial_indices = np.where(partial_front)[0][trimmed_and_sorted_indices].tolist()
        else:
            partial_indices = []

        self.selection = [index for indices in whole_indices for index in indices] + partial_indices
        self.selected_individuals = solutions
        self.selected_targets = outputs

        self.notify()
        return solutions, outputs

    def state(self) -> Sequence[Message]:
        """Return the state of the selector."""
        if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
            return []
        if self.verbosity == 1:
            return [
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "population_size": self.population_size,
                        "selected_individuals": self.selection,
                    },
                    source=self.__class__.__name__,
                )
            ]
        # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "population_size": self.population_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            ),
            message,
            NumpyArrayMessage(
                topic=SelectorMessageTopics.SELECTED_FITNESS,
                value=self.fitness,
                source=self.__class__.__name__,
            ),
        ]

    def update(self, message: Message) -> None:
        """Handle an incoming message. This operator does not react to messages."""
interested_topics property
interested_topics

The topics the NSGA2 method is interested in.

provided_topics property
provided_topics

The topics provided for the NSGA2 method.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    population_size: int,
    seed: int = 0,
)

Initialize the NSGA-II selection operator.

Parameters:

Name Type Description Default
problem Problem

The optimization problem to be solved.

required
verbosity int

The verbosity level of the operator.

required
publisher Publisher

The publisher to use for communication.

required
population_size int

The number of individuals to select each generation.

required
seed int

The random seed to use. Defaults to 0.

0
Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    population_size: int,
    seed: int = 0,
):
    """Initialize the NSGA-II selection operator.

    Args:
        problem (Problem): The optimization problem to be solved.
        verbosity (int): The verbosity level of the operator.
        publisher (Publisher): The publisher to use for communication.
        population_size (int): The number of individuals to select each generation.
        seed (int, optional): The random seed to use. Defaults to 0.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
    self.population_size = population_size
    self.seed = seed
    self.selection: list[int] | None = None
    self.selected_individuals: SolutionType | None = None
    self.selected_targets: pl.DataFrame | None = None
_sort
_sort(
    targets: ndarray, violations: ndarray | None
) -> np.ndarray

Rank a population into fronts, applying constrained domination when the problem has constraints.

Constrained domination never compares a feasible solution with an infeasible one on objectives, so the two groups can be ranked separately and concatenated instead of a dominance relation being redefined. The feasible group gets the ordinary non-dominated sort; the infeasible group is stratified by total violation, one front per distinct value, which is what "the one with the smaller violation wins" means as a ranking.

Everything downstream -- crowding distance, the partial-front trim, the fitness -- is left alone, exactly as in NSGA-II, where constraint handling enters only through the ranking.

Source code in desdeo/emo/operators/selection.py
def _sort(self, targets: np.ndarray, violations: np.ndarray | None) -> np.ndarray:
    """Rank a population into fronts, applying constrained domination when the problem has constraints.

    Constrained domination never compares a feasible solution with an infeasible one on
    objectives, so the two groups can be ranked separately and concatenated instead of a
    dominance relation being redefined. The feasible group gets the ordinary non-dominated
    sort; the infeasible group is stratified by total violation, one front per distinct value,
    which is what "the one with the smaller violation wins" means as a ranking.

    Everything downstream -- crowding distance, the partial-front trim, the fitness -- is left
    alone, exactly as in NSGA-II, where constraint handling enters only through the ranking.
    """
    if violations is None:
        return fast_non_dominated_sort(targets)

    feasible = np.flatnonzero(violations <= 0)
    infeasible = np.flatnonzero(violations > 0)
    fronts = []

    if feasible.size > 0:
        for front in fast_non_dominated_sort(targets[feasible]):
            row = np.zeros(len(targets), dtype=np.bool_)
            row[feasible[front]] = True
            fronts.append(row)

    for violation in np.unique(violations[infeasible]):
        row = np.zeros(len(targets), dtype=np.bool_)
        row[infeasible[violations[infeasible] == violation]] = True
        fronts.append(row)

    return np.array(fronts)
do
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Source code in desdeo/emo/operators/selection.py
def do(
    self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation."""
    # First iteration, offspring is empty
    # Do basic binary tournament selection, recombination, and mutation
    # In practice, just compute the non-dom ranks and provide them as fitness

    # Off-spring empty (first iteration, compute only non-dominated ranks and provide them as fitness)
    if offsprings[0].is_empty() and offsprings[1].is_empty():
        # just compute non-dominated ranks of population and be done
        parents_a = parents[1][self.target_symbols].to_numpy()
        fronts = self._sort(parents_a, total_constraint_violation(parents[1], self.constraints_symbols))

        # assign fitness according to non-dom rank, flipped to higher-is-better and kept
        # strictly positive for the consumers of SELECTED_FITNESS; see the main branch below
        scores = np.arange(len(fronts))
        self.fitness = len(fronts) - scores @ fronts

        # all selected in first iteration
        self.selection = list(range(len(parents[1])))
        self.selected_individuals = parents[0]
        self.selected_targets = parents[1]

        self.notify()

        return self.selected_individuals, self.selected_targets

    # #Actual selection operator for NSGA2

    # Combine parent and offspring R_t = P_t U Q_t
    r_solutions = parents[0].vstack(offsprings[0])
    r_population = parents[1].vstack(offsprings[1])
    r_targets_arr = r_population[self.target_symbols].to_numpy()

    # the minimum and maximum target values in the whole current population
    f_mins, f_maxs = np.min(r_targets_arr, axis=0), np.max(r_targets_arr, axis=0)

    # Do fast non-dominated sorting on R_t -> F
    fronts = self._sort(r_targets_arr, total_constraint_violation(r_population, self.constraints_symbols))
    crowding_distances = np.ones(self.population_size) * np.nan
    rankings = np.ones(self.population_size) * np.nan
    fitness_values = np.ones(self.population_size) * np.nan

    # Set the new parent population to P_t+1 = empty and i=1
    new_parents = np.ones((self.population_size, parents[1].shape[1])) * np.nan
    new_parents_solutions = np.ones((self.population_size, parents[0].shape[1])) * np.nan
    parents_ptr = 0  # keep track where stuff was last added

    # the -1 is here because searchsorted returns the index where we can insert the population size to preserve the
    # order, hence, the previous index of this will be the last element in the cumsum that is less than
    # the population size
    last_whole_front_idx = (
        np.searchsorted(np.cumsum(np.sum(fronts, axis=1)), self.population_size, side="right") - 1
    )

    last_ranking = 0  # in case first front is larger th population size
    for i in range(last_whole_front_idx + 1):  # inclusive
        # The looped front here will result in a new population with size <= 100.

        # Compute the crowding distances for F_i
        distances = _nsga2_crowding_distance_assignment(r_targets_arr[fronts[i]], f_mins, f_maxs)
        crowding_distances[parents_ptr : parents_ptr + distances.shape[0]] = (
            distances  # distances will have same number of elements as in front[i]
        )

        # keep track of the rankings as well (best = 0, larger worse). First
        # non-dom front will have a rank fitness of 0.
        rankings[parents_ptr : parents_ptr + distances.shape[0]] = i

        #   P_t+1 = P_t+1 U F_i
        new_parents[parents_ptr : parents_ptr + distances.shape[0]] = r_population.filter(fronts[i])
        new_parents_solutions[parents_ptr : parents_ptr + distances.shape[0]] = r_solutions.filter(fronts[i])

        # compute fitness
        # infs are checked since boundary points are assigned this value when computing the crowding distance
        finite_distances = distances[distances != np.inf]
        max_no_inf = np.nanmax(finite_distances) if finite_distances.size > 0 else np.ones(fronts[i].sum())
        distances_no_inf = np.nan_to_num(distances, posinf=max_no_inf * 1.1)

        # Distances for the current front normalized between 0 and 1.
        # The small scalar we add in the nominator and denominator is to
        # ensure that no distance value would result in exactly 0 after
        # normalizing, which would increase the corresponding solution
        # ranking, once reversed, which we do not want to.
        normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
            distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
        )

        # since higher is better for the crowded distance, we substract the normalized distances from 1 so that
        # lower is better, which allows us to combine them with the ranking
        # No value here should be 1.0 or greater.
        reversed_distances = 1.0 - normalized_distances

        front_fitness = reversed_distances + rankings[parents_ptr : parents_ptr + distances.shape[0]]
        fitness_values[parents_ptr : parents_ptr + distances.shape[0]] = front_fitness

        # increment parent pointer
        parents_ptr += distances.shape[0]

        # keep track of last given rank
        last_ranking = i

    # deal with last (partial) front, if needed
    trimmed_and_sorted_indices = None
    if parents_ptr < self.population_size:
        distances = _nsga2_crowding_distance_assignment(
            r_targets_arr[fronts[last_whole_front_idx + 1]], f_mins, f_maxs
        )

        # Sort F_i in descending order according to crowding distance
        # This makes picking the selected part of the partial front easier
        trimmed_and_sorted_indices = distances.argsort()[::-1][: self.population_size - parents_ptr]

        crowding_distances[parents_ptr : self.population_size] = distances[trimmed_and_sorted_indices]
        rankings[parents_ptr : self.population_size] = last_ranking + 1

        # P_t+1 = P_t+1 U F_i[1: (N - |P_t+1|)]
        new_parents[parents_ptr : self.population_size] = r_population.filter(fronts[last_whole_front_idx + 1])[
            trimmed_and_sorted_indices
        ]
        new_parents_solutions[parents_ptr : self.population_size] = r_solutions.filter(
            fronts[last_whole_front_idx + 1]
        )[trimmed_and_sorted_indices]

        # compute fitness (see above for details)
        finite_distances = distances[trimmed_and_sorted_indices][distances[trimmed_and_sorted_indices] != np.inf]
        max_no_inf = (
            np.nanmax(finite_distances)
            if finite_distances.size > 0
            else np.ones(len(trimmed_and_sorted_indices))  # we have only boundary points
        )
        distances_no_inf = np.nan_to_num(distances[trimmed_and_sorted_indices], posinf=max_no_inf * 1.1)

        normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
            distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
        )

        reversed_distances = 1.0 - normalized_distances

        front_fitness = reversed_distances + rankings[parents_ptr : self.population_size]
        fitness_values[parents_ptr : parents_ptr + self.population_size] = front_fitness

    # back to polars, return values
    solutions = pl.DataFrame(new_parents_solutions, schema=parents[0].schema)
    outputs = pl.DataFrame(new_parents, schema=parents[1].schema)

    # Everything downstream of SELECTED_FITNESS reads it as higher-is-better: the mating
    # tournament takes an argmax, and roulette-wheel selection treats it as a weight, which also
    # requires it to be positive. The NSGA-II quantity built above is the opposite -- front rank
    # plus a reversed crowding distance, lower is better -- so publishing it directly made the
    # binary tournament pick the loser of every pair. Reflecting it about the worst attainable
    # value flips the direction, keeps every value strictly positive (a front's fitness lies in
    # `(n_fronts - rank - 1, n_fronts - rank]`, since the reversed distance never reaches 1) and
    # preserves the ordering exactly.
    self.fitness = (np.nanmax(rankings) + 1.0) - fitness_values

    whole_fronts = fronts[: last_whole_front_idx + 1]
    whole_indices = [np.where(row)[0].tolist() for row in whole_fronts]

    if trimmed_and_sorted_indices is not None:
        # partial front considered
        partial_front = fronts[last_whole_front_idx + 1]
        partial_indices = np.where(partial_front)[0][trimmed_and_sorted_indices].tolist()
    else:
        partial_indices = []

    self.selection = [index for indices in whole_indices for index in indices] + partial_indices
    self.selected_individuals = solutions
    self.selected_targets = outputs

    self.notify()
    return solutions, outputs
state
state() -> Sequence[Message]

Return the state of the selector.

Source code in desdeo/emo/operators/selection.py
def state(self) -> Sequence[Message]:
    """Return the state of the selector."""
    if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
        return []
    if self.verbosity == 1:
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "population_size": self.population_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            )
        ]
    # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "population_size": self.population_size,
                "selected_individuals": self.selection,
            },
            source=self.__class__.__name__,
        ),
        message,
        NumpyArrayMessage(
            topic=SelectorMessageTopics.SELECTED_FITNESS,
            value=self.fitness,
            source=self.__class__.__name__,
        ),
    ]
update
update(message: Message) -> None

Handle an incoming message. This operator does not react to messages.

Source code in desdeo/emo/operators/selection.py
def update(self, message: Message) -> None:
    """Handle an incoming message. This operator does not react to messages."""

NSGA3Selector

Bases: BaseDecompositionSelector

The NSGA-III selection operator, heavily based on the version of nsga3 in the pymoo package by msu-coinlab.

Source code in desdeo/emo/operators/selection.py
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
class NSGA3Selector(BaseDecompositionSelector):
    """The NSGA-III selection operator, heavily based on the version of nsga3 in the pymoo package by msu-coinlab."""

    @property
    def provided_topics(self):
        """The message topics this operator publishes, keyed by verbosity level."""
        return {
            0: [],
            1: [
                SelectorMessageTopics.STATE,
            ],
            2: [
                SelectorMessageTopics.REFERENCE_VECTORS,
                SelectorMessageTopics.STATE,
                SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics this operator subscribes to."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        reference_vector_options: ReferenceVectorOptions | None = None,
        invert_reference_vectors: bool = False,
        seed: int = 0,
    ):
        """Initialize the NSGA-III selection operator.

        Args:
            problem (Problem): The optimization problem to be solved.
            verbosity (int): The verbosity level of the operator.
            publisher (Publisher): The publisher to use for communication.
            reference_vector_options (ReferenceVectorOptions | None, optional): Reference vector options.
                Defaults to None.
            invert_reference_vectors (bool, optional): Whether to invert the reference vectors. Defaults to False.
            seed (int, optional): The random seed to use. Defaults to 0.
        """
        if reference_vector_options is None:
            reference_vector_options = ReferenceVectorOptions()
        elif isinstance(reference_vector_options, dict):
            reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)

        # Just asserting correct options for NSGA-III
        reference_vector_options.vector_type = "planar"
        super().__init__(
            problem,
            reference_vector_options=reference_vector_options,
            verbosity=verbosity,
            publisher=publisher,
            seed=seed,
            invert_reference_vectors=invert_reference_vectors,
        )

        self.adapted_reference_vectors = None
        self.worst_fitness: np.ndarray | None = None
        self.extreme_points: np.ndarray | None = None
        self.n_survive = self.reference_vectors.shape[0]
        self.selection: list[int] | None = None
        self.selected_individuals: SolutionType | None = None
        self.selected_targets: pl.DataFrame | None = None

    def do(  # NOQA: C901
        self,
        parents: tuple[SolutionType, pl.DataFrame],
        offsprings: tuple[SolutionType, pl.DataFrame],
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
                targets, and constraint violations.
        """
        if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
            solutions = parents[0].vstack(offsprings[0])
        elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
            solutions = parents[0] + offsprings[0]
        else:
            raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
        alltargets = parents[1].vstack(offsprings[1])

        # Check if there are constraints and filter feasible solutions
        if self.constraints_symbols is not None and len(self.constraints_symbols) > 0:
            constraints = alltargets[self.constraints_symbols].to_numpy()
            constraints = np.where(constraints > 0, constraints, 0)
            constraints_sum = np.sum(constraints, axis=1)
            feasible = np.where(constraints_sum == 0)[0].tolist()
            sorted_infeasible = constraints_sum.argsort().tolist()[len(feasible) :]
            if len(feasible) <= self.n_survive:
                # Put all feasible solutions in the selection, then fill the rest with the least constraint violation
                select = feasible.copy()
                remaining = self.n_survive - len(select)
                select += sorted_infeasible[:remaining]
                self.selection = select
                if isinstance(solutions, pl.DataFrame) and self.selection is not None:
                    self.selected_individuals = solutions[self.selection]
                elif isinstance(solutions, list) and self.selection is not None:
                    self.selected_individuals = [solutions[i] for i in self.selection]
                else:
                    raise RuntimeError("Something went wrong with the selection")
                self.selected_targets = alltargets[self.selection]

                self.notify()
                return self.selected_individuals, self.selected_targets
            # else:
            # Only consider feasible solutions for selection
            if isinstance(solutions, pl.DataFrame):
                solutions = solutions[feasible]
            elif isinstance(solutions, list):
                solutions = [solutions[i] for i in feasible]
            alltargets = alltargets[feasible]

        targets = alltargets[self.target_symbols].to_numpy()
        ref_dirs = self.reference_vectors

        if self.ideal is None:
            self.ideal = np.min(targets, axis=0)
        else:
            self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
        fitness = targets
        # Calculating fronts and ranks
        # fronts, dl, dc, rank = nds(fitness)
        fronts = fast_non_dominated_sort(fitness)
        fronts = [np.where(fronts[i])[0] for i in range(len(fronts))]
        non_dominated = fronts[0]

        if self.worst_fitness is None:
            self.worst_fitness = np.max(fitness, axis=0)
        else:
            self.worst_fitness = np.amax(np.vstack((self.worst_fitness, fitness)), axis=0)

        # Calculating worst points
        worst_of_population = np.amax(fitness, axis=0)
        worst_of_front = np.max(fitness[non_dominated, :], axis=0)
        self.extreme_points = self.get_extreme_points_c(
            fitness[non_dominated, :], self.ideal, extreme_points=self.extreme_points
        )
        self.nadir_point = nadir_point = self.get_nadir_point(
            self.extreme_points,
            self.ideal,
            self.worst_fitness,
            worst_of_population,
            worst_of_front,
        )

        # Finding individuals in first 'n' fronts
        selection = np.asarray([], dtype=int)
        for front_id in range(len(fronts)):
            if len(np.concatenate(fronts[: front_id + 1])) < self.n_survive:
                continue
            fronts = fronts[: front_id + 1]
            selection = np.concatenate(fronts)
            break
        else:
            # The combined population is smaller than the desired number of survivors
            # (this happens, e.g., when preferred solutions inflate the number of
            # reference vectors beyond the population size). Keep all available solutions.
            if fronts:
                selection = np.concatenate(fronts)
        front_fitness = fitness[selection]

        last_front = fronts[-1]

        # Selecting individuals from the last acceptable front.
        if len(selection) > self.n_survive:
            niche_of_individuals, dist_to_niche = self.associate_to_niches(
                front_fitness, ref_dirs, self.ideal, nadir_point
            )
            # if there is only one front
            if len(fronts) == 1:
                n_remaining = self.n_survive
                until_last_front = np.array([], dtype=int)
                niche_count = np.zeros(len(ref_dirs), dtype=int)

            # if some individuals already survived
            else:
                until_last_front = np.concatenate(fronts[:-1])
                id_until_last_front = list(range(len(until_last_front)))
                niche_count = self.calc_niche_count(len(ref_dirs), niche_of_individuals[id_until_last_front])
                n_remaining = self.n_survive - len(until_last_front)

            last_front_selection_id = list(range(len(until_last_front), len(selection)))
            selected_from_last_front = self.niching(
                fitness[last_front, :],
                n_remaining,
                niche_count,
                niche_of_individuals[last_front_selection_id],
                dist_to_niche[last_front_selection_id],
            )
            final_selection = np.concatenate((until_last_front, last_front[selected_from_last_front]))
        else:
            final_selection = selection

        self.selection = final_selection.tolist()
        if isinstance(solutions, pl.DataFrame) and self.selection is not None:
            self.selected_individuals = solutions[self.selection]
        elif isinstance(solutions, list) and self.selection is not None:
            self.selected_individuals = [solutions[i] for i in self.selection]
        else:
            raise RuntimeError("Something went wrong with the selection")
        self.selected_targets = alltargets[self.selection]

        self.notify()
        return self.selected_individuals, self.selected_targets

    def get_extreme_points_c(self, f, ideal_point, extreme_points=None):
        """Find the extreme points used for normalization (adapted from pymoo)."""
        # calculate the asf which is used for the extreme point decomposition
        asf = np.eye(f.shape[1])
        asf[asf == 0] = 1e6

        # add the old extreme points to never loose them for normalization
        all_f = f
        if extreme_points is not None:
            all_f = np.concatenate([extreme_points, all_f], axis=0)

        # translate so that small values are substituted to 0
        near_zero_tolerance = 1e-3
        translated_f = all_f - ideal_point
        translated_f[translated_f < near_zero_tolerance] = 0

        # update the extreme points for the normalization having the highest asf value each
        f_asf = np.max(translated_f * asf[:, None, :], axis=2)
        min_idx = np.argmin(f_asf, axis=1)
        return all_f[min_idx, :]

    def get_nadir_point(
        self,
        extreme_points,
        ideal_point,
        worst_point,
        worst_of_front,
        worst_of_population,
    ):
        """Estimate the nadir point from the extreme points (adapted from pymoo)."""
        degenerate_tolerance = 1e-6
        try:
            # find the intercepts using gaussian elimination
            coeff_matrix = extreme_points - ideal_point
            b = np.ones(extreme_points.shape[1])
            plane = np.linalg.solve(coeff_matrix, b)
            intercepts = 1 / plane

            nadir_point = ideal_point + intercepts

            if (
                not np.allclose(np.dot(coeff_matrix, plane), b)
                or np.any(intercepts <= degenerate_tolerance)
                or np.any(nadir_point > worst_point)
            ):
                raise np.linalg.LinAlgError  # noqa: TRY301

        except np.linalg.LinAlgError:
            nadir_point = worst_of_front

        b = nadir_point - ideal_point <= degenerate_tolerance
        nadir_point[b] = worst_of_population[b]
        return nadir_point

    def niching(self, f, n_remaining, niche_count, niche_of_individuals, dist_to_niche):
        """Select survivors from the last front by reference-vector niching (adapted from pymoo)."""
        survivors = []

        # boolean array of elements that are considered for each iteration
        mask = np.full(f.shape[0], fill_value=True)

        while len(survivors) < n_remaining:
            # all niches where new individuals can be assigned to
            next_niches_list = np.unique(niche_of_individuals[mask])

            # pick a niche with minimum assigned individuals - break tie if necessary
            next_niche_count = niche_count[next_niches_list]
            next_niche = np.where(next_niche_count == next_niche_count.min())[0]
            next_niche = next_niches_list[next_niche]
            next_niche = next_niche[self.rng.integers(0, len(next_niche))]

            # indices of individuals that are considered and assign to next_niche
            next_ind = np.where(np.logical_and(niche_of_individuals == next_niche, mask))[0]

            # shuffle to break random tie (equal perp. dist) or select randomly
            self.rng.shuffle(next_ind)

            # if the niche is empty, take the closest individual; otherwise take a random one (already shuffled)
            next_ind = next_ind[np.argmin(dist_to_niche[next_ind])] if niche_count[next_niche] == 0 else next_ind[0]

            mask[next_ind] = False
            survivors.append(int(next_ind))

            niche_count[next_niche] += 1

        return survivors

    def associate_to_niches(self, f, ref_dirs, ideal_point, nadir_point, utopian_epsilon=0.0):
        """Associate each solution with its closest reference vector (adapted from pymoo)."""
        utopian_point = ideal_point - utopian_epsilon

        denom = nadir_point - utopian_point
        denom[denom == 0] = 1e-12

        # normalize by ideal point and intercepts
        normalized = (f - utopian_point) / denom
        dist_matrix = jitted_calc_perpendicular_distance(normalized, ref_dirs, self.invert_reference_vectors)

        niche_of_individuals = np.argmin(dist_matrix, axis=1)
        dist_to_niche = dist_matrix[np.arange(f.shape[0]), niche_of_individuals]

        return niche_of_individuals, dist_to_niche

    def calc_niche_count(self, n_niches, niche_of_individuals):
        """Count how many of the given individuals are assigned to each niche."""
        niche_count = np.zeros(n_niches, dtype=int)
        index, count = np.unique(niche_of_individuals, return_counts=True)
        niche_count[index] = count
        return niche_count

    def calc_perpendicular_distance(self, normalized, ref_dirs):
        """Compute the perpendicular distance from each normalized solution to each reference direction."""
        if self.invert_reference_vectors:
            u = np.tile(-ref_dirs, (len(normalized), 1))
            v = np.repeat(1 - normalized, len(ref_dirs), axis=0)
        else:
            u = np.tile(ref_dirs, (len(normalized), 1))
            v = np.repeat(normalized, len(ref_dirs), axis=0)

        norm_u = np.linalg.norm(u, axis=1)

        scalar_proj = np.sum(v * u, axis=1) / norm_u
        proj = scalar_proj[:, None] * u / norm_u[:, None]
        val = np.linalg.norm(proj - v, axis=1)
        return np.reshape(val, (len(normalized), len(ref_dirs)))

    def state(self) -> Sequence[Message]:
        """Return the operator's state as messages for the current verbosity level."""
        if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
            return []
        if self.verbosity == 1:
            return [
                Array2DMessage(
                    topic=SelectorMessageTopics.REFERENCE_VECTORS,
                    value=self.reference_vectors.tolist(),
                    source=self.__class__.__name__,
                ),
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "ideal": self.ideal,
                        "nadir": self.worst_fitness,
                        "extreme_points": self.extreme_points,
                        "n_survive": self.n_survive,
                    },
                    source=self.__class__.__name__,
                ),
            ]
        # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            Array2DMessage(
                topic=SelectorMessageTopics.REFERENCE_VECTORS,
                value=self.reference_vectors.tolist(),
                source=self.__class__.__name__,
            ),
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "ideal": self.ideal,
                    "nadir": self.worst_fitness,
                    "extreme_points": self.extreme_points,
                    "n_survive": self.n_survive,
                },
                source=self.__class__.__name__,
            ),
            # Array2DMessage(
            #     topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
            #     value=self.selected_individuals,
            #     source=self.__class__.__name__,
            # ),
            message,
        ]

    def update(self, message: Message) -> None:
        """Handle an incoming message. This operator does not react to messages."""
interested_topics property
interested_topics

The message topics this operator subscribes to.

provided_topics property
provided_topics

The message topics this operator publishes, keyed by verbosity level.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    reference_vector_options: ReferenceVectorOptions
    | None = None,
    invert_reference_vectors: bool = False,
    seed: int = 0,
)

Initialize the NSGA-III selection operator.

Parameters:

Name Type Description Default
problem Problem

The optimization problem to be solved.

required
verbosity int

The verbosity level of the operator.

required
publisher Publisher

The publisher to use for communication.

required
reference_vector_options ReferenceVectorOptions | None

Reference vector options. Defaults to None.

None
invert_reference_vectors bool

Whether to invert the reference vectors. Defaults to False.

False
seed int

The random seed to use. Defaults to 0.

0
Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    reference_vector_options: ReferenceVectorOptions | None = None,
    invert_reference_vectors: bool = False,
    seed: int = 0,
):
    """Initialize the NSGA-III selection operator.

    Args:
        problem (Problem): The optimization problem to be solved.
        verbosity (int): The verbosity level of the operator.
        publisher (Publisher): The publisher to use for communication.
        reference_vector_options (ReferenceVectorOptions | None, optional): Reference vector options.
            Defaults to None.
        invert_reference_vectors (bool, optional): Whether to invert the reference vectors. Defaults to False.
        seed (int, optional): The random seed to use. Defaults to 0.
    """
    if reference_vector_options is None:
        reference_vector_options = ReferenceVectorOptions()
    elif isinstance(reference_vector_options, dict):
        reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)

    # Just asserting correct options for NSGA-III
    reference_vector_options.vector_type = "planar"
    super().__init__(
        problem,
        reference_vector_options=reference_vector_options,
        verbosity=verbosity,
        publisher=publisher,
        seed=seed,
        invert_reference_vectors=invert_reference_vectors,
    )

    self.adapted_reference_vectors = None
    self.worst_fitness: np.ndarray | None = None
    self.extreme_points: np.ndarray | None = None
    self.n_survive = self.reference_vectors.shape[0]
    self.selection: list[int] | None = None
    self.selected_individuals: SolutionType | None = None
    self.selected_targets: pl.DataFrame | None = None
associate_to_niches
associate_to_niches(
    f,
    ref_dirs,
    ideal_point,
    nadir_point,
    utopian_epsilon=0.0,
)

Associate each solution with its closest reference vector (adapted from pymoo).

Source code in desdeo/emo/operators/selection.py
def associate_to_niches(self, f, ref_dirs, ideal_point, nadir_point, utopian_epsilon=0.0):
    """Associate each solution with its closest reference vector (adapted from pymoo)."""
    utopian_point = ideal_point - utopian_epsilon

    denom = nadir_point - utopian_point
    denom[denom == 0] = 1e-12

    # normalize by ideal point and intercepts
    normalized = (f - utopian_point) / denom
    dist_matrix = jitted_calc_perpendicular_distance(normalized, ref_dirs, self.invert_reference_vectors)

    niche_of_individuals = np.argmin(dist_matrix, axis=1)
    dist_to_niche = dist_matrix[np.arange(f.shape[0]), niche_of_individuals]

    return niche_of_individuals, dist_to_niche
calc_niche_count
calc_niche_count(n_niches, niche_of_individuals)

Count how many of the given individuals are assigned to each niche.

Source code in desdeo/emo/operators/selection.py
def calc_niche_count(self, n_niches, niche_of_individuals):
    """Count how many of the given individuals are assigned to each niche."""
    niche_count = np.zeros(n_niches, dtype=int)
    index, count = np.unique(niche_of_individuals, return_counts=True)
    niche_count[index] = count
    return niche_count
calc_perpendicular_distance
calc_perpendicular_distance(normalized, ref_dirs)

Compute the perpendicular distance from each normalized solution to each reference direction.

Source code in desdeo/emo/operators/selection.py
def calc_perpendicular_distance(self, normalized, ref_dirs):
    """Compute the perpendicular distance from each normalized solution to each reference direction."""
    if self.invert_reference_vectors:
        u = np.tile(-ref_dirs, (len(normalized), 1))
        v = np.repeat(1 - normalized, len(ref_dirs), axis=0)
    else:
        u = np.tile(ref_dirs, (len(normalized), 1))
        v = np.repeat(normalized, len(ref_dirs), axis=0)

    norm_u = np.linalg.norm(u, axis=1)

    scalar_proj = np.sum(v * u, axis=1) / norm_u
    proj = scalar_proj[:, None] * u / norm_u[:, None]
    val = np.linalg.norm(proj - v, axis=1)
    return np.reshape(val, (len(normalized), len(ref_dirs)))
do
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
parents tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
offsprings tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values, targets, and constraint violations.

Source code in desdeo/emo/operators/selection.py
def do(  # NOQA: C901
    self,
    parents: tuple[SolutionType, pl.DataFrame],
    offsprings: tuple[SolutionType, pl.DataFrame],
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
            targets, and constraint violations.
    """
    if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
        solutions = parents[0].vstack(offsprings[0])
    elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
        solutions = parents[0] + offsprings[0]
    else:
        raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
    alltargets = parents[1].vstack(offsprings[1])

    # Check if there are constraints and filter feasible solutions
    if self.constraints_symbols is not None and len(self.constraints_symbols) > 0:
        constraints = alltargets[self.constraints_symbols].to_numpy()
        constraints = np.where(constraints > 0, constraints, 0)
        constraints_sum = np.sum(constraints, axis=1)
        feasible = np.where(constraints_sum == 0)[0].tolist()
        sorted_infeasible = constraints_sum.argsort().tolist()[len(feasible) :]
        if len(feasible) <= self.n_survive:
            # Put all feasible solutions in the selection, then fill the rest with the least constraint violation
            select = feasible.copy()
            remaining = self.n_survive - len(select)
            select += sorted_infeasible[:remaining]
            self.selection = select
            if isinstance(solutions, pl.DataFrame) and self.selection is not None:
                self.selected_individuals = solutions[self.selection]
            elif isinstance(solutions, list) and self.selection is not None:
                self.selected_individuals = [solutions[i] for i in self.selection]
            else:
                raise RuntimeError("Something went wrong with the selection")
            self.selected_targets = alltargets[self.selection]

            self.notify()
            return self.selected_individuals, self.selected_targets
        # else:
        # Only consider feasible solutions for selection
        if isinstance(solutions, pl.DataFrame):
            solutions = solutions[feasible]
        elif isinstance(solutions, list):
            solutions = [solutions[i] for i in feasible]
        alltargets = alltargets[feasible]

    targets = alltargets[self.target_symbols].to_numpy()
    ref_dirs = self.reference_vectors

    if self.ideal is None:
        self.ideal = np.min(targets, axis=0)
    else:
        self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
    fitness = targets
    # Calculating fronts and ranks
    # fronts, dl, dc, rank = nds(fitness)
    fronts = fast_non_dominated_sort(fitness)
    fronts = [np.where(fronts[i])[0] for i in range(len(fronts))]
    non_dominated = fronts[0]

    if self.worst_fitness is None:
        self.worst_fitness = np.max(fitness, axis=0)
    else:
        self.worst_fitness = np.amax(np.vstack((self.worst_fitness, fitness)), axis=0)

    # Calculating worst points
    worst_of_population = np.amax(fitness, axis=0)
    worst_of_front = np.max(fitness[non_dominated, :], axis=0)
    self.extreme_points = self.get_extreme_points_c(
        fitness[non_dominated, :], self.ideal, extreme_points=self.extreme_points
    )
    self.nadir_point = nadir_point = self.get_nadir_point(
        self.extreme_points,
        self.ideal,
        self.worst_fitness,
        worst_of_population,
        worst_of_front,
    )

    # Finding individuals in first 'n' fronts
    selection = np.asarray([], dtype=int)
    for front_id in range(len(fronts)):
        if len(np.concatenate(fronts[: front_id + 1])) < self.n_survive:
            continue
        fronts = fronts[: front_id + 1]
        selection = np.concatenate(fronts)
        break
    else:
        # The combined population is smaller than the desired number of survivors
        # (this happens, e.g., when preferred solutions inflate the number of
        # reference vectors beyond the population size). Keep all available solutions.
        if fronts:
            selection = np.concatenate(fronts)
    front_fitness = fitness[selection]

    last_front = fronts[-1]

    # Selecting individuals from the last acceptable front.
    if len(selection) > self.n_survive:
        niche_of_individuals, dist_to_niche = self.associate_to_niches(
            front_fitness, ref_dirs, self.ideal, nadir_point
        )
        # if there is only one front
        if len(fronts) == 1:
            n_remaining = self.n_survive
            until_last_front = np.array([], dtype=int)
            niche_count = np.zeros(len(ref_dirs), dtype=int)

        # if some individuals already survived
        else:
            until_last_front = np.concatenate(fronts[:-1])
            id_until_last_front = list(range(len(until_last_front)))
            niche_count = self.calc_niche_count(len(ref_dirs), niche_of_individuals[id_until_last_front])
            n_remaining = self.n_survive - len(until_last_front)

        last_front_selection_id = list(range(len(until_last_front), len(selection)))
        selected_from_last_front = self.niching(
            fitness[last_front, :],
            n_remaining,
            niche_count,
            niche_of_individuals[last_front_selection_id],
            dist_to_niche[last_front_selection_id],
        )
        final_selection = np.concatenate((until_last_front, last_front[selected_from_last_front]))
    else:
        final_selection = selection

    self.selection = final_selection.tolist()
    if isinstance(solutions, pl.DataFrame) and self.selection is not None:
        self.selected_individuals = solutions[self.selection]
    elif isinstance(solutions, list) and self.selection is not None:
        self.selected_individuals = [solutions[i] for i in self.selection]
    else:
        raise RuntimeError("Something went wrong with the selection")
    self.selected_targets = alltargets[self.selection]

    self.notify()
    return self.selected_individuals, self.selected_targets
get_extreme_points_c
get_extreme_points_c(f, ideal_point, extreme_points=None)

Find the extreme points used for normalization (adapted from pymoo).

Source code in desdeo/emo/operators/selection.py
def get_extreme_points_c(self, f, ideal_point, extreme_points=None):
    """Find the extreme points used for normalization (adapted from pymoo)."""
    # calculate the asf which is used for the extreme point decomposition
    asf = np.eye(f.shape[1])
    asf[asf == 0] = 1e6

    # add the old extreme points to never loose them for normalization
    all_f = f
    if extreme_points is not None:
        all_f = np.concatenate([extreme_points, all_f], axis=0)

    # translate so that small values are substituted to 0
    near_zero_tolerance = 1e-3
    translated_f = all_f - ideal_point
    translated_f[translated_f < near_zero_tolerance] = 0

    # update the extreme points for the normalization having the highest asf value each
    f_asf = np.max(translated_f * asf[:, None, :], axis=2)
    min_idx = np.argmin(f_asf, axis=1)
    return all_f[min_idx, :]
get_nadir_point
get_nadir_point(
    extreme_points,
    ideal_point,
    worst_point,
    worst_of_front,
    worst_of_population,
)

Estimate the nadir point from the extreme points (adapted from pymoo).

Source code in desdeo/emo/operators/selection.py
def get_nadir_point(
    self,
    extreme_points,
    ideal_point,
    worst_point,
    worst_of_front,
    worst_of_population,
):
    """Estimate the nadir point from the extreme points (adapted from pymoo)."""
    degenerate_tolerance = 1e-6
    try:
        # find the intercepts using gaussian elimination
        coeff_matrix = extreme_points - ideal_point
        b = np.ones(extreme_points.shape[1])
        plane = np.linalg.solve(coeff_matrix, b)
        intercepts = 1 / plane

        nadir_point = ideal_point + intercepts

        if (
            not np.allclose(np.dot(coeff_matrix, plane), b)
            or np.any(intercepts <= degenerate_tolerance)
            or np.any(nadir_point > worst_point)
        ):
            raise np.linalg.LinAlgError  # noqa: TRY301

    except np.linalg.LinAlgError:
        nadir_point = worst_of_front

    b = nadir_point - ideal_point <= degenerate_tolerance
    nadir_point[b] = worst_of_population[b]
    return nadir_point
niching
niching(
    f,
    n_remaining,
    niche_count,
    niche_of_individuals,
    dist_to_niche,
)

Select survivors from the last front by reference-vector niching (adapted from pymoo).

Source code in desdeo/emo/operators/selection.py
def niching(self, f, n_remaining, niche_count, niche_of_individuals, dist_to_niche):
    """Select survivors from the last front by reference-vector niching (adapted from pymoo)."""
    survivors = []

    # boolean array of elements that are considered for each iteration
    mask = np.full(f.shape[0], fill_value=True)

    while len(survivors) < n_remaining:
        # all niches where new individuals can be assigned to
        next_niches_list = np.unique(niche_of_individuals[mask])

        # pick a niche with minimum assigned individuals - break tie if necessary
        next_niche_count = niche_count[next_niches_list]
        next_niche = np.where(next_niche_count == next_niche_count.min())[0]
        next_niche = next_niches_list[next_niche]
        next_niche = next_niche[self.rng.integers(0, len(next_niche))]

        # indices of individuals that are considered and assign to next_niche
        next_ind = np.where(np.logical_and(niche_of_individuals == next_niche, mask))[0]

        # shuffle to break random tie (equal perp. dist) or select randomly
        self.rng.shuffle(next_ind)

        # if the niche is empty, take the closest individual; otherwise take a random one (already shuffled)
        next_ind = next_ind[np.argmin(dist_to_niche[next_ind])] if niche_count[next_niche] == 0 else next_ind[0]

        mask[next_ind] = False
        survivors.append(int(next_ind))

        niche_count[next_niche] += 1

    return survivors
state
state() -> Sequence[Message]

Return the operator's state as messages for the current verbosity level.

Source code in desdeo/emo/operators/selection.py
def state(self) -> Sequence[Message]:
    """Return the operator's state as messages for the current verbosity level."""
    if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
        return []
    if self.verbosity == 1:
        return [
            Array2DMessage(
                topic=SelectorMessageTopics.REFERENCE_VECTORS,
                value=self.reference_vectors.tolist(),
                source=self.__class__.__name__,
            ),
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "ideal": self.ideal,
                    "nadir": self.worst_fitness,
                    "extreme_points": self.extreme_points,
                    "n_survive": self.n_survive,
                },
                source=self.__class__.__name__,
            ),
        ]
    # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        Array2DMessage(
            topic=SelectorMessageTopics.REFERENCE_VECTORS,
            value=self.reference_vectors.tolist(),
            source=self.__class__.__name__,
        ),
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "ideal": self.ideal,
                "nadir": self.worst_fitness,
                "extreme_points": self.extreme_points,
                "n_survive": self.n_survive,
            },
            source=self.__class__.__name__,
        ),
        # Array2DMessage(
        #     topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
        #     value=self.selected_individuals,
        #     source=self.__class__.__name__,
        # ),
        message,
    ]
update
update(message: Message) -> None

Handle an incoming message. This operator does not react to messages.

Source code in desdeo/emo/operators/selection.py
def update(self, message: Message) -> None:
    """Handle an incoming message. This operator does not react to messages."""

ParameterAdaptationStrategy

Bases: StrEnum

The parameter adaptation strategies for the RVEA selector.

Source code in desdeo/emo/operators/selection.py
class ParameterAdaptationStrategy(StrEnum):
    """The parameter adaptation strategies for the RVEA selector."""

    GENERATION_BASED = "GENERATION_BASED"  # Based on the current generation and the maximum generation.
    FUNCTION_EVALUATION_BASED = (
        "FUNCTION_EVALUATION_BASED"  # Based on the current function evaluation and the maximum function evaluation.
    )
    OTHER = "OTHER"  # As of yet undefined strategies.

RVEASelector

Bases: BaseDecompositionSelector

Reference Vector Guided Evolutionary Algorithm (RVEA) selection operator.

Source code in desdeo/emo/operators/selection.py
class RVEASelector(BaseDecompositionSelector):
    """Reference Vector Guided Evolutionary Algorithm (RVEA) selection operator."""

    @property
    def provided_topics(self):
        """The message topics this operator publishes, keyed by verbosity level."""
        return {
            0: [],
            1: [
                SelectorMessageTopics.STATE,
            ],
            2: [
                SelectorMessageTopics.REFERENCE_VECTORS,
                SelectorMessageTopics.STATE,
                SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics this operator subscribes to."""
        return [
            TerminatorMessageTopics.GENERATION,
            TerminatorMessageTopics.MAX_GENERATIONS,
            TerminatorMessageTopics.EVALUATION,
            TerminatorMessageTopics.MAX_EVALUATIONS,
        ]

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        alpha: float = 2.0,
        parameter_adaptation_strategy: ParameterAdaptationStrategy = ParameterAdaptationStrategy.GENERATION_BASED,
        reference_vector_options: ReferenceVectorOptions | dict | None = None,
        seed: int = 0,
    ):
        """Initialize the RVEA selection operator. See the class and base class for argument details."""
        if parameter_adaptation_strategy not in ParameterAdaptationStrategy:
            raise TypeError(f"Parameter adaptation strategy must be of Type {type(ParameterAdaptationStrategy)}")
        if parameter_adaptation_strategy == ParameterAdaptationStrategy.OTHER:
            raise ValueError("Other parameter adaptation strategies are not yet implemented.")

        if reference_vector_options is None:
            reference_vector_options = ReferenceVectorOptions()

        if isinstance(reference_vector_options, dict):
            reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)

        # Just asserting correct options for RVEA
        reference_vector_options.vector_type = "spherical"
        if reference_vector_options.adaptation_frequency == 0:
            warnings.warn(
                "Adaptation frequency was set to 0. Setting it to 100 for RVEA selector. "
                "Set it to 0 only if you provide preference information.",
                UserWarning,
                stacklevel=3,
            )
            reference_vector_options.adaptation_frequency = 100

        super().__init__(
            problem=problem,
            reference_vector_options=reference_vector_options,
            verbosity=verbosity,
            publisher=publisher,
            seed=seed,
        )

        self.reference_vectors_gamma: np.ndarray
        self.numerator: float | None = None
        self.denominator: float | None = None
        # Which adaptation epoch was last acted on under an evaluation budget.
        self._adaptation_epoch = 0
        # Whether the ideal so far was taken over infeasible members only, for
        # want of any feasible one. Such an ideal can be better than any
        # feasible design can reach, so it is replaced, not minimised into,
        # the moment a feasible member appears.
        self._ideal_is_provisional = False
        self.alpha = alpha
        self.selected_individuals: list | pl.DataFrame
        self.selected_targets: pl.DataFrame
        self.selection: list[int]
        self.penalty = None
        self.parameter_adaptation_strategy = parameter_adaptation_strategy
        self.adapted_reference_vectors = None

    def do(
        self,
        parents: tuple[SolutionType, pl.DataFrame],
        offsprings: tuple[SolutionType, pl.DataFrame],
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
                targets, and constraint violations.
        """
        if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
            solutions = parents[0].vstack(offsprings[0])
        elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
            solutions = parents[0] + offsprings[0]
        else:
            raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
        if len(parents[0]) == 0:
            raise RuntimeError(
                "The parents population is empty. Cannot perform selection. This is a known unresolved issue."
            )
        alltargets = parents[1].vstack(offsprings[1])
        targets = alltargets[self.target_symbols].to_numpy()
        if self.constraints_symbols is None or len(self.constraints_symbols) == 0:
            # No constraints :)
            if self.ideal is None:
                self.ideal = np.min(targets, axis=0)
            else:
                self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
            self.nadir = np.max(targets, axis=0) if self.nadir is None else self.nadir
            if self.adapted_reference_vectors is None:
                self._adapt()
            selection, _ = _rvea_selection(
                fitness=targets,
                reference_vectors=self.adapted_reference_vectors,
                ideal=self.ideal,
                partial_penalty=self._partial_penalty_factor(),
                gamma=self.reference_vectors_gamma,
            )
        else:
            # Yes constraints :(
            constraints = (
                parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_symbols]).to_numpy()
            )
            feasible = (constraints <= 0).all(axis=1)
            # The ideal is tracked over feasible members only. A generation may have
            # none, at the start on a tightly constrained problem or later once the
            # population has contracted, and a minimum over nothing raises. Keep the
            # previous ideal in that case, as the nadir below already does. With no
            # ideal yet, take a provisional one over every member. The ideal of
            # infeasible members can be far better than any feasible design, and
            # minimising into it would carry that error through every later
            # generation, so the provisional ideal is replaced outright by the
            # first feasible one, and only then minimised into as usual.
            if feasible.any():
                feasible_ideal = np.min(targets[feasible], axis=0)
                if self.ideal is None or self._ideal_is_provisional:
                    self.ideal = feasible_ideal
                    self._ideal_is_provisional = False
                else:
                    self.ideal = np.min(np.vstack((self.ideal, feasible_ideal)), axis=0)
            elif self.ideal is None or self._ideal_is_provisional:
                self.ideal = np.min(targets, axis=0)
                self._ideal_is_provisional = True
            try:
                nadir = np.max(targets[feasible], axis=0)
                self.nadir = nadir
            except ValueError:  # No feasible solution in current population
                pass  # Use previous nadir
            if self.adapted_reference_vectors is None:
                self._adapt()
            selection, _ = _rvea_selection_constrained(
                fitness=targets,
                constraints=constraints,
                reference_vectors=self.adapted_reference_vectors,
                ideal=self.ideal,
                partial_penalty=self._partial_penalty_factor(),
                gamma=self.reference_vectors_gamma,
            )

        self.selection = np.where(selection)[0].tolist()
        self.selected_individuals = solutions[self.selection]
        self.selected_targets = alltargets[self.selection]
        self.notify()
        return self.selected_individuals, self.selected_targets

    def _partial_penalty_factor(self) -> float:
        """Calculate and return the partial penalty factor for APD calculation.

            This calculation does not include the angle related terms, hence the name.
            If the calculated penalty is outside [0, 1], it will round it up/down to 0/1

        Returns:
            float: The partial penalty factor
        """
        if self.numerator is None or self.denominator is None or self.denominator == 0:
            raise RuntimeError("Numerator and denominator must be set before calculating the partial penalty factor.")
        penalty = self.numerator / self.denominator
        penalty = float(np.clip(penalty, 0, 1))
        self.penalty = (penalty**self.alpha) * self.reference_vectors.shape[1]
        return self.penalty

    def update(self, message: Message) -> None:
        """Update the parameters of the RVEA APD calculation.

        Args:
            message (Message): The message to update the parameters. The message should be coming from the
                Terminator operator (via the Publisher).
        """
        if not isinstance(message.topic, TerminatorMessageTopics):
            return
        if not isinstance(message.value, int):
            return
        if self.parameter_adaptation_strategy == ParameterAdaptationStrategy.GENERATION_BASED:
            if message.topic == TerminatorMessageTopics.GENERATION:
                self.numerator = message.value
                if (
                    self.reference_vector_options.adaptation_frequency > 0
                    and self.numerator % self.reference_vector_options.adaptation_frequency == 0
                ):
                    self._adapt()
            if message.topic == TerminatorMessageTopics.MAX_GENERATIONS:
                self.denominator = message.value
        elif self.parameter_adaptation_strategy == ParameterAdaptationStrategy.FUNCTION_EVALUATION_BASED:
            if message.topic == TerminatorMessageTopics.EVALUATION:
                self.numerator = message.value
                # The adaptation frequency is stated in generations. Under an
                # evaluation budget one generation is about one population of
                # evaluations, so adapt each time the count crosses that many
                # evaluations times the frequency, as the generation-based
                # branch adapts every frequency generations.
                frequency = self.reference_vector_options.adaptation_frequency
                if frequency > 0:
                    every = frequency * max(int(self.reference_vector_options.number_of_vectors or 1), 1)
                    epoch = self.numerator // every
                    if epoch > self._adaptation_epoch:
                        self._adaptation_epoch = epoch
                        self._adapt()
            if message.topic == TerminatorMessageTopics.MAX_EVALUATIONS:
                self.denominator = message.value
        return

    def state(self) -> Sequence[Message]:
        """Return the operator's state as messages for the current verbosity level."""
        if self.verbosity == 0 or self.selection is None:
            return []
        if self.verbosity == 1:
            return [
                Array2DMessage(
                    topic=SelectorMessageTopics.REFERENCE_VECTORS,
                    value=self.reference_vectors.tolist(),
                    source=self.__class__.__name__,
                ),
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "ideal": self.ideal,
                        "nadir": self.nadir,
                        "partial_penalty_factor": self._partial_penalty_factor(),
                    },
                    source=self.__class__.__name__,
                ),
            ]  # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            Array2DMessage(
                topic=SelectorMessageTopics.REFERENCE_VECTORS,
                value=self.reference_vectors.tolist(),
                source=self.__class__.__name__,
            ),
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "ideal": self.ideal,
                    "nadir": self.nadir,
                    "partial_penalty_factor": self._partial_penalty_factor(),
                },
                source=self.__class__.__name__,
            ),
            # DictMessage(
            #     topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
            #     value=self.selection[0].tolist(),
            #     source=self.__class__.__name__,
            # ),
            message,
        ]

    def _adapt(self):
        self.adapted_reference_vectors = self.reference_vectors
        if self.ideal is not None and self.nadir is not None:
            for i in range(self.reference_vectors.shape[0]):
                self.adapted_reference_vectors[i] = self.reference_vectors[i] * (self.nadir - self.ideal)
        self.adapted_reference_vectors = (
            self.adapted_reference_vectors / np.linalg.norm(self.adapted_reference_vectors, axis=1)[:, None]
        )

        self.reference_vectors_gamma = np.zeros(self.adapted_reference_vectors.shape[0])
        for i in range(self.adapted_reference_vectors.shape[0]):
            closest_angle = np.inf
            for j in range(self.adapted_reference_vectors.shape[0]):
                if i != j:
                    angle = np.arccos(
                        np.clip(np.dot(self.adapted_reference_vectors[i], self.adapted_reference_vectors[j]), -1.0, 1.0)
                    )
                    if angle < closest_angle and angle > 0:
                        # In cases with extreme differences in obj func ranges
                        # sometimes, the closest reference vectors are so close that
                        # the angle between them is 0 according to arccos (literally 0)
                        closest_angle = angle
            self.reference_vectors_gamma[i] = closest_angle
interested_topics property
interested_topics

The message topics this operator subscribes to.

provided_topics property
provided_topics

The message topics this operator publishes, keyed by verbosity level.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    alpha: float = 2.0,
    parameter_adaptation_strategy: ParameterAdaptationStrategy = ParameterAdaptationStrategy.GENERATION_BASED,
    reference_vector_options: ReferenceVectorOptions
    | dict
    | None = None,
    seed: int = 0,
)

Initialize the RVEA selection operator. See the class and base class for argument details.

Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    alpha: float = 2.0,
    parameter_adaptation_strategy: ParameterAdaptationStrategy = ParameterAdaptationStrategy.GENERATION_BASED,
    reference_vector_options: ReferenceVectorOptions | dict | None = None,
    seed: int = 0,
):
    """Initialize the RVEA selection operator. See the class and base class for argument details."""
    if parameter_adaptation_strategy not in ParameterAdaptationStrategy:
        raise TypeError(f"Parameter adaptation strategy must be of Type {type(ParameterAdaptationStrategy)}")
    if parameter_adaptation_strategy == ParameterAdaptationStrategy.OTHER:
        raise ValueError("Other parameter adaptation strategies are not yet implemented.")

    if reference_vector_options is None:
        reference_vector_options = ReferenceVectorOptions()

    if isinstance(reference_vector_options, dict):
        reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)

    # Just asserting correct options for RVEA
    reference_vector_options.vector_type = "spherical"
    if reference_vector_options.adaptation_frequency == 0:
        warnings.warn(
            "Adaptation frequency was set to 0. Setting it to 100 for RVEA selector. "
            "Set it to 0 only if you provide preference information.",
            UserWarning,
            stacklevel=3,
        )
        reference_vector_options.adaptation_frequency = 100

    super().__init__(
        problem=problem,
        reference_vector_options=reference_vector_options,
        verbosity=verbosity,
        publisher=publisher,
        seed=seed,
    )

    self.reference_vectors_gamma: np.ndarray
    self.numerator: float | None = None
    self.denominator: float | None = None
    # Which adaptation epoch was last acted on under an evaluation budget.
    self._adaptation_epoch = 0
    # Whether the ideal so far was taken over infeasible members only, for
    # want of any feasible one. Such an ideal can be better than any
    # feasible design can reach, so it is replaced, not minimised into,
    # the moment a feasible member appears.
    self._ideal_is_provisional = False
    self.alpha = alpha
    self.selected_individuals: list | pl.DataFrame
    self.selected_targets: pl.DataFrame
    self.selection: list[int]
    self.penalty = None
    self.parameter_adaptation_strategy = parameter_adaptation_strategy
    self.adapted_reference_vectors = None
_partial_penalty_factor
_partial_penalty_factor() -> float

Calculate and return the partial penalty factor for APD calculation.

This calculation does not include the angle related terms, hence the name.
If the calculated penalty is outside [0, 1], it will round it up/down to 0/1

Returns:

Name Type Description
float float

The partial penalty factor

Source code in desdeo/emo/operators/selection.py
def _partial_penalty_factor(self) -> float:
    """Calculate and return the partial penalty factor for APD calculation.

        This calculation does not include the angle related terms, hence the name.
        If the calculated penalty is outside [0, 1], it will round it up/down to 0/1

    Returns:
        float: The partial penalty factor
    """
    if self.numerator is None or self.denominator is None or self.denominator == 0:
        raise RuntimeError("Numerator and denominator must be set before calculating the partial penalty factor.")
    penalty = self.numerator / self.denominator
    penalty = float(np.clip(penalty, 0, 1))
    self.penalty = (penalty**self.alpha) * self.reference_vectors.shape[1]
    return self.penalty
do
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
parents tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
offsprings tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values, targets, and constraint violations.

Source code in desdeo/emo/operators/selection.py
def do(
    self,
    parents: tuple[SolutionType, pl.DataFrame],
    offsprings: tuple[SolutionType, pl.DataFrame],
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
            targets, and constraint violations.
    """
    if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
        solutions = parents[0].vstack(offsprings[0])
    elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
        solutions = parents[0] + offsprings[0]
    else:
        raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
    if len(parents[0]) == 0:
        raise RuntimeError(
            "The parents population is empty. Cannot perform selection. This is a known unresolved issue."
        )
    alltargets = parents[1].vstack(offsprings[1])
    targets = alltargets[self.target_symbols].to_numpy()
    if self.constraints_symbols is None or len(self.constraints_symbols) == 0:
        # No constraints :)
        if self.ideal is None:
            self.ideal = np.min(targets, axis=0)
        else:
            self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
        self.nadir = np.max(targets, axis=0) if self.nadir is None else self.nadir
        if self.adapted_reference_vectors is None:
            self._adapt()
        selection, _ = _rvea_selection(
            fitness=targets,
            reference_vectors=self.adapted_reference_vectors,
            ideal=self.ideal,
            partial_penalty=self._partial_penalty_factor(),
            gamma=self.reference_vectors_gamma,
        )
    else:
        # Yes constraints :(
        constraints = (
            parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_symbols]).to_numpy()
        )
        feasible = (constraints <= 0).all(axis=1)
        # The ideal is tracked over feasible members only. A generation may have
        # none, at the start on a tightly constrained problem or later once the
        # population has contracted, and a minimum over nothing raises. Keep the
        # previous ideal in that case, as the nadir below already does. With no
        # ideal yet, take a provisional one over every member. The ideal of
        # infeasible members can be far better than any feasible design, and
        # minimising into it would carry that error through every later
        # generation, so the provisional ideal is replaced outright by the
        # first feasible one, and only then minimised into as usual.
        if feasible.any():
            feasible_ideal = np.min(targets[feasible], axis=0)
            if self.ideal is None or self._ideal_is_provisional:
                self.ideal = feasible_ideal
                self._ideal_is_provisional = False
            else:
                self.ideal = np.min(np.vstack((self.ideal, feasible_ideal)), axis=0)
        elif self.ideal is None or self._ideal_is_provisional:
            self.ideal = np.min(targets, axis=0)
            self._ideal_is_provisional = True
        try:
            nadir = np.max(targets[feasible], axis=0)
            self.nadir = nadir
        except ValueError:  # No feasible solution in current population
            pass  # Use previous nadir
        if self.adapted_reference_vectors is None:
            self._adapt()
        selection, _ = _rvea_selection_constrained(
            fitness=targets,
            constraints=constraints,
            reference_vectors=self.adapted_reference_vectors,
            ideal=self.ideal,
            partial_penalty=self._partial_penalty_factor(),
            gamma=self.reference_vectors_gamma,
        )

    self.selection = np.where(selection)[0].tolist()
    self.selected_individuals = solutions[self.selection]
    self.selected_targets = alltargets[self.selection]
    self.notify()
    return self.selected_individuals, self.selected_targets
state
state() -> Sequence[Message]

Return the operator's state as messages for the current verbosity level.

Source code in desdeo/emo/operators/selection.py
def state(self) -> Sequence[Message]:
    """Return the operator's state as messages for the current verbosity level."""
    if self.verbosity == 0 or self.selection is None:
        return []
    if self.verbosity == 1:
        return [
            Array2DMessage(
                topic=SelectorMessageTopics.REFERENCE_VECTORS,
                value=self.reference_vectors.tolist(),
                source=self.__class__.__name__,
            ),
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "ideal": self.ideal,
                    "nadir": self.nadir,
                    "partial_penalty_factor": self._partial_penalty_factor(),
                },
                source=self.__class__.__name__,
            ),
        ]  # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        Array2DMessage(
            topic=SelectorMessageTopics.REFERENCE_VECTORS,
            value=self.reference_vectors.tolist(),
            source=self.__class__.__name__,
        ),
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "ideal": self.ideal,
                "nadir": self.nadir,
                "partial_penalty_factor": self._partial_penalty_factor(),
            },
            source=self.__class__.__name__,
        ),
        # DictMessage(
        #     topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
        #     value=self.selection[0].tolist(),
        #     source=self.__class__.__name__,
        # ),
        message,
    ]
update
update(message: Message) -> None

Update the parameters of the RVEA APD calculation.

Parameters:

Name Type Description Default
message Message

The message to update the parameters. The message should be coming from the Terminator operator (via the Publisher).

required
Source code in desdeo/emo/operators/selection.py
def update(self, message: Message) -> None:
    """Update the parameters of the RVEA APD calculation.

    Args:
        message (Message): The message to update the parameters. The message should be coming from the
            Terminator operator (via the Publisher).
    """
    if not isinstance(message.topic, TerminatorMessageTopics):
        return
    if not isinstance(message.value, int):
        return
    if self.parameter_adaptation_strategy == ParameterAdaptationStrategy.GENERATION_BASED:
        if message.topic == TerminatorMessageTopics.GENERATION:
            self.numerator = message.value
            if (
                self.reference_vector_options.adaptation_frequency > 0
                and self.numerator % self.reference_vector_options.adaptation_frequency == 0
            ):
                self._adapt()
        if message.topic == TerminatorMessageTopics.MAX_GENERATIONS:
            self.denominator = message.value
    elif self.parameter_adaptation_strategy == ParameterAdaptationStrategy.FUNCTION_EVALUATION_BASED:
        if message.topic == TerminatorMessageTopics.EVALUATION:
            self.numerator = message.value
            # The adaptation frequency is stated in generations. Under an
            # evaluation budget one generation is about one population of
            # evaluations, so adapt each time the count crosses that many
            # evaluations times the frequency, as the generation-based
            # branch adapts every frequency generations.
            frequency = self.reference_vector_options.adaptation_frequency
            if frequency > 0:
                every = frequency * max(int(self.reference_vector_options.number_of_vectors or 1), 1)
                epoch = self.numerator // every
                if epoch > self._adaptation_epoch:
                    self._adaptation_epoch = epoch
                    self._adapt()
        if message.topic == TerminatorMessageTopics.MAX_EVALUATIONS:
            self.denominator = message.value
    return

ReferenceVectorOptions

Bases: BaseModel

Pydantic model for Reference Vector arguments.

Source code in desdeo/emo/operators/selection.py
class ReferenceVectorOptions(BaseModel):
    """Pydantic model for Reference Vector arguments."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    adaptation_frequency: int = Field(default=0)
    """Number of generations between reference vector adaptation. If set to 0, no adaptation occurs. Defaults to 0.
    Only used if no preference is provided."""
    creation_type: Literal["simplex", "s_energy"] = Field(default="simplex")
    """The method for creating reference vectors. Defaults to "simplex".
    Currently only "simplex" is implemented. Future versions will include "s_energy".

    If set to "simplex", the reference vectors are created using the simplex lattice design method.
    This method is generates distributions with specific numbers of reference vectors.
    Check: https://www.itl.nist.gov/div898/handbook/pri/section5/pri542.htm for more information.
    If set to "s_energy", the reference vectors are created using the Riesz s-energy criterion. This method is used to
    distribute an arbitrary number of reference vectors in the objective space while minimizing the s-energy.
    Currently not implemented.
    """
    vector_type: Literal["spherical", "planar"] = Field(default="spherical")
    """The method for normalizing the reference vectors. Defaults to "spherical"."""
    lattice_resolution: int | None = None
    """Number of divisions along an axis when creating the simplex lattice. This is not required/used for the "s_energy"
    method. If not specified, the lattice resolution is calculated based on the `number_of_vectors`. If "spherical" is
    selected as the `vector_type`, this value overrides the `number_of_vectors`.
    """
    number_of_vectors: int = 200
    """Number of reference vectors to be created. If "simplex" is selected as the `creation_type`, then the closest
    `lattice_resolution` is calculated based on this value. If "s_energy" is selected, then this value is used directly.
    Note that if neither `lattice_resolution` nor `number_of_vectors` is specified, the number of vectors defaults to
    200. Overridden if "spherical" is selected as the `vector_type` and `lattice_resolution` is provided.
    """
    adaptation_distance: float = Field(default=0.2)
    """Distance parameter for the interactive adaptation methods. Defaults to 0.2."""
    reference_point: dict[str, float] | None = Field(default=None)
    """The reference point for interactive adaptation."""
    preferred_solutions: dict[str, list[float]] | None = Field(default=None)
    """The preferred solutions for interactive adaptation."""
    non_preferred_solutions: dict[str, list[float]] | None = Field(default=None)
    """The non-preferred solutions for interactive adaptation."""
    preferred_ranges: dict[str, list[float]] | None = Field(default=None)
    """The preferred ranges for interactive adaptation."""
adaptation_distance class-attribute instance-attribute
adaptation_distance: float = Field(default=0.2)

Distance parameter for the interactive adaptation methods. Defaults to 0.2.

adaptation_frequency class-attribute instance-attribute
adaptation_frequency: int = Field(default=0)

Number of generations between reference vector adaptation. If set to 0, no adaptation occurs. Defaults to 0. Only used if no preference is provided.

creation_type class-attribute instance-attribute
creation_type: Literal["simplex", "s_energy"] = Field(
    default="simplex"
)

The method for creating reference vectors. Defaults to "simplex". Currently only "simplex" is implemented. Future versions will include "s_energy".

If set to "simplex", the reference vectors are created using the simplex lattice design method. This method is generates distributions with specific numbers of reference vectors. Check: https://www.itl.nist.gov/div898/handbook/pri/section5/pri542.htm for more information. If set to "s_energy", the reference vectors are created using the Riesz s-energy criterion. This method is used to distribute an arbitrary number of reference vectors in the objective space while minimizing the s-energy. Currently not implemented.

lattice_resolution class-attribute instance-attribute
lattice_resolution: int | None = None

Number of divisions along an axis when creating the simplex lattice. This is not required/used for the "s_energy" method. If not specified, the lattice resolution is calculated based on the number_of_vectors. If "spherical" is selected as the vector_type, this value overrides the number_of_vectors.

non_preferred_solutions class-attribute instance-attribute
non_preferred_solutions: dict[str, list[float]] | None = (
    Field(default=None)
)

The non-preferred solutions for interactive adaptation.

number_of_vectors class-attribute instance-attribute
number_of_vectors: int = 200

Number of reference vectors to be created. If "simplex" is selected as the creation_type, then the closest lattice_resolution is calculated based on this value. If "s_energy" is selected, then this value is used directly. Note that if neither lattice_resolution nor number_of_vectors is specified, the number of vectors defaults to 200. Overridden if "spherical" is selected as the vector_type and lattice_resolution is provided.

preferred_ranges class-attribute instance-attribute
preferred_ranges: dict[str, list[float]] | None = Field(
    default=None
)

The preferred ranges for interactive adaptation.

preferred_solutions class-attribute instance-attribute
preferred_solutions: dict[str, list[float]] | None = Field(
    default=None
)

The preferred solutions for interactive adaptation.

reference_point class-attribute instance-attribute
reference_point: dict[str, float] | None = Field(
    default=None
)

The reference point for interactive adaptation.

vector_type class-attribute instance-attribute
vector_type: Literal["spherical", "planar"] = Field(
    default="spherical"
)

The method for normalizing the reference vectors. Defaults to "spherical".

SMSEMOASelector

Bases: BaseSelector

Implements the selection operator defined for SMSEMOA.

Implements the selection operator defined for SMSEMOA, which included the hypervolume contribution calculation.

Beume, N., Naujoks, B., & Emmerich, M. (2007). SMS-EMOA: Multiobjective selection based on dominated hypervolume. European Journal of Operational Research, 181(3), 1653-1669. https://doi.org/10.1016/j.ejor.2006.08.008

Source code in desdeo/emo/operators/selection.py
class SMSEMOASelector(BaseSelector):
    """Implements the selection operator defined for SMSEMOA.

    Implements the selection operator defined for SMSEMOA, which included the hypervolume
    contribution calculation.

    Beume, N., Naujoks, B., & Emmerich, M. (2007). SMS-EMOA: Multiobjective selection based on dominated hypervolume.
    European Journal of Operational Research, 181(3), 1653-1669. https://doi.org/10.1016/j.ejor.2006.08.008
    """

    @property
    def provided_topics(self):
        """The message topics this operator publishes, keyed by verbosity level."""
        return {
            0: [],
            1: [SelectorMessageTopics.STATE],
            2: [
                SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                SelectorMessageTopics.STATE,
            ],
        }

    @property
    def interested_topics(self):
        """The message topics this operator subscribes to."""
        return []

    def __init__(
        self,
        problem: Problem,
        verbosity: int,
        publisher: Publisher,
        normalised_reference_point_component: float,
        seed: int = 0,
    ):
        """Initialize the SMS-EMOA selection operator.

        Args:
            problem (Problem): The optimization problem to be solved.
            verbosity (int): The verbosity level of the operator.
            publisher (Publisher): The publisher to use for communication.
            normalised_reference_point_component (float, optional): The reference point component used for hypervolume
                calculation. The solutions are normalized to the range [0, 1] and the reference point is set to
                a vector of ones multiplied by this component.
            seed (int, optional): The random seed to use. Defaults to 0.
        """
        super().__init__(
            problem,
            verbosity=verbosity,
            publisher=publisher,
            seed=seed,
        )
        self.selection: list[int] | None = None
        self.selected_individuals: SolutionType | None = None
        self.selected_targets: pl.DataFrame | None = None
        if normalised_reference_point_component < 1:
            raise ValueError("The reference point component must be greater than or equal to 1.")
        self.reference_point_component = normalised_reference_point_component
        self.removed: int = 0

    def do(
        self,
        parents: tuple[SolutionType, pl.DataFrame],
        offsprings: tuple[SolutionType, pl.DataFrame],
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
                targets, and constraint violations.
        """
        if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
            solutions = parents[0].vstack(offsprings[0])
        elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
            solutions = parents[0] + offsprings[0]
        else:
            raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
        alltargets = parents[1].vstack(offsprings[1])

        if self.constraints_symbols is None or len(self.constraints_symbols) == 0:
            # No constraints, use SMS-EMOA selection
            self.removed = self._sms_emoa_selection(alltargets[self.target_symbols].to_numpy())
        elif not (alltargets.select(self.constraints_symbols) > 0).to_numpy().any():
            # All offsprings are feasible
            self.removed = self._sms_emoa_selection(alltargets[self.target_symbols].to_numpy())
        else:
            # Some offsprings are infeasible. Remove the most infeasible offspring.
            violations = (
                alltargets[self.constraints_symbols].to_numpy(),
                alltargets.select(self.constraints_symbols).to_numpy(),
            )
            self.removed = int(np.argmax(np.sum(np.maximum(0, violations), axis=1)))

        self.selection = list(range(len(alltargets)))
        self.selection.remove(self.removed)
        if isinstance(solutions, pl.DataFrame) and self.selection is not None:
            self.selected_individuals = solutions[self.selection]
        elif isinstance(solutions, list) and self.selection is not None:
            self.selected_individuals = [solutions[i] for i in self.selection]
        else:
            raise RuntimeError("Something went wrong with the selection")

        self.selected_targets = alltargets[self.selection]
        self.notify()
        return self.selected_individuals, self.selected_targets

    def _sms_emoa_selection(self, targets: np.ndarray) -> int:
        """Perform the SMS-EMOA selection operation.

        Note that in the paper in Algorithm 2, the factor (totalHV - currentHV) is minimized. As totalHV is constant,
        this is equivalent to maximizing currentHV, which is what we do here.

        Args:
            targets (np.ndarray): The objective values of the individuals.

        Returns:
            int: The index of the individual to be removed.
        """
        fronts = fast_non_dominated_sort_indices(targets)
        last_front = fronts[-1]
        if len(last_front) == 1:
            return last_front[0]
        # last front has more than one individual, compute hypervolume contributions
        max_hv = -np.inf
        worst_index = -1
        targets = targets - np.min(targets, axis=0)  # shift to origin
        targets = targets / np.max(targets, axis=0)  # scale to [0, 1] (based on the whole population)
        # using moocore hv contribusions
        hv_contribs = hv_contributions(
            targets[last_front], ref=np.ones(targets.shape[1]) * self.reference_point_component
        )
        min_contrib_index = np.argmin(hv_contribs)
        return last_front[min_contrib_index]
        for i in last_front:
            remaining_front = [j for j in last_front if j != i]
            current_hv = hv(targets[remaining_front], reference_point_component=self.reference_point_component)
            if current_hv > max_hv:
                max_hv = current_hv
                worst_index = i
        return worst_index

    def update(self, message: Message) -> None:
        """Handle an incoming message. This operator does not react to messages."""

    def state(self) -> Sequence[Message]:
        """Return the operator's state as messages for the current verbosity level."""
        if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
            return []
        if self.verbosity == 1:
            return [
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "selection": self.selection,
                        "removed": self.removed,
                    },
                    source=self.__class__.__name__,
                )
            ]
        # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "selection": self.selection,
                    "removed": self.removed,
                },
                source=self.__class__.__name__,
            ),
            message,
        ]
interested_topics property
interested_topics

The message topics this operator subscribes to.

provided_topics property
provided_topics

The message topics this operator publishes, keyed by verbosity level.

__init__
__init__(
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    normalised_reference_point_component: float,
    seed: int = 0,
)

Initialize the SMS-EMOA selection operator.

Parameters:

Name Type Description Default
problem Problem

The optimization problem to be solved.

required
verbosity int

The verbosity level of the operator.

required
publisher Publisher

The publisher to use for communication.

required
normalised_reference_point_component float

The reference point component used for hypervolume calculation. The solutions are normalized to the range [0, 1] and the reference point is set to a vector of ones multiplied by this component.

required
seed int

The random seed to use. Defaults to 0.

0
Source code in desdeo/emo/operators/selection.py
def __init__(
    self,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    normalised_reference_point_component: float,
    seed: int = 0,
):
    """Initialize the SMS-EMOA selection operator.

    Args:
        problem (Problem): The optimization problem to be solved.
        verbosity (int): The verbosity level of the operator.
        publisher (Publisher): The publisher to use for communication.
        normalised_reference_point_component (float, optional): The reference point component used for hypervolume
            calculation. The solutions are normalized to the range [0, 1] and the reference point is set to
            a vector of ones multiplied by this component.
        seed (int, optional): The random seed to use. Defaults to 0.
    """
    super().__init__(
        problem,
        verbosity=verbosity,
        publisher=publisher,
        seed=seed,
    )
    self.selection: list[int] | None = None
    self.selected_individuals: SolutionType | None = None
    self.selected_targets: pl.DataFrame | None = None
    if normalised_reference_point_component < 1:
        raise ValueError("The reference point component must be greater than or equal to 1.")
    self.reference_point_component = normalised_reference_point_component
    self.removed: int = 0
_sms_emoa_selection
_sms_emoa_selection(targets: ndarray) -> int

Perform the SMS-EMOA selection operation.

Note that in the paper in Algorithm 2, the factor (totalHV - currentHV) is minimized. As totalHV is constant, this is equivalent to maximizing currentHV, which is what we do here.

Parameters:

Name Type Description Default
targets ndarray

The objective values of the individuals.

required

Returns:

Name Type Description
int int

The index of the individual to be removed.

Source code in desdeo/emo/operators/selection.py
def _sms_emoa_selection(self, targets: np.ndarray) -> int:
    """Perform the SMS-EMOA selection operation.

    Note that in the paper in Algorithm 2, the factor (totalHV - currentHV) is minimized. As totalHV is constant,
    this is equivalent to maximizing currentHV, which is what we do here.

    Args:
        targets (np.ndarray): The objective values of the individuals.

    Returns:
        int: The index of the individual to be removed.
    """
    fronts = fast_non_dominated_sort_indices(targets)
    last_front = fronts[-1]
    if len(last_front) == 1:
        return last_front[0]
    # last front has more than one individual, compute hypervolume contributions
    max_hv = -np.inf
    worst_index = -1
    targets = targets - np.min(targets, axis=0)  # shift to origin
    targets = targets / np.max(targets, axis=0)  # scale to [0, 1] (based on the whole population)
    # using moocore hv contribusions
    hv_contribs = hv_contributions(
        targets[last_front], ref=np.ones(targets.shape[1]) * self.reference_point_component
    )
    min_contrib_index = np.argmin(hv_contribs)
    return last_front[min_contrib_index]
    for i in last_front:
        remaining_front = [j for j in last_front if j != i]
        current_hv = hv(targets[remaining_front], reference_point_component=self.reference_point_component)
        if current_hv > max_hv:
            max_hv = current_hv
            worst_index = i
    return worst_index
do
do(
    parents: tuple[SolutionType, DataFrame],
    offsprings: tuple[SolutionType, DataFrame],
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
parents tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
offsprings tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values, targets, and constraint violations.

Source code in desdeo/emo/operators/selection.py
def do(
    self,
    parents: tuple[SolutionType, pl.DataFrame],
    offsprings: tuple[SolutionType, pl.DataFrame],
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
            targets, and constraint violations.
    """
    if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
        solutions = parents[0].vstack(offsprings[0])
    elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
        solutions = parents[0] + offsprings[0]
    else:
        raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
    alltargets = parents[1].vstack(offsprings[1])

    if self.constraints_symbols is None or len(self.constraints_symbols) == 0:
        # No constraints, use SMS-EMOA selection
        self.removed = self._sms_emoa_selection(alltargets[self.target_symbols].to_numpy())
    elif not (alltargets.select(self.constraints_symbols) > 0).to_numpy().any():
        # All offsprings are feasible
        self.removed = self._sms_emoa_selection(alltargets[self.target_symbols].to_numpy())
    else:
        # Some offsprings are infeasible. Remove the most infeasible offspring.
        violations = (
            alltargets[self.constraints_symbols].to_numpy(),
            alltargets.select(self.constraints_symbols).to_numpy(),
        )
        self.removed = int(np.argmax(np.sum(np.maximum(0, violations), axis=1)))

    self.selection = list(range(len(alltargets)))
    self.selection.remove(self.removed)
    if isinstance(solutions, pl.DataFrame) and self.selection is not None:
        self.selected_individuals = solutions[self.selection]
    elif isinstance(solutions, list) and self.selection is not None:
        self.selected_individuals = [solutions[i] for i in self.selection]
    else:
        raise RuntimeError("Something went wrong with the selection")

    self.selected_targets = alltargets[self.selection]
    self.notify()
    return self.selected_individuals, self.selected_targets
state
state() -> Sequence[Message]

Return the operator's state as messages for the current verbosity level.

Source code in desdeo/emo/operators/selection.py
def state(self) -> Sequence[Message]:
    """Return the operator's state as messages for the current verbosity level."""
    if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
        return []
    if self.verbosity == 1:
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "selection": self.selection,
                    "removed": self.removed,
                },
                source=self.__class__.__name__,
            )
        ]
    # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "selection": self.selection,
                "removed": self.removed,
            },
            source=self.__class__.__name__,
        ),
        message,
    ]
update
update(message: Message) -> None

Handle an incoming message. This operator does not react to messages.

Source code in desdeo/emo/operators/selection.py
def update(self, message: Message) -> None:
    """Handle an incoming message. This operator does not react to messages."""

_ibea_fitness

_ibea_fitness(
    fitness_components: ndarray, kappa: float
) -> np.ndarray

Calculates the IBEA fitness for each individual based on pairwise fitness components.

Parameters:

Name Type Description Default
fitness_components ndarray

The pairwise fitness components of the individuals.

required
kappa float

The kappa value for the IBEA selection.

required

Returns:

Type Description
ndarray

np.ndarray: The IBEA fitness values for each individual.

Source code in desdeo/emo/operators/selection.py
@njit
def _ibea_fitness(fitness_components: np.ndarray, kappa: float) -> np.ndarray:
    """Calculates the IBEA fitness for each individual based on pairwise fitness components.

    Args:
        fitness_components (np.ndarray): The pairwise fitness components of the individuals.
        kappa (float): The kappa value for the IBEA selection.

    Returns:
        np.ndarray: The IBEA fitness values for each individual.
    """
    num_individuals = fitness_components.shape[0]
    fitness = np.zeros(num_individuals)
    for i in range(num_individuals):
        for j in range(num_individuals):
            if i != j:
                fitness[i] -= np.exp(-fitness_components[j, i] / kappa)
    return fitness

_ibea_select

_ibea_select(
    fitness_components: ndarray,
    bad_sols: ndarray,
    kappa: float,
) -> int

Selects the worst individual based on the IBEA indicator.

Parameters:

Name Type Description Default
fitness_components ndarray

The pairwise fitness components of the individuals.

required
bad_sols ndarray

A boolean array indicating which individuals are considered "bad".

required
kappa float

The kappa value for the IBEA selection.

required

Returns:

Name Type Description
int int

The index of the selected individual.

Source code in desdeo/emo/operators/selection.py
@njit
def _ibea_select(fitness_components: np.ndarray, bad_sols: np.ndarray, kappa: float) -> int:
    """Selects the worst individual based on the IBEA indicator.

    Args:
        fitness_components (np.ndarray): The pairwise fitness components of the individuals.
        bad_sols (np.ndarray): A boolean array indicating which individuals are considered "bad".
        kappa (float): The kappa value for the IBEA selection.

    Returns:
        int: The index of the selected individual.
    """
    fitness = np.zeros(len(fitness_components))
    for i in range(len(fitness_components)):
        if bad_sols[i]:
            continue
        for j in range(len(fitness_components)):
            if bad_sols[j] or i == j:
                continue
            fitness[i] -= np.exp(-fitness_components[j, i] / kappa)
    choice = np.argmin(fitness)
    if fitness[choice] >= 0:
        if sum(bad_sols) == len(fitness_components) - 1:
            # If all but one individual is chosen, select the last one
            return np.where(~bad_sols)[0][0]
        raise RuntimeError("All individuals have non-negative fitness. Cannot select a new individual.")
    return choice

_ibea_select_all

_ibea_select_all(
    fitness_components: ndarray,
    population_size: int,
    kappa: float,
) -> np.ndarray

Selects all individuals based on the IBEA indicator.

Parameters:

Name Type Description Default
fitness_components ndarray

The pairwise fitness components of the individuals.

required
population_size int

The desired size of the population after selection.

required
kappa float

The kappa value for the IBEA selection.

required

Returns:

Type Description
ndarray

list[int]: The list of indices of the selected individuals.

Source code in desdeo/emo/operators/selection.py
@njit
def _ibea_select_all(fitness_components: np.ndarray, population_size: int, kappa: float) -> np.ndarray:
    """Selects all individuals based on the IBEA indicator.

    Args:
        fitness_components (np.ndarray): The pairwise fitness components of the individuals.
        population_size (int): The desired size of the population after selection.
        kappa (float): The kappa value for the IBEA selection.

    Returns:
        list[int]: The list of indices of the selected individuals.
    """
    current_pop_size = len(fitness_components)
    bad_sols = np.zeros(current_pop_size, dtype=np.bool_)
    fitness = np.zeros(len(fitness_components))
    mod_fit_components = np.exp(-fitness_components / kappa)
    for i in range(len(fitness_components)):
        for j in range(len(fitness_components)):
            if i == j:
                continue
            fitness[i] -= mod_fit_components[j, i]
    while current_pop_size - sum(bad_sols) > population_size:
        selected = np.argmin(fitness)
        if fitness[selected] >= 0:
            if sum(bad_sols) == len(fitness_components) - 1:
                # If all but one individual is chosen, select the last one
                selected = np.where(~bad_sols)[0][0]
            raise RuntimeError("All individuals have non-negative fitness. Cannot select a new individual.")
        fitness[selected] = np.inf  # Make sure that this individual is not selected again
        bad_sols[selected] = True
        for i in range(len(mod_fit_components)):
            if bad_sols[i]:
                continue
            # Update fitness of the remaining individuals
            fitness[i] += mod_fit_components[selected, i]
    return ~bad_sols

_nsga2_crowding_distance_assignment

_nsga2_crowding_distance_assignment(
    non_dominated_front: ndarray,
    f_mins: ndarray,
    f_maxs: ndarray,
) -> np.ndarray

Computes the crowding distance as pecified in the definition of NSGA2.

This function computed the crowding distances for a non-dominated set of solutions. A smaller value means that a solution is more crowded (worse), while a larger value means it is less crowded (better).

Note

The boundary point in non_dominated_front will be assigned a non-crowding distance value of np.inf indicating, that they shouls always be included in later sorting.

Parameters:

Name Type Description Default
non_dominated_front ndarray

a 2D numpy array (size n x m = number of vectors x number of targets (obejctive funcitons)) containing mutually non-dominated vectors. The values of the vectors correspond to the optimization 'target' (usually the minimized objective function values.)

required
f_mins ndarray

a 1D numpy array of size m containing the minimum objective function values in non_dominated_front.

required
f_maxs ndarray

a 1D numpy array of size m containing the maximum objective function values in non_dominated_front.

required

Returns:

Type Description
ndarray

np.ndarray: a numpy array of size m containing the crowding distances for each vector in non_dominated_front.

Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.

(2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE transactions on evolutionary computation, 6(2), 182-197.

Source code in desdeo/emo/operators/selection.py
@njit
def _nsga2_crowding_distance_assignment(
    non_dominated_front: np.ndarray, f_mins: np.ndarray, f_maxs: np.ndarray
) -> np.ndarray:
    """Computes the crowding distance as pecified in the definition of NSGA2.

    This function computed the crowding distances for a non-dominated set of solutions.
    A smaller value means that a solution is more crowded (worse), while a larger value means
    it is less crowded (better).

    Note:
        The boundary point in `non_dominated_front` will be assigned a non-crowding
            distance value of `np.inf` indicating, that they shouls always be included
            in later sorting.

    Args:
        non_dominated_front (np.ndarray): a 2D numpy array (size n x m = number
            of vectors x number of targets (obejctive funcitons)) containing
            mutually non-dominated vectors. The values of the vectors correspond to
            the optimization 'target' (usually the minimized objective function
            values.)
        f_mins (np.ndarray): a 1D numpy array of size m containing the minimum objective function
            values in `non_dominated_front`.
        f_maxs (np.ndarray): a 1D numpy array of size m containing the maximum objective function
            values in `non_dominated_front`.

    Returns:
        np.ndarray: a numpy array of size m containing the crowding distances for each vector
            in `non_dominated_front`.

    Reference: Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.
        (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE
        transactions on evolutionary computation, 6(2), 182-197.
    """
    vectors = non_dominated_front  # I
    num_vectors = vectors.shape[0]  # l
    num_objectives = vectors.shape[1]

    crowding_distances = np.zeros(num_vectors)  # I[i]_distance

    for m in range(num_objectives):
        # An objective that is constant across the whole population separates nothing. Sorting by it
        # gives an arbitrary order, so marking its two ends as boundary points would hand an infinite
        # distance to two arbitrary solutions, and the normalisation below would divide by zero.
        if f_maxs[m] == f_mins[m]:
            continue

        # sort by column (objective)
        m_order = vectors[:, m].argsort()
        # inlcude boundary points
        crowding_distances[m_order[0]], crowding_distances[m_order[-1]] = np.inf, np.inf

        for i in range(1, num_vectors - 1):
            crowding_distances[m_order[i]] = crowding_distances[m_order[i]] + (
                vectors[m_order[i + 1], m] - vectors[m_order[i - 1], m]
            ) / (f_maxs[m] - f_mins[m])

    return crowding_distances

_rvea_selection

_rvea_selection(
    fitness: ndarray,
    reference_vectors: ndarray,
    ideal: ndarray,
    partial_penalty: float,
    gamma: ndarray,
) -> tuple[np.ndarray, np.ndarray]

Select individuals based on their fitness and their distance to the reference vectors.

Parameters:

Name Type Description Default
fitness ndarray

The fitness values of the individuals.

required
reference_vectors ndarray

The reference vectors.

required
ideal ndarray

The ideal point.

required
partial_penalty float

The partial penalty in APD.

required
gamma ndarray

The angle between current and closest reference vector.

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.

Source code in desdeo/emo/operators/selection.py
@njit
def _rvea_selection(
    fitness: np.ndarray, reference_vectors: np.ndarray, ideal: np.ndarray, partial_penalty: float, gamma: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """Select individuals based on their fitness and their distance to the reference vectors.

    Args:
        fitness (np.ndarray): The fitness values of the individuals.
        reference_vectors (np.ndarray): The reference vectors.
        ideal (np.ndarray): The ideal point.
        partial_penalty (float): The partial penalty in APD.
        gamma (np.ndarray): The angle between current and closest reference vector.

    Returns:
        tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.
    """
    tranlated_fitness = fitness - ideal
    num_vectors = reference_vectors.shape[0]
    num_solutions = fitness.shape[0]

    cos_matrix = np.zeros((num_solutions, num_vectors))

    for i in range(num_solutions):
        solution = tranlated_fitness[i]
        norm = np.linalg.norm(solution)
        for j in range(num_vectors):
            cos_matrix[i, j] = np.dot(solution, reference_vectors[j]) / max(1e-10, norm)  # Avoid division by zero

    assignment_matrix = np.zeros((num_solutions, num_vectors), dtype=np.bool_)

    for i in range(num_solutions):
        assignment_matrix[i, np.argmax(cos_matrix[i])] = True

    selection = np.zeros(num_solutions, dtype=np.bool_)
    apd_fitness = np.zeros(num_solutions, dtype=np.float64)

    for j in range(num_vectors):
        min_apd = np.inf
        select = -1
        for i in np.where(assignment_matrix[:, j])[0]:
            solution = tranlated_fitness[i]
            apd = (1 + (partial_penalty * np.arccos(cos_matrix[i, j]) / gamma[j])) * np.linalg.norm(solution)
            apd_fitness[i] = apd
            if apd < min_apd:
                min_apd = apd
                select = i
        # A reference vector with no associated solution leaves select at -1. Guarding here is
        # essential: selection[-1] = True would silently promote the *last* individual in the array,
        # once per empty vector. Empty vectors are common, and become commoner as the number of
        # objectives grows, so the unguarded write biases selection exactly where RVEA is used most.
        if select != -1:
            selection[select] = True

    return selection, apd_fitness

_rvea_selection_constrained

_rvea_selection_constrained(
    fitness: ndarray,
    constraints: ndarray,
    reference_vectors: ndarray,
    ideal: ndarray,
    partial_penalty: float,
    gamma: ndarray,
) -> tuple[np.ndarray, np.ndarray]

Select individuals based on their fitness and their distance to the reference vectors.

Parameters:

Name Type Description Default
fitness ndarray

The fitness values of the individuals.

required
constraints ndarray

The constraint violations of the individuals.

required
reference_vectors ndarray

The reference vectors.

required
ideal ndarray

The ideal point.

required
partial_penalty float

The partial penalty in APD.

required
gamma ndarray

The angle between current and closest reference vector.

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.

Source code in desdeo/emo/operators/selection.py
@njit
def _rvea_selection_constrained(
    fitness: np.ndarray,
    constraints: np.ndarray,
    reference_vectors: np.ndarray,
    ideal: np.ndarray,
    partial_penalty: float,
    gamma: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Select individuals based on their fitness and their distance to the reference vectors.

    Args:
        fitness (np.ndarray): The fitness values of the individuals.
        constraints (np.ndarray): The constraint violations of the individuals.
        reference_vectors (np.ndarray): The reference vectors.
        ideal (np.ndarray): The ideal point.
        partial_penalty (float): The partial penalty in APD.
        gamma (np.ndarray): The angle between current and closest reference vector.

    Returns:
        tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.
    """
    tranlated_fitness = fitness - ideal
    num_vectors = reference_vectors.shape[0]
    num_solutions = fitness.shape[0]

    violations = np.maximum(0, constraints)

    cos_matrix = np.zeros((num_solutions, num_vectors))

    for i in range(num_solutions):
        solution = tranlated_fitness[i]
        norm = np.linalg.norm(solution)
        for j in range(num_vectors):
            cos_matrix[i, j] = np.dot(solution, reference_vectors[j]) / max(1e-10, norm)  # Avoid division by zero

    assignment_matrix = np.zeros((num_solutions, num_vectors), dtype=np.bool_)

    for i in range(num_solutions):
        assignment_matrix[i, np.argmax(cos_matrix[i])] = True

    selection = np.zeros(num_solutions, dtype=np.bool_)
    apd_fitness = np.zeros(num_solutions, dtype=np.float64)

    for j in range(num_vectors):
        min_apd = np.inf
        min_violation = np.inf
        select = -1
        select_violation = -1
        for i in np.where(assignment_matrix[:, j])[0]:
            solution = tranlated_fitness[i]
            apd = (1 + (partial_penalty * np.arccos(cos_matrix[i, j]) / gamma[j])) * np.linalg.norm(solution)
            apd_fitness[i] = apd
            feasible = np.all(violations[i] == 0)
            current_violation = np.sum(violations[i])
            if feasible:
                if apd < min_apd:
                    min_apd = apd
                    select = i
            elif current_violation < min_violation:
                min_violation = current_violation
                select_violation = i
        if select != -1:
            selection[select] = True
        elif select_violation != -1:
            # Guarding this branch matters as much as the one above: a reference vector with no
            # associated solution at all leaves both indices at -1, and selection[-1] = True would
            # silently promote the last individual in the array, once per empty vector.
            selection[select_violation] = True

    return selection, apd_fitness

jitted_calc_perpendicular_distance

jitted_calc_perpendicular_distance(
    solutions: ndarray,
    ref_dirs: ndarray,
    invert_reference_vectors: bool,
) -> np.ndarray

Calculate the perpendicular distance between solutions and reference directions.

Parameters:

Name Type Description Default
solutions ndarray

The normalized solutions.

required
ref_dirs ndarray

The reference directions.

required
invert_reference_vectors bool

Whether to invert the reference vectors.

required

Returns:

Type Description
ndarray

np.ndarray: The perpendicular distance matrix.

Source code in desdeo/emo/operators/selection.py
@njit
def jitted_calc_perpendicular_distance(
    solutions: np.ndarray, ref_dirs: np.ndarray, invert_reference_vectors: bool
) -> np.ndarray:
    """Calculate the perpendicular distance between solutions and reference directions.

    Args:
        solutions (np.ndarray): The normalized solutions.
        ref_dirs (np.ndarray): The reference directions.
        invert_reference_vectors (bool): Whether to invert the reference vectors.

    Returns:
        np.ndarray: The perpendicular distance matrix.
    """
    matrix = np.zeros((solutions.shape[0], ref_dirs.shape[0]))
    for i in range(ref_dirs.shape[0]):
        for j in range(solutions.shape[0]):
            if invert_reference_vectors:
                unit_vector = 1 - ref_dirs[i]
                unit_vector = -unit_vector / np.linalg.norm(unit_vector)
            else:
                unit_vector = ref_dirs[i] / np.linalg.norm(ref_dirs[i])
            component = ref_dirs[i] - solutions[j] - np.dot(ref_dirs[i] - solutions[j], unit_vector) * unit_vector
            matrix[j, i] = np.linalg.norm(component)
    return matrix

total_constraint_violation

total_constraint_violation(
    outputs: DataFrame,
    constraint_symbols: Sequence[str] | None,
) -> np.ndarray | None

Sum each solution's constraint violations, following DESDEO's positive-means-violated convention.

Parameters:

Name Type Description Default
outputs DataFrame

The objective values, targets, and constraint values of a population.

required
constraint_symbols Sequence[str] | None

The constraint columns to read, or None for an unconstrained problem.

required

Returns:

Type Description
ndarray | None

np.ndarray | None: A 1-D array of total violations, one per row, zero where feasible. None if the problem has no constraints, which lets callers keep the unconstrained code path.

Source code in desdeo/emo/operators/selection.py
def total_constraint_violation(outputs: pl.DataFrame, constraint_symbols: Sequence[str] | None) -> np.ndarray | None:
    """Sum each solution's constraint violations, following DESDEO's positive-means-violated convention.

    Args:
        outputs (pl.DataFrame): The objective values, targets, and constraint values of a population.
        constraint_symbols (Sequence[str] | None): The constraint columns to read, or None for an
            unconstrained problem.

    Returns:
        np.ndarray | None: A 1-D array of total violations, one per row, zero where feasible. None if
            the problem has no constraints, which lets callers keep the unconstrained code path.
    """
    if not constraint_symbols:
        return None
    values = outputs[list(constraint_symbols)].to_numpy()
    return np.maximum(values, 0.0).sum(axis=1)

Scalar selection operators

desdeo.emo.operators.scalar_selection

Classs for scalar selection operators.

BaseScalarSelector

Bases: Subscriber

A base class for selection operators.

Source code in desdeo/emo/operators/scalar_selection.py
class BaseScalarSelector(Subscriber):
    """A base class for selection operators."""

    @property
    def provided_topics(self):
        """The message topics provided by the selection operator."""
        return {
            0: [],
            1: [],
            2: [],
        }

    @property
    def interested_topics(self):
        """The message topics that the selection operator is interested in."""
        return [
            SelectorMessageTopics.SELECTED_FITNESS,
        ]

    def __init__(self, verbosity: int, publisher: Publisher):
        """Initialize a selection operator."""
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.fitness: np.ndarray | None = None

    @abstractmethod
    def _do(
        self,
        solutions: tuple[SolutionType, pl.DataFrame],
        fitness: np.ndarray | None = None,
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
                calculated from the messages sent by the publisher.

        Returns:
            SolutionType: The selected decision variables.
        """

    def do(
        self,
        solutions: tuple[SolutionType, pl.DataFrame],
        fitness: np.ndarray | None = None,
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the selection operation.

        Args:
            solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
                The second element is the objective values, targets, and constraint violations.
            fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
                calculated from the messages sent by the publisher.

        Returns:
            SolutionType: The selected decision variables.
        """
        if fitness is not None and self.fitness is not None:
            raise RuntimeError("The fitness is being set twice.")
        if fitness is None and self.fitness is None:
            raise RuntimeError(
                "The fitness is not set. Either pass it as an argument or make sure the publisher sends it."
            )
        if fitness is None:
            fitness = self.fitness
        if len(fitness) != len(solutions[0]):
            raise ValueError(
                f"The length of the fitness array ({len(fitness)}) does not match"
                f" the number of solutions ({len(solutions[0])})."
            )
        returnval = self._do(solutions, fitness)
        self.fitness = None  # Reset fitness after selection
        return returnval

    def update(self, message: Message) -> None:
        """Update the operator with a message.

        Args:
            message (Message): The message to update the operator with.
        """
        if message.topic == SelectorMessageTopics.SELECTED_FITNESS and isinstance(message.value, np.ndarray):
            self.fitness = message.value
        else:
            raise ValueError(f"Unknown message topic: {message.topic}")

    def state(self) -> Sequence[Message]:
        """Return the state of the selection operator."""
        return []
interested_topics property
interested_topics

The message topics that the selection operator is interested in.

provided_topics property
provided_topics

The message topics provided by the selection operator.

__init__
__init__(verbosity: int, publisher: Publisher)

Initialize a selection operator.

Source code in desdeo/emo/operators/scalar_selection.py
def __init__(self, verbosity: int, publisher: Publisher):
    """Initialize a selection operator."""
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.fitness: np.ndarray | None = None
_do abstractmethod
_do(
    solutions: tuple[SolutionType, DataFrame],
    fitness: ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
solutions tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
fitness ndarray | None

The fitness values of the solutions. If None, the fitness is calculated from the messages sent by the publisher.

None

Returns:

Name Type Description
SolutionType tuple[SolutionType, DataFrame]

The selected decision variables.

Source code in desdeo/emo/operators/scalar_selection.py
@abstractmethod
def _do(
    self,
    solutions: tuple[SolutionType, pl.DataFrame],
    fitness: np.ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
            calculated from the messages sent by the publisher.

    Returns:
        SolutionType: The selected decision variables.
    """
do
do(
    solutions: tuple[SolutionType, DataFrame],
    fitness: ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]

Perform the selection operation.

Parameters:

Name Type Description Default
solutions tuple[SolutionType, DataFrame]

the decision variables as the first element. The second element is the objective values, targets, and constraint violations.

required
fitness ndarray | None

The fitness values of the solutions. If None, the fitness is calculated from the messages sent by the publisher.

None

Returns:

Name Type Description
SolutionType tuple[SolutionType, DataFrame]

The selected decision variables.

Source code in desdeo/emo/operators/scalar_selection.py
def do(
    self,
    solutions: tuple[SolutionType, pl.DataFrame],
    fitness: np.ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the selection operation.

    Args:
        solutions (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
            The second element is the objective values, targets, and constraint violations.
        fitness (np.ndarray | None, optional): The fitness values of the solutions. If None, the fitness is
            calculated from the messages sent by the publisher.

    Returns:
        SolutionType: The selected decision variables.
    """
    if fitness is not None and self.fitness is not None:
        raise RuntimeError("The fitness is being set twice.")
    if fitness is None and self.fitness is None:
        raise RuntimeError(
            "The fitness is not set. Either pass it as an argument or make sure the publisher sends it."
        )
    if fitness is None:
        fitness = self.fitness
    if len(fitness) != len(solutions[0]):
        raise ValueError(
            f"The length of the fitness array ({len(fitness)}) does not match"
            f" the number of solutions ({len(solutions[0])})."
        )
    returnval = self._do(solutions, fitness)
    self.fitness = None  # Reset fitness after selection
    return returnval
state
state() -> Sequence[Message]

Return the state of the selection operator.

Source code in desdeo/emo/operators/scalar_selection.py
def state(self) -> Sequence[Message]:
    """Return the state of the selection operator."""
    return []
update
update(message: Message) -> None

Update the operator with a message.

Parameters:

Name Type Description Default
message Message

The message to update the operator with.

required
Source code in desdeo/emo/operators/scalar_selection.py
def update(self, message: Message) -> None:
    """Update the operator with a message.

    Args:
        message (Message): The message to update the operator with.
    """
    if message.topic == SelectorMessageTopics.SELECTED_FITNESS and isinstance(message.value, np.ndarray):
        self.fitness = message.value
    else:
        raise ValueError(f"Unknown message topic: {message.topic}")

ElitistSelection

Bases: BaseScalarSelector

Deterministic elitist selection: keep the top winner_size rows by a single output column.

The selector ranks the combined (decision_variables, outputs) tuple by the values of outputs[target_column] (lower is better) and returns the best winner_size rows. No randomness, so the operation is reproducible. Useful as an elitist scheme for any single fitness column already present in the outputs DataFrame, e.g. an achievement scalarizing function value added via desdeo.tools.scalarization.add_asf_nondiff.

Source code in desdeo/emo/operators/scalar_selection.py
class ElitistSelection(BaseScalarSelector):
    """Deterministic elitist selection: keep the top ``winner_size`` rows by a single output column.

    The selector ranks the combined ``(decision_variables, outputs)`` tuple by
    the values of ``outputs[target_column]`` (lower is better) and returns the
    best ``winner_size`` rows. No randomness, so the operation is reproducible.
    Useful as an elitist scheme for any single fitness column already present
    in the outputs DataFrame, e.g. an achievement scalarizing function value
    added via [desdeo.tools.scalarization.add_asf_nondiff][].
    """

    @property
    def provided_topics(self):
        """Topics published by this selector for each verbosity level."""
        return {
            0: [],
            1: [SelectorMessageTopics.STATE],
            2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
        }

    @property
    def interested_topics(self):
        """ElitistSelection reads its fitness column from the outputs DataFrame; no subscriptions needed."""
        return []

    def __init__(
        self,
        *,
        winner_size: int,
        target_column: str,
        verbosity: int,
        publisher: Publisher,
    ):
        """Initialize the elitist scalar selector.

        Args:
            winner_size (int): The number of individuals to keep after selection.
            target_column (str): Name of the output column to sort by (ascending,
                lower is better).
            verbosity (int): Verbosity level for emitted messages.
            publisher (Publisher): Publisher used to emit selection state.
        """
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.winner_size = winner_size
        self.target_column = target_column
        self.selection: list[int] | None = None
        self.selected_individuals: SolutionType | None = None
        self.selected_targets: pl.DataFrame | None = None

    def do(
        self,
        solutions: tuple[SolutionType, pl.DataFrame],
        fitness: np.ndarray | None = None,
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Keep the top ``winner_size`` rows by ``target_column`` (ascending).

        Args:
            solutions (tuple[SolutionType, pl.DataFrame]): Combined parents and
                offspring as a single ``(decision_variables, outputs)`` tuple.
            fitness (np.ndarray | None, optional): Optional fitness override.
                If ``None`` (the usual case), the values in
                ``outputs[target_column]`` are used.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables
                and their outputs.
        """
        decvars, outputs = solutions
        values = fitness if fitness is not None else outputs[self.target_column].to_numpy()
        order = np.argsort(values, kind="stable")
        chosen = order[: self.winner_size].tolist()

        if isinstance(decvars, pl.DataFrame):
            self.selected_individuals = decvars[chosen]
        else:
            self.selected_individuals = [decvars[i] for i in chosen]
        self.selected_targets = outputs[chosen]
        self.selection = chosen
        self.fitness = np.asarray(values)[chosen]

        self.notify()
        return self.selected_individuals, self.selected_targets

    def _do(
        self,
        solutions: tuple[SolutionType, pl.DataFrame],
        fitness: np.ndarray | None = None,
    ) -> tuple[SolutionType, pl.DataFrame]:
        """ABC requirement: delegate to the public `do()`."""
        return self.do(solutions, fitness)

    def state(self) -> Sequence[Message]:
        """Emit the selection state for archivers and other listeners."""
        if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
            return []
        if self.verbosity == 1:
            return [
                DictMessage(
                    topic=SelectorMessageTopics.STATE,
                    value={
                        "winner_size": self.winner_size,
                        "selected_individuals": self.selection,
                    },
                    source=self.__class__.__name__,
                )
            ]
        # verbosity == 2
        if isinstance(self.selected_individuals, pl.DataFrame):
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_individuals.hstack(self.selected_targets),
                source=self.__class__.__name__,
            )
        else:
            warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
            message = PolarsDataFrameMessage(
                topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
                value=self.selected_targets,
                source=self.__class__.__name__,
            )
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "winner_size": self.winner_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            ),
            message,
            NumpyArrayMessage(
                topic=SelectorMessageTopics.SELECTED_FITNESS,
                value=self.fitness,
                source=self.__class__.__name__,
            ),
        ]

    def update(self, message: Message) -> None:
        """ElitistSelection has no subscriptions; ignore any messages."""
interested_topics property
interested_topics

ElitistSelection reads its fitness column from the outputs DataFrame; no subscriptions needed.

provided_topics property
provided_topics

Topics published by this selector for each verbosity level.

__init__
__init__(
    *,
    winner_size: int,
    target_column: str,
    verbosity: int,
    publisher: Publisher,
)

Initialize the elitist scalar selector.

Parameters:

Name Type Description Default
winner_size int

The number of individuals to keep after selection.

required
target_column str

Name of the output column to sort by (ascending, lower is better).

required
verbosity int

Verbosity level for emitted messages.

required
publisher Publisher

Publisher used to emit selection state.

required
Source code in desdeo/emo/operators/scalar_selection.py
def __init__(
    self,
    *,
    winner_size: int,
    target_column: str,
    verbosity: int,
    publisher: Publisher,
):
    """Initialize the elitist scalar selector.

    Args:
        winner_size (int): The number of individuals to keep after selection.
        target_column (str): Name of the output column to sort by (ascending,
            lower is better).
        verbosity (int): Verbosity level for emitted messages.
        publisher (Publisher): Publisher used to emit selection state.
    """
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.winner_size = winner_size
    self.target_column = target_column
    self.selection: list[int] | None = None
    self.selected_individuals: SolutionType | None = None
    self.selected_targets: pl.DataFrame | None = None
_do
_do(
    solutions: tuple[SolutionType, DataFrame],
    fitness: ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]

ABC requirement: delegate to the public do().

Source code in desdeo/emo/operators/scalar_selection.py
def _do(
    self,
    solutions: tuple[SolutionType, pl.DataFrame],
    fitness: np.ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]:
    """ABC requirement: delegate to the public `do()`."""
    return self.do(solutions, fitness)
do
do(
    solutions: tuple[SolutionType, DataFrame],
    fitness: ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]

Keep the top winner_size rows by target_column (ascending).

Parameters:

Name Type Description Default
solutions tuple[SolutionType, DataFrame]

Combined parents and offspring as a single (decision_variables, outputs) tuple.

required
fitness ndarray | None

Optional fitness override. If None (the usual case), the values in outputs[target_column] are used.

None

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their outputs.

Source code in desdeo/emo/operators/scalar_selection.py
def do(
    self,
    solutions: tuple[SolutionType, pl.DataFrame],
    fitness: np.ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]:
    """Keep the top ``winner_size`` rows by ``target_column`` (ascending).

    Args:
        solutions (tuple[SolutionType, pl.DataFrame]): Combined parents and
            offspring as a single ``(decision_variables, outputs)`` tuple.
        fitness (np.ndarray | None, optional): Optional fitness override.
            If ``None`` (the usual case), the values in
            ``outputs[target_column]`` are used.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables
            and their outputs.
    """
    decvars, outputs = solutions
    values = fitness if fitness is not None else outputs[self.target_column].to_numpy()
    order = np.argsort(values, kind="stable")
    chosen = order[: self.winner_size].tolist()

    if isinstance(decvars, pl.DataFrame):
        self.selected_individuals = decvars[chosen]
    else:
        self.selected_individuals = [decvars[i] for i in chosen]
    self.selected_targets = outputs[chosen]
    self.selection = chosen
    self.fitness = np.asarray(values)[chosen]

    self.notify()
    return self.selected_individuals, self.selected_targets
state
state() -> Sequence[Message]

Emit the selection state for archivers and other listeners.

Source code in desdeo/emo/operators/scalar_selection.py
def state(self) -> Sequence[Message]:
    """Emit the selection state for archivers and other listeners."""
    if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
        return []
    if self.verbosity == 1:
        return [
            DictMessage(
                topic=SelectorMessageTopics.STATE,
                value={
                    "winner_size": self.winner_size,
                    "selected_individuals": self.selection,
                },
                source=self.__class__.__name__,
            )
        ]
    # verbosity == 2
    if isinstance(self.selected_individuals, pl.DataFrame):
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_individuals.hstack(self.selected_targets),
            source=self.__class__.__name__,
        )
    else:
        warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
        message = PolarsDataFrameMessage(
            topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            value=self.selected_targets,
            source=self.__class__.__name__,
        )
    return [
        DictMessage(
            topic=SelectorMessageTopics.STATE,
            value={
                "winner_size": self.winner_size,
                "selected_individuals": self.selection,
            },
            source=self.__class__.__name__,
        ),
        message,
        NumpyArrayMessage(
            topic=SelectorMessageTopics.SELECTED_FITNESS,
            value=self.fitness,
            source=self.__class__.__name__,
        ),
    ]
update
update(message: Message) -> None

ElitistSelection has no subscriptions; ignore any messages.

Source code in desdeo/emo/operators/scalar_selection.py
def update(self, message: Message) -> None:
    """ElitistSelection has no subscriptions; ignore any messages."""

TournamentSelection

Bases: BaseScalarSelector

A tournament selection operator.

Source code in desdeo/emo/operators/scalar_selection.py
class TournamentSelection(BaseScalarSelector):
    """A tournament selection operator."""

    def __init__(
        self,
        *,
        winner_size: int,
        verbosity: int,
        publisher: Publisher,
        tournament_size: int = 2,
        seed: int = 0,
        stochastic: bool = False,
        selection_probability: float | None = None,
    ) -> None:
        """Initialize the tournament selection operator.

        Args:
            winner_size (int): The number of winners to select.
            verbosity (int): The verbosity level of the operator.
            publisher (Publisher): The publisher to send messages to.
            tournament_size (int, optional): The size of the tournament. Defaults to 2, which corresponds to binary
                tournament.
            seed (int, optional): The seed for the random number generator, which draws the tournaments and, when
                `stochastic` is set, the winner within each one. Defaults to 0.
            stochastic (bool, optional): If False, the solution with the highest fitness in the tournament always
                wins. If True, the winner is drawn at random, with a probability proportional to fitness unless
                `selection_probability` says otherwise, which is roulette wheel selection. Defaults to False.
            selection_probability (float | None, optional): The probability of selecting a solution in the tournament,
                only meaningful when `stochastic` is set. If None, the probabilities are proportional to the fitness
                values of the solutions in the tournament. If a value p is given, the probability of choosing the
                k-best solution in the tournament is p * (1 - p) ** (k - 1).

        Raises:
            ValueError: If `selection_probability` is given without `stochastic`, which would silently ignore it.

        Note:
            `seed` used to double as the switch between the two rules, so that leaving it unset made the selection
            deterministic *and* seeded the generator from OS entropy. That made a whole run irreproducible even with
            a fixed template seed, so the two are now separate: `seed` only seeds, and `stochastic` only chooses the
            rule.
        """
        super().__init__(verbosity=verbosity, publisher=publisher)
        self.winner_size = winner_size
        self.tournament_size = tournament_size
        self.seed = seed
        self.rng = np.random.default_rng(seed)
        self.stochastic = stochastic
        self.selection_probability = selection_probability
        if not self.stochastic and self.selection_probability is not None:
            raise ValueError("If selection_probability is provided, stochastic must be True for it to have an effect.")

    @staticmethod
    def deterministic_select(indices: np.ndarray, fitness: np.ndarray) -> int:
        """Select the index of the solution with the highest fitness from the given indices.

        Args:
            indices (np.ndarray): The indices of the solutions to select from.
            fitness (np.ndarray): The fitness values of the solutions.

        Returns:
            int: The index of the solution with the highest fitness.
        """
        return indices[np.argmax(fitness)]

    def stochastic_select(self, indices: np.ndarray, fitness: np.ndarray) -> int:
        """Select the index of the solution with a probability proportional to its fitness from the given indices.

        Args:
            indices (np.ndarray): The indices of the solutions to select from.
            fitness (np.ndarray): The fitness values of the solutions.

        Returns:
            int: The index of the selected solution.
        """
        if self.selection_probability is None:
            # Fitness can sum to zero (e.g. all-zero fitness), giving 0/0; the resulting nan is acceptable.
            with np.errstate(divide="ignore", invalid="ignore"):
                probabilities = fitness / np.sum(fitness)
            probabilities = np.cumsum(probabilities)
        else:
            indices = indices[np.argsort(fitness)[::-1]]  # Sort indices by fitness in descending order
            probabilities = np.array(
                [self.selection_probability * (1 - self.selection_probability) ** i for i in range(len(indices))]
            )
        random_value = self.rng.random()
        selected_index = np.searchsorted(probabilities, random_value)
        return indices[selected_index]

    def _do(
        self, solutions: tuple[SolutionType, pl.DataFrame], fitness: np.ndarray | None = None
    ) -> tuple[SolutionType, pl.DataFrame]:
        """Perform the tournament selection operation.

        Args:
            solutions (tuple[SolutionType, pl.DataFrame]): The decision variables and their outputs.
            fitness (np.ndarray | None, optional): The fitness values of the solutions.

        Returns:
            tuple[SolutionType, pl.DataFrame]: The selected decision variables and their outputs.
        """
        selected_indices = np.zeros(self.winner_size, dtype=int)
        for i in range(self.winner_size):
            tournament_indices = self.rng.choice(range(len(solutions[0])), size=self.tournament_size, replace=True)
            if self.stochastic:
                selected_indices[i] = self.stochastic_select(tournament_indices, fitness[tournament_indices])
            else:
                selected_indices[i] = self.deterministic_select(tournament_indices, fitness[tournament_indices])
        selected_solutions = solutions[0][selected_indices]
        selected_outputs = solutions[1][selected_indices]
        return selected_solutions, selected_outputs
__init__
__init__(
    *,
    winner_size: int,
    verbosity: int,
    publisher: Publisher,
    tournament_size: int = 2,
    seed: int = 0,
    stochastic: bool = False,
    selection_probability: float | None = None,
) -> None

Initialize the tournament selection operator.

Parameters:

Name Type Description Default
winner_size int

The number of winners to select.

required
verbosity int

The verbosity level of the operator.

required
publisher Publisher

The publisher to send messages to.

required
tournament_size int

The size of the tournament. Defaults to 2, which corresponds to binary tournament.

2
seed int

The seed for the random number generator, which draws the tournaments and, when stochastic is set, the winner within each one. Defaults to 0.

0
stochastic bool

If False, the solution with the highest fitness in the tournament always wins. If True, the winner is drawn at random, with a probability proportional to fitness unless selection_probability says otherwise, which is roulette wheel selection. Defaults to False.

False
selection_probability float | None

The probability of selecting a solution in the tournament, only meaningful when stochastic is set. If None, the probabilities are proportional to the fitness values of the solutions in the tournament. If a value p is given, the probability of choosing the k-best solution in the tournament is p * (1 - p) ** (k - 1).

None

Raises:

Type Description
ValueError

If selection_probability is given without stochastic, which would silently ignore it.

Note

seed used to double as the switch between the two rules, so that leaving it unset made the selection deterministic and seeded the generator from OS entropy. That made a whole run irreproducible even with a fixed template seed, so the two are now separate: seed only seeds, and stochastic only chooses the rule.

Source code in desdeo/emo/operators/scalar_selection.py
def __init__(
    self,
    *,
    winner_size: int,
    verbosity: int,
    publisher: Publisher,
    tournament_size: int = 2,
    seed: int = 0,
    stochastic: bool = False,
    selection_probability: float | None = None,
) -> None:
    """Initialize the tournament selection operator.

    Args:
        winner_size (int): The number of winners to select.
        verbosity (int): The verbosity level of the operator.
        publisher (Publisher): The publisher to send messages to.
        tournament_size (int, optional): The size of the tournament. Defaults to 2, which corresponds to binary
            tournament.
        seed (int, optional): The seed for the random number generator, which draws the tournaments and, when
            `stochastic` is set, the winner within each one. Defaults to 0.
        stochastic (bool, optional): If False, the solution with the highest fitness in the tournament always
            wins. If True, the winner is drawn at random, with a probability proportional to fitness unless
            `selection_probability` says otherwise, which is roulette wheel selection. Defaults to False.
        selection_probability (float | None, optional): The probability of selecting a solution in the tournament,
            only meaningful when `stochastic` is set. If None, the probabilities are proportional to the fitness
            values of the solutions in the tournament. If a value p is given, the probability of choosing the
            k-best solution in the tournament is p * (1 - p) ** (k - 1).

    Raises:
        ValueError: If `selection_probability` is given without `stochastic`, which would silently ignore it.

    Note:
        `seed` used to double as the switch between the two rules, so that leaving it unset made the selection
        deterministic *and* seeded the generator from OS entropy. That made a whole run irreproducible even with
        a fixed template seed, so the two are now separate: `seed` only seeds, and `stochastic` only chooses the
        rule.
    """
    super().__init__(verbosity=verbosity, publisher=publisher)
    self.winner_size = winner_size
    self.tournament_size = tournament_size
    self.seed = seed
    self.rng = np.random.default_rng(seed)
    self.stochastic = stochastic
    self.selection_probability = selection_probability
    if not self.stochastic and self.selection_probability is not None:
        raise ValueError("If selection_probability is provided, stochastic must be True for it to have an effect.")
_do
_do(
    solutions: tuple[SolutionType, DataFrame],
    fitness: ndarray | None = None,
) -> tuple[SolutionType, pl.DataFrame]

Perform the tournament selection operation.

Parameters:

Name Type Description Default
solutions tuple[SolutionType, DataFrame]

The decision variables and their outputs.

required
fitness ndarray | None

The fitness values of the solutions.

None

Returns:

Type Description
tuple[SolutionType, DataFrame]

tuple[SolutionType, pl.DataFrame]: The selected decision variables and their outputs.

Source code in desdeo/emo/operators/scalar_selection.py
def _do(
    self, solutions: tuple[SolutionType, pl.DataFrame], fitness: np.ndarray | None = None
) -> tuple[SolutionType, pl.DataFrame]:
    """Perform the tournament selection operation.

    Args:
        solutions (tuple[SolutionType, pl.DataFrame]): The decision variables and their outputs.
        fitness (np.ndarray | None, optional): The fitness values of the solutions.

    Returns:
        tuple[SolutionType, pl.DataFrame]: The selected decision variables and their outputs.
    """
    selected_indices = np.zeros(self.winner_size, dtype=int)
    for i in range(self.winner_size):
        tournament_indices = self.rng.choice(range(len(solutions[0])), size=self.tournament_size, replace=True)
        if self.stochastic:
            selected_indices[i] = self.stochastic_select(tournament_indices, fitness[tournament_indices])
        else:
            selected_indices[i] = self.deterministic_select(tournament_indices, fitness[tournament_indices])
    selected_solutions = solutions[0][selected_indices]
    selected_outputs = solutions[1][selected_indices]
    return selected_solutions, selected_outputs
deterministic_select staticmethod
deterministic_select(
    indices: ndarray, fitness: ndarray
) -> int

Select the index of the solution with the highest fitness from the given indices.

Parameters:

Name Type Description Default
indices ndarray

The indices of the solutions to select from.

required
fitness ndarray

The fitness values of the solutions.

required

Returns:

Name Type Description
int int

The index of the solution with the highest fitness.

Source code in desdeo/emo/operators/scalar_selection.py
@staticmethod
def deterministic_select(indices: np.ndarray, fitness: np.ndarray) -> int:
    """Select the index of the solution with the highest fitness from the given indices.

    Args:
        indices (np.ndarray): The indices of the solutions to select from.
        fitness (np.ndarray): The fitness values of the solutions.

    Returns:
        int: The index of the solution with the highest fitness.
    """
    return indices[np.argmax(fitness)]
stochastic_select
stochastic_select(
    indices: ndarray, fitness: ndarray
) -> int

Select the index of the solution with a probability proportional to its fitness from the given indices.

Parameters:

Name Type Description Default
indices ndarray

The indices of the solutions to select from.

required
fitness ndarray

The fitness values of the solutions.

required

Returns:

Name Type Description
int int

The index of the selected solution.

Source code in desdeo/emo/operators/scalar_selection.py
def stochastic_select(self, indices: np.ndarray, fitness: np.ndarray) -> int:
    """Select the index of the solution with a probability proportional to its fitness from the given indices.

    Args:
        indices (np.ndarray): The indices of the solutions to select from.
        fitness (np.ndarray): The fitness values of the solutions.

    Returns:
        int: The index of the selected solution.
    """
    if self.selection_probability is None:
        # Fitness can sum to zero (e.g. all-zero fitness), giving 0/0; the resulting nan is acceptable.
        with np.errstate(divide="ignore", invalid="ignore"):
            probabilities = fitness / np.sum(fitness)
        probabilities = np.cumsum(probabilities)
    else:
        indices = indices[np.argsort(fitness)[::-1]]  # Sort indices by fitness in descending order
        probabilities = np.array(
            [self.selection_probability * (1 - self.selection_probability) ** i for i in range(len(indices))]
        )
    random_value = self.rng.random()
    selected_index = np.searchsorted(probabilities, random_value)
    return indices[selected_index]

Termination criteria

desdeo.emo.operators.termination

The base class for termination criteria.

The termination criterion is used to determine when the optimization process should stop. In this implementation, it also includes a simple counter for the number of elapsed generations. This counter is increased by one each time the termination criterion is called. The simplest termination criterion is reaching the maximum number of generations. The implementation also contains a counter for the number of evaluations. This counter is updated by the Evaluator and Generator classes. The termination criterion can be based on the number of evaluations as well.

Warning

Each subclass of BaseTerminator must implement the do method. The do method should always call the super().do method to increment the generation counter before conducting the termination check.

BaseTerminator

Bases: Subscriber

The base class for the termination criteria.

Also includes a simple counter for number of elapsed generations. This counter is increased by one each time the termination criterion is called.

Source code in desdeo/emo/operators/termination.py
class BaseTerminator(Subscriber):
    """The base class for the termination criteria.

    Also includes a simple counter for number of elapsed generations. This counter is increased by one each time the
    termination criterion is called.
    """

    @property
    def provided_topics(self) -> dict[int, Sequence[TerminatorMessageTopics]]:
        """Return the topics provided by the terminator.

        Returns:
            dict[int, Sequence[TerminatorMessageTopics]]: The topics provided by the terminator.
        """
        return {
            0: [],
            1: [
                TerminatorMessageTopics.GENERATION,
                TerminatorMessageTopics.EVALUATION,
                TerminatorMessageTopics.MAX_GENERATIONS,
                TerminatorMessageTopics.MAX_EVALUATIONS,
            ],
        }

    @property
    def interested_topics(self):
        """Return the message topics that the terminator is interested in."""
        return [EvaluatorMessageTopics.NEW_EVALUATIONS]

    def __init__(self, publisher: Publisher):
        """Initialize a termination criterion."""
        super().__init__(publisher=publisher, verbosity=1)
        self.current_generation: int = 1
        self.current_evaluations: int = 0
        self.max_generations: int = 0
        self.max_evaluations: int = 0

    def check(self) -> bool:
        """Check if the termination criterion is reached.

        Returns:
            bool: True if the termination criterion is reached, False otherwise.
        """
        self.current_generation += 1
        self.notify()

    def state(self) -> Sequence[Message]:
        """Return the state of the termination criterion."""
        state = [
            IntMessage(
                topic=TerminatorMessageTopics.GENERATION,
                value=self.current_generation,
                source=self.__class__.__name__,
            ),
            IntMessage(
                topic=TerminatorMessageTopics.EVALUATION, value=self.current_evaluations, source=self.__class__.__name__
            ),
        ]
        if self.max_evaluations != 0:
            state.append(
                IntMessage(
                    topic=TerminatorMessageTopics.MAX_EVALUATIONS,
                    value=self.max_evaluations,
                    source=self.__class__.__name__,
                )
            )
        if self.max_generations != 0:
            state.append(
                IntMessage(
                    topic=TerminatorMessageTopics.MAX_GENERATIONS,
                    value=self.max_generations,
                    source=self.__class__.__name__,
                )
            )
        return state

    def update(self, message: Message) -> None:
        """Update the number of evaluations.

        Note that for this method to work, this class must be registered as an observer of a subject that sends
        messages with the key "num_evaluations". The Evaluator class does this.

        Args:
            message (dict): the message from the subject, must contain the key "num_evaluations".
        """
        if not isinstance(message, IntMessage):
            return
        if not (isinstance(message.topic, (EvaluatorMessageTopics, GeneratorMessageTopics))):
            return
        if (
            message.topic == EvaluatorMessageTopics.NEW_EVALUATIONS  # NOQA: PLR1714
            or message.topic == GeneratorMessageTopics.NEW_EVALUATIONS
        ):
            self.current_evaluations += message.value
interested_topics property
interested_topics

Return the message topics that the terminator is interested in.

provided_topics property
provided_topics: dict[
    int, Sequence[TerminatorMessageTopics]
]

Return the topics provided by the terminator.

Returns:

Type Description
dict[int, Sequence[TerminatorMessageTopics]]

dict[int, Sequence[TerminatorMessageTopics]]: The topics provided by the terminator.

__init__
__init__(publisher: Publisher)

Initialize a termination criterion.

Source code in desdeo/emo/operators/termination.py
def __init__(self, publisher: Publisher):
    """Initialize a termination criterion."""
    super().__init__(publisher=publisher, verbosity=1)
    self.current_generation: int = 1
    self.current_evaluations: int = 0
    self.max_generations: int = 0
    self.max_evaluations: int = 0
check
check() -> bool

Check if the termination criterion is reached.

Returns:

Name Type Description
bool bool

True if the termination criterion is reached, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination criterion is reached.

    Returns:
        bool: True if the termination criterion is reached, False otherwise.
    """
    self.current_generation += 1
    self.notify()
state
state() -> Sequence[Message]

Return the state of the termination criterion.

Source code in desdeo/emo/operators/termination.py
def state(self) -> Sequence[Message]:
    """Return the state of the termination criterion."""
    state = [
        IntMessage(
            topic=TerminatorMessageTopics.GENERATION,
            value=self.current_generation,
            source=self.__class__.__name__,
        ),
        IntMessage(
            topic=TerminatorMessageTopics.EVALUATION, value=self.current_evaluations, source=self.__class__.__name__
        ),
    ]
    if self.max_evaluations != 0:
        state.append(
            IntMessage(
                topic=TerminatorMessageTopics.MAX_EVALUATIONS,
                value=self.max_evaluations,
                source=self.__class__.__name__,
            )
        )
    if self.max_generations != 0:
        state.append(
            IntMessage(
                topic=TerminatorMessageTopics.MAX_GENERATIONS,
                value=self.max_generations,
                source=self.__class__.__name__,
            )
        )
    return state
update
update(message: Message) -> None

Update the number of evaluations.

Note that for this method to work, this class must be registered as an observer of a subject that sends messages with the key "num_evaluations". The Evaluator class does this.

Parameters:

Name Type Description Default
message dict

the message from the subject, must contain the key "num_evaluations".

required
Source code in desdeo/emo/operators/termination.py
def update(self, message: Message) -> None:
    """Update the number of evaluations.

    Note that for this method to work, this class must be registered as an observer of a subject that sends
    messages with the key "num_evaluations". The Evaluator class does this.

    Args:
        message (dict): the message from the subject, must contain the key "num_evaluations".
    """
    if not isinstance(message, IntMessage):
        return
    if not (isinstance(message.topic, (EvaluatorMessageTopics, GeneratorMessageTopics))):
        return
    if (
        message.topic == EvaluatorMessageTopics.NEW_EVALUATIONS  # NOQA: PLR1714
        or message.topic == GeneratorMessageTopics.NEW_EVALUATIONS
    ):
        self.current_evaluations += message.value

CompositeTerminator

Bases: BaseTerminator

Combines multiple terminators using logical AND or OR.

Source code in desdeo/emo/operators/termination.py
class CompositeTerminator(BaseTerminator):
    """Combines multiple terminators using logical AND or OR."""

    def __init__(self, terminators: list[BaseTerminator], publisher: Publisher, mode: str = "any"):
        """Initialize a composite termination criterion.

        Args:
            terminators (list[BaseTerminator]): List of BaseTerminator instances.
            publisher (Publisher): Publisher for passing messages.
            mode (str): "any" (terminate if any) or "all" (terminate if all). By default, "any".
        """
        super().__init__(publisher=publisher)
        self.terminators = terminators
        for t in self.terminators:
            t.notify = lambda: None  # Reset the notify method so that individual terminators do not send notifications
        types = [type(t) for t in self.terminators]
        # Assert that all terminators are unique
        if len(types) != len(set(types)):
            raise ValueError("All terminators must be unique.")
        max_generations = [t.max_generations for t in self.terminators if isinstance(t, MaxGenerationsTerminator)]
        if max_generations:
            self.max_generations = max(max_generations)
        max_evaluations = [t.max_evaluations for t in self.terminators if isinstance(t, MaxEvaluationsTerminator)]
        if max_evaluations:
            self.max_evaluations = max(max_evaluations)
        self.mode = mode

    def check(self) -> bool:
        """Check if the termination criterion is reached.

        Returns:
            bool: True if the termination criterion is reached, False otherwise.
        """
        super().check()
        results = [t.check() for t in self.terminators]
        if self.mode == "all":
            return all(results)
        return any(results)
__init__
__init__(
    terminators: list[BaseTerminator],
    publisher: Publisher,
    mode: str = "any",
)

Initialize a composite termination criterion.

Parameters:

Name Type Description Default
terminators list[BaseTerminator]

List of BaseTerminator instances.

required
publisher Publisher

Publisher for passing messages.

required
mode str

"any" (terminate if any) or "all" (terminate if all). By default, "any".

'any'
Source code in desdeo/emo/operators/termination.py
def __init__(self, terminators: list[BaseTerminator], publisher: Publisher, mode: str = "any"):
    """Initialize a composite termination criterion.

    Args:
        terminators (list[BaseTerminator]): List of BaseTerminator instances.
        publisher (Publisher): Publisher for passing messages.
        mode (str): "any" (terminate if any) or "all" (terminate if all). By default, "any".
    """
    super().__init__(publisher=publisher)
    self.terminators = terminators
    for t in self.terminators:
        t.notify = lambda: None  # Reset the notify method so that individual terminators do not send notifications
    types = [type(t) for t in self.terminators]
    # Assert that all terminators are unique
    if len(types) != len(set(types)):
        raise ValueError("All terminators must be unique.")
    max_generations = [t.max_generations for t in self.terminators if isinstance(t, MaxGenerationsTerminator)]
    if max_generations:
        self.max_generations = max(max_generations)
    max_evaluations = [t.max_evaluations for t in self.terminators if isinstance(t, MaxEvaluationsTerminator)]
    if max_evaluations:
        self.max_evaluations = max(max_evaluations)
    self.mode = mode
check
check() -> bool

Check if the termination criterion is reached.

Returns:

Name Type Description
bool bool

True if the termination criterion is reached, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination criterion is reached.

    Returns:
        bool: True if the termination criterion is reached, False otherwise.
    """
    super().check()
    results = [t.check() for t in self.terminators]
    if self.mode == "all":
        return all(results)
    return any(results)

ExternalCheckTerminator

Bases: BaseTerminator

A termination criterion that checks an external condition.

Source code in desdeo/emo/operators/termination.py
class ExternalCheckTerminator(BaseTerminator):
    """A termination criterion that checks an external condition."""

    def __init__(self, check_function, publisher: Publisher):
        """Initialize the external check terminator.

        Args:
            check_function (callable): A function that returns True if the termination condition is met.
            publisher (Publisher): The publisher to send messages to.
        """
        super().__init__(publisher=publisher)
        self.check_function = check_function

    def check(self) -> bool:
        """Check if the termination condition is met.

        Returns:
            bool: True if the termination condition is met, False otherwise.
        """
        super().check()
        self.notify()
        return self.check_function()
__init__
__init__(check_function, publisher: Publisher)

Initialize the external check terminator.

Parameters:

Name Type Description Default
check_function callable

A function that returns True if the termination condition is met.

required
publisher Publisher

The publisher to send messages to.

required
Source code in desdeo/emo/operators/termination.py
def __init__(self, check_function, publisher: Publisher):
    """Initialize the external check terminator.

    Args:
        check_function (callable): A function that returns True if the termination condition is met.
        publisher (Publisher): The publisher to send messages to.
    """
    super().__init__(publisher=publisher)
    self.check_function = check_function
check
check() -> bool

Check if the termination condition is met.

Returns:

Name Type Description
bool bool

True if the termination condition is met, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination condition is met.

    Returns:
        bool: True if the termination condition is met, False otherwise.
    """
    super().check()
    self.notify()
    return self.check_function()

MaxEvaluationsTerminator

Bases: BaseTerminator

A class for a termination criterion based on the number of evaluations.

Source code in desdeo/emo/operators/termination.py
class MaxEvaluationsTerminator(BaseTerminator):
    """A class for a termination criterion based on the number of evaluations."""

    def __init__(self, max_evaluations: int, publisher: Publisher):
        """Initialize a termination criterion based on the number of evaluations.

        Looks for messages with key "num_evaluations" to update the number of evaluations.

        Args:
            max_evaluations (int): the maximum number of evaluations.
            publisher (Publisher): The publisher to which the terminator will publish its state.
                publisher must be passed. See the Subscriber class for more information.
        """
        super().__init__(publisher=publisher)
        if not isinstance(max_evaluations, int) or max_evaluations < 0:
            raise ValueError("max_evaluations must be a non-negative integer")
        self.max_evaluations = max_evaluations
        self.current_evaluations = 0

    def check(self) -> bool:
        """Check if the termination criterion based on the number of generations is reached.

        Returns:
            bool: True if the termination criterion is reached, False otherwise.
        """
        super().check()
        self.notify()
        return self.current_evaluations >= self.max_evaluations
__init__
__init__(max_evaluations: int, publisher: Publisher)

Initialize a termination criterion based on the number of evaluations.

Looks for messages with key "num_evaluations" to update the number of evaluations.

Parameters:

Name Type Description Default
max_evaluations int

the maximum number of evaluations.

required
publisher Publisher

The publisher to which the terminator will publish its state. publisher must be passed. See the Subscriber class for more information.

required
Source code in desdeo/emo/operators/termination.py
def __init__(self, max_evaluations: int, publisher: Publisher):
    """Initialize a termination criterion based on the number of evaluations.

    Looks for messages with key "num_evaluations" to update the number of evaluations.

    Args:
        max_evaluations (int): the maximum number of evaluations.
        publisher (Publisher): The publisher to which the terminator will publish its state.
            publisher must be passed. See the Subscriber class for more information.
    """
    super().__init__(publisher=publisher)
    if not isinstance(max_evaluations, int) or max_evaluations < 0:
        raise ValueError("max_evaluations must be a non-negative integer")
    self.max_evaluations = max_evaluations
    self.current_evaluations = 0
check
check() -> bool

Check if the termination criterion based on the number of generations is reached.

Returns:

Name Type Description
bool bool

True if the termination criterion is reached, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination criterion based on the number of generations is reached.

    Returns:
        bool: True if the termination criterion is reached, False otherwise.
    """
    super().check()
    self.notify()
    return self.current_evaluations >= self.max_evaluations

MaxGenerationsTerminator

Bases: BaseTerminator

A class for a termination criterion based on the number of generations.

Source code in desdeo/emo/operators/termination.py
class MaxGenerationsTerminator(BaseTerminator):
    """A class for a termination criterion based on the number of generations."""

    def __init__(self, max_generations: int, publisher: Publisher):
        """Initialize a termination criterion based on the number of generations.

        Args:
            max_generations (int): the maximum number of generations.
            publisher (Publisher): The publisher to which the terminator will publish its state.
        """
        super().__init__(publisher=publisher)
        self.max_generations = max_generations

    def check(self) -> bool:
        """Check if the termination criterion based on the number of generations is reached.

        Returns:
            bool: True if the termination criterion is reached, False otherwise.
        """
        super().check()
        self.notify()
        return self.current_generation > self.max_generations
__init__
__init__(max_generations: int, publisher: Publisher)

Initialize a termination criterion based on the number of generations.

Parameters:

Name Type Description Default
max_generations int

the maximum number of generations.

required
publisher Publisher

The publisher to which the terminator will publish its state.

required
Source code in desdeo/emo/operators/termination.py
def __init__(self, max_generations: int, publisher: Publisher):
    """Initialize a termination criterion based on the number of generations.

    Args:
        max_generations (int): the maximum number of generations.
        publisher (Publisher): The publisher to which the terminator will publish its state.
    """
    super().__init__(publisher=publisher)
    self.max_generations = max_generations
check
check() -> bool

Check if the termination criterion based on the number of generations is reached.

Returns:

Name Type Description
bool bool

True if the termination criterion is reached, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination criterion based on the number of generations is reached.

    Returns:
        bool: True if the termination criterion is reached, False otherwise.
    """
    super().check()
    self.notify()
    return self.current_generation > self.max_generations

MaxTimeTerminator

Bases: BaseTerminator

A termination criterion based on the maximum elapsed time.

Source code in desdeo/emo/operators/termination.py
class MaxTimeTerminator(BaseTerminator):
    """A termination criterion based on the maximum elapsed time."""

    @property
    def provided_topics(self) -> dict[int, Sequence[TerminatorMessageTopics]]:
        """Return the topics provided by the terminator.

        Returns:
            dict[int, Sequence[TerminatorMessageTopics]]: The topics provided by the terminator.
        """
        return {
            0: [],
            1: [
                TerminatorMessageTopics.GENERATION,
                TerminatorMessageTopics.EVALUATION,
            ],
        }

    def __init__(self, max_time_in_seconds: float, publisher: Publisher):
        """Initialize the maximum time terminator.

        Args:
            max_time_in_seconds (float): The maximum elapsed time in seconds.
            publisher (Publisher): The publisher to which the terminator will publish its state.
        """
        super().__init__(publisher=publisher)
        if not isinstance(max_time_in_seconds, float) or max_time_in_seconds < 0:
            raise ValueError("max_time must be a non-negative float")
        self.max_time = max_time_in_seconds
        self.start_time = None

    def check(self) -> bool:
        """Check if the termination criterion based on the maximum elapsed time is reached.

        Returns:
            bool: True if the termination criterion is reached, False otherwise.
        """
        super().check()
        self.notify()
        if self.start_time is None:
            self.start_time = time.perf_counter()
        elapsed_time = time.perf_counter() - self.start_time
        return elapsed_time >= self.max_time
provided_topics property
provided_topics: dict[
    int, Sequence[TerminatorMessageTopics]
]

Return the topics provided by the terminator.

Returns:

Type Description
dict[int, Sequence[TerminatorMessageTopics]]

dict[int, Sequence[TerminatorMessageTopics]]: The topics provided by the terminator.

__init__
__init__(max_time_in_seconds: float, publisher: Publisher)

Initialize the maximum time terminator.

Parameters:

Name Type Description Default
max_time_in_seconds float

The maximum elapsed time in seconds.

required
publisher Publisher

The publisher to which the terminator will publish its state.

required
Source code in desdeo/emo/operators/termination.py
def __init__(self, max_time_in_seconds: float, publisher: Publisher):
    """Initialize the maximum time terminator.

    Args:
        max_time_in_seconds (float): The maximum elapsed time in seconds.
        publisher (Publisher): The publisher to which the terminator will publish its state.
    """
    super().__init__(publisher=publisher)
    if not isinstance(max_time_in_seconds, float) or max_time_in_seconds < 0:
        raise ValueError("max_time must be a non-negative float")
    self.max_time = max_time_in_seconds
    self.start_time = None
check
check() -> bool

Check if the termination criterion based on the maximum elapsed time is reached.

Returns:

Name Type Description
bool bool

True if the termination criterion is reached, False otherwise.

Source code in desdeo/emo/operators/termination.py
def check(self) -> bool:
    """Check if the termination criterion based on the maximum elapsed time is reached.

    Returns:
        bool: True if the termination criterion is reached, False otherwise.
    """
    super().check()
    self.notify()
    if self.start_time is None:
        self.start_time = time.perf_counter()
    elapsed_time = time.perf_counter() - self.start_time
    return elapsed_time >= self.max_time

Archivers

desdeo.emo.hooks.archivers

A collection of archivers for storing solutions evaluated during evolution.

Archive

Bases: BaseArchive

An archiver that stores the solutions evaluated during evolution.

Source code in desdeo/emo/hooks/archivers.py
class Archive(BaseArchive):
    """An archiver that stores the solutions evaluated during evolution."""

    def update(self, message: Message) -> None:
        """Update the archiver with the new data.

        Args:
            message (Message): Message from the publisher.
        """
        if (
            message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
            or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
        ):
            super().update(message)
            return
        data = message.value
        data = data.with_columns(generation=self.generation_number)
        if self.solutions is None:
            self.solutions = data
        else:
            self.solutions = pl.concat([self.solutions, data])
update
update(message: Message) -> None

Update the archiver with the new data.

Parameters:

Name Type Description Default
message Message

Message from the publisher.

required
Source code in desdeo/emo/hooks/archivers.py
def update(self, message: Message) -> None:
    """Update the archiver with the new data.

    Args:
        message (Message): Message from the publisher.
    """
    if (
        message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
        or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
    ):
        super().update(message)
        return
    data = message.value
    data = data.with_columns(generation=self.generation_number)
    if self.solutions is None:
        self.solutions = data
    else:
        self.solutions = pl.concat([self.solutions, data])

BaseArchive

Bases: Subscriber

Base class for archivers.

Source code in desdeo/emo/hooks/archivers.py
class BaseArchive(Subscriber):
    """Base class for archivers."""

    @property
    def interested_topics(self) -> Sequence[MessageTopics]:
        """Return the message topics that the archiver is interested in."""
        return [
            GeneratorMessageTopics.VERBOSE_OUTPUTS,
            EvaluatorMessageTopics.VERBOSE_OUTPUTS,
            SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            TerminatorMessageTopics.GENERATION,
        ]

    @property
    def provided_topics(self) -> dict[int, Sequence[MessageTopics]]:
        """Return the topics provided by the archiver."""
        return {0: []}

    def __init__(self, *, problem: Problem, publisher: Publisher):
        """Initialize the base archiver.

        Args:
            problem (Problem): The problem being solved.
            publisher (Publisher): The publisher object.
        """
        super().__init__(publisher, verbosity=0)
        self.solutions: pl.DataFrame = None
        self.selections: pl.DataFrame = None
        self.problem = problem
        self.generation_number = 1

    def state(self) -> Sequence[Message]:
        """Return the state of the archiver."""
        return []

    def update(self, message: Message) -> None:
        """Updae the archiver with new data.

        Takes care of common archiving jobs. Make sure to run this for every archiver.

        Args:
            message (Message): Message from the publisher.
        """
        if message.topic == TerminatorMessageTopics.GENERATION:
            self.generation_number = message.value
            return
        if message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS:
            data: pl.DataFrame = message.value
            data = data.with_columns(generation=self.generation_number)
            if self.selections is None:
                self.selections = data
            else:
                self.selections = pl.concat([self.selections, data], how="vertical")
            return

    @property
    def results(self) -> EMOResult:
        """Return the results of the archiver."""
        dec_vars = [x.symbol for x in self.problem.get_flattened_variables()]
        all_cols = self.solutions.columns
        non_decs = [col for col in all_cols if col not in dec_vars]
        return EMOResult(optimal_variables=self.solutions[dec_vars], optimal_outputs=self.solutions[non_decs])
interested_topics property
interested_topics: Sequence[MessageTopics]

Return the message topics that the archiver is interested in.

provided_topics property
provided_topics: dict[int, Sequence[MessageTopics]]

Return the topics provided by the archiver.

results property
results: EMOResult

Return the results of the archiver.

__init__
__init__(*, problem: Problem, publisher: Publisher)

Initialize the base archiver.

Parameters:

Name Type Description Default
problem Problem

The problem being solved.

required
publisher Publisher

The publisher object.

required
Source code in desdeo/emo/hooks/archivers.py
def __init__(self, *, problem: Problem, publisher: Publisher):
    """Initialize the base archiver.

    Args:
        problem (Problem): The problem being solved.
        publisher (Publisher): The publisher object.
    """
    super().__init__(publisher, verbosity=0)
    self.solutions: pl.DataFrame = None
    self.selections: pl.DataFrame = None
    self.problem = problem
    self.generation_number = 1
state
state() -> Sequence[Message]

Return the state of the archiver.

Source code in desdeo/emo/hooks/archivers.py
def state(self) -> Sequence[Message]:
    """Return the state of the archiver."""
    return []
update
update(message: Message) -> None

Updae the archiver with new data.

Takes care of common archiving jobs. Make sure to run this for every archiver.

Parameters:

Name Type Description Default
message Message

Message from the publisher.

required
Source code in desdeo/emo/hooks/archivers.py
def update(self, message: Message) -> None:
    """Updae the archiver with new data.

    Takes care of common archiving jobs. Make sure to run this for every archiver.

    Args:
        message (Message): Message from the publisher.
    """
    if message.topic == TerminatorMessageTopics.GENERATION:
        self.generation_number = message.value
        return
    if message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS:
        data: pl.DataFrame = message.value
        data = data.with_columns(generation=self.generation_number)
        if self.selections is None:
            self.selections = data
        else:
            self.selections = pl.concat([self.selections, data], how="vertical")
        return

FeasibleArchive

Bases: BaseArchive

An archiver that stores all feasible solutions evaluated during evolution.

Source code in desdeo/emo/hooks/archivers.py
class FeasibleArchive(BaseArchive):
    """An archiver that stores all feasible solutions evaluated during evolution."""

    def __init__(self, *, problem: Problem, publisher: Publisher):
        """Initialize the archiver.

        Args:
            problem (Problem): The problem being solved.
            publisher (Publisher): The publisher object.
        """
        super().__init__(problem=problem, publisher=publisher)

        if problem.constraints is None:
            raise ValueError("The problem has no constraints.")
        self.cons_symb = [x.symbol for x in problem.constraints]

    def update(self, message: Message) -> None:
        """Update the archiver with the new data.

        Args:
            message (Message): Message from the publisher.
        """
        if (
            message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
            or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
        ):
            super().update(message)
            return
        data = message.value
        feasible_mask = (data[self.cons_symb] <= 0).to_numpy().all(axis=1)
        feasible_data = data.filter(feasible_mask)
        feasible_data = feasible_data.with_columns(generation=self.generation_number)
        if self.solutions is None:
            self.solutions = feasible_data
        else:
            self.solutions = pl.concat([self.solutions, feasible_data])
__init__
__init__(*, problem: Problem, publisher: Publisher)

Initialize the archiver.

Parameters:

Name Type Description Default
problem Problem

The problem being solved.

required
publisher Publisher

The publisher object.

required
Source code in desdeo/emo/hooks/archivers.py
def __init__(self, *, problem: Problem, publisher: Publisher):
    """Initialize the archiver.

    Args:
        problem (Problem): The problem being solved.
        publisher (Publisher): The publisher object.
    """
    super().__init__(problem=problem, publisher=publisher)

    if problem.constraints is None:
        raise ValueError("The problem has no constraints.")
    self.cons_symb = [x.symbol for x in problem.constraints]
update
update(message: Message) -> None

Update the archiver with the new data.

Parameters:

Name Type Description Default
message Message

Message from the publisher.

required
Source code in desdeo/emo/hooks/archivers.py
def update(self, message: Message) -> None:
    """Update the archiver with the new data.

    Args:
        message (Message): Message from the publisher.
    """
    if (
        message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
        or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
    ):
        super().update(message)
        return
    data = message.value
    feasible_mask = (data[self.cons_symb] <= 0).to_numpy().all(axis=1)
    feasible_data = data.filter(feasible_mask)
    feasible_data = feasible_data.with_columns(generation=self.generation_number)
    if self.solutions is None:
        self.solutions = feasible_data
    else:
        self.solutions = pl.concat([self.solutions, feasible_data])

NonDominatedArchive

Bases: Archive

An archiver that stores only the feasible non-dominated solutions evaluated during evolution.

Source code in desdeo/emo/hooks/archivers.py
class NonDominatedArchive(Archive):
    """An archiver that stores only the feasible non-dominated solutions evaluated during evolution."""

    def __init__(self, *, problem: Problem, publisher: Publisher):
        """Initialize the archiver.

        Args:
            problem (Problem): The problem being solved.
            publisher (Publisher): The publisher object.
        """
        super().__init__(problem=problem, publisher=publisher)
        self.targets = [f"{x.symbol}_min" for x in problem.objectives]
        if problem.constraints is None:
            self.cons_symb = []
        else:
            self.cons_symb = [x.symbol for x in problem.constraints]

    def update(self, message: Message) -> None:
        """Update the archiver with the new data.

        Args:
            message (Message): Message from the publisher.
        """
        if (
            message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
            or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
        ):
            super().update(message)
            return
        data = message.value
        data = data.with_columns(generation=self.generation_number)
        if type(data) is not pl.DataFrame:
            raise ValueError("Data should be a polars DataFrame")
        if self.cons_symb:
            feasible_mask = (data[self.cons_symb] <= 0).to_numpy().all(axis=1)
            data = data.filter(feasible_mask)
        if self.solutions is None:
            non_dom_mask = non_dominated(data[self.targets].to_numpy())
            self.solutions = data.filter(non_dom_mask)
        else:
            to_add = data.filter(non_dominated(data[self.targets].to_numpy()))
            mask1, mask2 = non_dominated_merge(self.solutions[self.targets].to_numpy(), to_add[self.targets].to_numpy())
            self.solutions = pl.concat([self.solutions.filter(mask1), to_add.filter(mask2)])
__init__
__init__(*, problem: Problem, publisher: Publisher)

Initialize the archiver.

Parameters:

Name Type Description Default
problem Problem

The problem being solved.

required
publisher Publisher

The publisher object.

required
Source code in desdeo/emo/hooks/archivers.py
def __init__(self, *, problem: Problem, publisher: Publisher):
    """Initialize the archiver.

    Args:
        problem (Problem): The problem being solved.
        publisher (Publisher): The publisher object.
    """
    super().__init__(problem=problem, publisher=publisher)
    self.targets = [f"{x.symbol}_min" for x in problem.objectives]
    if problem.constraints is None:
        self.cons_symb = []
    else:
        self.cons_symb = [x.symbol for x in problem.constraints]
update
update(message: Message) -> None

Update the archiver with the new data.

Parameters:

Name Type Description Default
message Message

Message from the publisher.

required
Source code in desdeo/emo/hooks/archivers.py
def update(self, message: Message) -> None:
    """Update the archiver with the new data.

    Args:
        message (Message): Message from the publisher.
    """
    if (
        message.topic == TerminatorMessageTopics.GENERATION  # NOQA: PLR1714
        or message.topic == SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS
    ):
        super().update(message)
        return
    data = message.value
    data = data.with_columns(generation=self.generation_number)
    if type(data) is not pl.DataFrame:
        raise ValueError("Data should be a polars DataFrame")
    if self.cons_symb:
        feasible_mask = (data[self.cons_symb] <= 0).to_numpy().all(axis=1)
        data = data.filter(feasible_mask)
    if self.solutions is None:
        non_dom_mask = non_dominated(data[self.targets].to_numpy())
        self.solutions = data.filter(non_dom_mask)
    else:
        to_add = data.filter(non_dominated(data[self.targets].to_numpy()))
        mask1, mask2 = non_dominated_merge(self.solutions[self.targets].to_numpy(), to_add[self.targets].to_numpy())
        self.solutions = pl.concat([self.solutions.filter(mask1), to_add.filter(mask2)])