Skip to content

emo

Methods

desdeo.emo.methods.EAs

[Deprecated] Implements common evolutionary algorithms for multi-objective optimization.

Use desdeo.emo.options.algorithms instead.

ibea

ibea(
    *,
    problem: Problem,
    population_size: int = 100,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    kappa: float = 0.05,
    binary_indicator: Callable[
        [ndarray], ndarray
    ] = self_epsilon,
    seed: int = 0,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]

Implements the Indicator-Based Evolutionary Algorithm (IBEA).

References

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

Parameters:

Name Type Description Default
problem Problem

The problem to be solved.

required
population_size int

The size of the population. Defaults to 100.

100
n_generations int

The number of generations to run the algorithm. Defaults to 100.

100
max_evaluations int | None

The maximum number of evaluations to run the algorithm. If None, the algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the algorithm will run until max_evaluations is reached.

None
kappa float

The kappa value for the IBEA selection. Defaults to 0.05.

0.05
binary_indicator Callable[[ndarray], ndarray]

A binary indicator function that takes the target values and returns a binary indicator for each individual. Defaults to self_epsilon with uses binary adaptive epsilon indicator.

self_epsilon
seed int

The seed for the random number generator. Defaults to 0.

0
forced_verbosity int | None

If not None, the verbosity of the algorithm is forced to this value. Defaults to None.

None

Returns:

Type Description
tuple[Callable[[], EMOResult], Publisher]

tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the publisher object. The publisher object can be used to subscribe to the topics of the algorithm components, as well as to inject additional functionality such as archiving the results.

Source code in desdeo/emo/methods/EAs.py
def ibea(
    *,
    problem: Problem,
    population_size: int = 100,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    kappa: float = 0.05,
    binary_indicator: Callable[[np.ndarray], np.ndarray] = self_epsilon,
    seed: int = 0,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]:
    """Implements the Indicator-Based Evolutionary Algorithm (IBEA).

    References:
        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

    Args:
        problem (Problem): The problem to be solved.
        population_size (int, optional): The size of the population. Defaults to 100.
        n_generations (int, optional): The number of generations to run the algorithm. Defaults to 100.
        max_evaluations (int | None, optional): The maximum number of evaluations to run the algorithm. If None, the
            algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are
            provided, the algorithm will run until max_evaluations is reached.
        kappa (float, optional): The kappa value for the IBEA selection. Defaults to 0.05.
        binary_indicator (Callable[[np.ndarray], np.ndarray], optional): A binary indicator function that takes the
            target values and returns a binary indicator for each individual. Defaults to self_epsilon with uses
            binary adaptive epsilon indicator.
        seed (int, optional): The seed for the random number generator. Defaults to 0.
        forced_verbosity (int | None, optional): If not None, the verbosity of the algorithm is forced to this value.
            Defaults to None.

    Returns:
        tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the
            publisher object. The publisher object can be used to subscribe to the topics of the algorithm components,
            as well as to inject additional functionality such as archiving the results.
    """
    publisher = Publisher()
    evaluator = EMOEvaluator(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )
    selector = IBEASelector(
        problem=problem,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
        publisher=publisher,
        population_size=population_size,
        kappa=kappa,
        binary_indicator=binary_indicator,
    )
    generator = LHSGenerator(
        problem=problem,
        evaluator=evaluator,
        publisher=publisher,
        n_points=population_size,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    crossover = SimulatedBinaryCrossover(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    mutation = BoundedPolynomialMutation(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    if max_evaluations is not None:
        terminator = MaxEvaluationsTerminator(max_evaluations, publisher=publisher)
    else:
        terminator = MaxGenerationsTerminator(
            n_generations,
            publisher=publisher,
        )

    scalar_selector = TournamentSelection(publisher=publisher, verbosity=0, winner_size=population_size, seed=seed)

    components = [
        evaluator,
        generator,
        crossover,
        mutation,
        selector,
        terminator,
        scalar_selector,
    ]
    [publisher.auto_subscribe(x) for x in components]
    [publisher.register_topics(x.provided_topics[x.verbosity], x.__class__.__name__) for x in components]
    return (
        partial(
            template2,
            evaluator=evaluator,
            crossover=crossover,
            mutation=mutation,
            generator=generator,
            selection=selector,
            terminator=terminator,
            mate_selection=scalar_selector,
        ),
        publisher,
    )

nsga3

nsga3(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]

Implements the NSGA-III algorithm as well as its interactive version.

References

K. Deb and H. Jain, “An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based Nondominated Sorting Approach, Part I: Solving Problems With Box Constraints,” IEEE Transactions on Evolutionary Computation, vol. 18, no. 4, pp. 577-601, Aug. 2014.

J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

Parameters:

Name Type Description Default
problem Problem

The problem to be solved.

required
seed int

The seed for the random number generator. Defaults to 0.

0
n_generations int

The number of generations to run the algorithm. Defaults to 100.

100
max_evaluations int

The maximum number of evaluations to run the algorithm. If None, the algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the algorithm will run until max_evaluations is reached.

None
reference_vector_options ReferenceVectorOptions

The options for the reference vectors. Defaults to None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive version of the algorithm, using preferences provided by the user.

None
forced_verbosity int

If not None, the verbosity of the algorithm is forced to this value. Defaults to None.

None

Returns:

Type Description
tuple[Callable[[], EMOResult], Publisher]

tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the publisher object. The publisher object can be used to subscribe to the topics of the algorithm components, as well as to inject additional functionality such as archiving the results.

Source code in desdeo/emo/methods/EAs.py
def nsga3(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]:
    """Implements the NSGA-III algorithm as well as its interactive version.

    References:
        K. Deb and H. Jain, “An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based
        Nondominated Sorting Approach, Part I: Solving Problems With Box Constraints,” IEEE Transactions on Evolutionary
        Computation, vol. 18, no. 4, pp. 577-601, Aug. 2014.

        J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different
        types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium
        Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

    Args:
        problem (Problem): The problem to be solved.
        seed (int, optional): The seed for the random number generator. Defaults to 0.
        n_generations (int, optional): The number of generations to run the algorithm. Defaults to 100.
        max_evaluations (int, optional): The maximum number of evaluations to run the algorithm. If None, the algorithm
            will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the
            algorithm will run until max_evaluations is reached.
        reference_vector_options (ReferenceVectorOptions, optional): The options for the reference vectors. Defaults to
            None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive
            version of the algorithm, using preferences provided by the user.
        forced_verbosity (int, optional): If not None, the verbosity of the algorithm is forced to this value. Defaults
            to None.

    Returns:
        tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the
            publisher object. The publisher object can be used to subscribe to the topics of the algorithm components,
            as well as to inject additional functionality such as archiving the results.
    """
    publisher = Publisher()
    evaluator = EMOEvaluator(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    selector = NSGA3Selector(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
        reference_vector_options=reference_vector_options,
    )

    # Note that the initial population size is equal to the number of reference vectors
    n_points = selector.reference_vectors.shape[0]

    generator = LHSGenerator(
        problem=problem,
        evaluator=evaluator,
        publisher=publisher,
        n_points=n_points,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    crossover = SimulatedBinaryCrossover(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    mutation = BoundedPolynomialMutation(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )

    if max_evaluations is not None:
        terminator = MaxEvaluationsTerminator(max_evaluations, publisher=publisher)
    else:
        terminator = MaxGenerationsTerminator(
            n_generations,
            publisher=publisher,
        )

    components = [evaluator, generator, crossover, mutation, selector, terminator]
    [publisher.auto_subscribe(x) for x in components]
    [publisher.register_topics(x.provided_topics[x.verbosity], x.__class__.__name__) for x in components]

    return (
        partial(
            template1,
            evaluator=evaluator,
            crossover=crossover,
            mutation=mutation,
            generator=generator,
            selection=selector,
            terminator=terminator,
        ),
        publisher,
    )

nsga3_mixed_integer

nsga3_mixed_integer(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]

Implements the NSGA-III algorithm as well as its interactive version for mixed-integer problems.

References

K. Deb and H. Jain, “An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based Nondominated Sorting Approach, Part I: Solving Problems With Box Constraints,” IEEE Transactions on Evolutionary Computation, vol. 18, no. 4, pp. 577-601, Aug. 2014.

J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

Parameters:

Name Type Description Default
problem Problem

The problem to be solved.

required
seed int

The seed for the random number generator. Defaults to 0.

0
n_generations int

The number of generations to run the algorithm. Defaults to 100.

100
max_evaluations int

The maximum number of evaluations to run the algorithm. If None, the algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the algorithm will run until max_evaluations is reached.

None
reference_vector_options ReferenceVectorOptions

The options for the reference vectors. Defaults to None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive version of the algorithm, using preferences provided by the user.

None
forced_verbosity int

If not None, the verbosity of the algorithm is forced to this value. Defaults to None.

None

Returns:

Type Description
tuple[Callable[[], EMOResult], Publisher]

tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the publisher object. The publisher object can be used to subscribe to the topics of the algorithm components, as well as to inject additional functionality such as archiving the results.

Source code in desdeo/emo/methods/EAs.py
def nsga3_mixed_integer(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations: int = 100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]:
    """Implements the NSGA-III algorithm as well as its interactive version for mixed-integer problems.

    References:
        K. Deb and H. Jain, “An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point-Based
        Nondominated Sorting Approach, Part I: Solving Problems With Box Constraints,” IEEE Transactions on Evolutionary
        Computation, vol. 18, no. 4, pp. 577-601, Aug. 2014.

        J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different
        types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium
        Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

    Args:
        problem (Problem): The problem to be solved.
        seed (int, optional): The seed for the random number generator. Defaults to 0.
        n_generations (int, optional): The number of generations to run the algorithm. Defaults to 100.
        max_evaluations (int, optional): The maximum number of evaluations to run the algorithm. If None, the algorithm
            will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the
            algorithm will run until max_evaluations is reached.
        reference_vector_options (ReferenceVectorOptions, optional): The options for the reference vectors. Defaults to
            None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive
            version of the algorithm, using preferences provided by the user.
        forced_verbosity (int, optional): If not None, the verbosity of the algorithm is forced to this value. Defaults
            to None.

    Returns:
        tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the
            publisher object. The publisher object can be used to subscribe to the topics of the algorithm components,
            as well as to inject additional functionality such as archiving the results.
    """
    publisher = Publisher()
    evaluator = EMOEvaluator(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    selector = NSGA3Selector(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
        reference_vector_options=reference_vector_options,
    )

    # Note that the initial population size is equal to the number of reference vectors
    n_points = selector.reference_vectors.shape[0]

    generator = RandomMixedIntegerGenerator(
        problem=problem,
        evaluator=evaluator,
        publisher=publisher,
        n_points=n_points,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    crossover = UniformMixedIntegerCrossover(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    mutation = MixedIntegerRandomMutation(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )

    if max_evaluations is not None:
        terminator = MaxEvaluationsTerminator(max_evaluations, publisher=publisher)
    else:
        terminator = MaxGenerationsTerminator(
            n_generations,
            publisher=publisher,
        )

    components = [evaluator, generator, crossover, mutation, selector, terminator]
    [publisher.auto_subscribe(x) for x in components]
    [publisher.register_topics(x.provided_topics[x.verbosity], x.__class__.__name__) for x in components]

    return (
        partial(
            template1,
            evaluator=evaluator,
            crossover=crossover,
            mutation=mutation,
            generator=generator,
            selection=selector,
            terminator=terminator,
        ),
        publisher,
    )

rvea

rvea(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations=100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]

Implements the Reference Vector Guided Evolutionary Algorithm (RVEA), as well as its interactive version.

References

R. Cheng, Y. Jin, M. Olhofer and B. Sendhoff, "A Reference Vector Guided Evolutionary Algorithm for Many- Objective Optimization," in IEEE Transactions on Evolutionary Computation, vol. 20, no. 5, pp. 773-791, Oct. 2016, doi: 10.1109/TEVC.2016.2519378.

J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

Parameters:

Name Type Description Default
problem Problem

The problem to be solved.

required
seed int

The seed for the random number generator. Defaults to 0.

0
n_generations int

The number of generations to run the algorithm. Defaults to 100.

100
max_evaluations int

The maximum number of evaluations to run the algorithm. If None, the algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the algorithm will run until max_evaluations is reached.

None
reference_vector_options ReferenceVectorOptions

The options for the reference vectors. Defaults to None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive version of the algorithm, using preferences provided by the user.

None
forced_verbosity int

If not None, the verbosity of the algorithm is forced to this value. Defaults to None.

None

Returns:

Type Description
tuple[Callable[[], EMOResult], Publisher]

tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the publisher object. The publisher object can be used to subscribe to the topics of the algorithm components, as well as to inject additional functionality such as archiving the results.

Source code in desdeo/emo/methods/EAs.py
def rvea(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations=100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]:
    """Implements the Reference Vector Guided Evolutionary Algorithm (RVEA), as well as its interactive version.

    References:
        R. Cheng, Y. Jin, M. Olhofer and B. Sendhoff, "A Reference Vector Guided Evolutionary Algorithm for Many-
        Objective Optimization," in IEEE Transactions on Evolutionary Computation, vol. 20, no. 5, pp. 773-791,
        Oct. 2016, doi: 10.1109/TEVC.2016.2519378.

        J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different
        types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium
        Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.


    Args:
        problem (Problem): The problem to be solved.
        seed (int, optional): The seed for the random number generator. Defaults to 0.
        n_generations (int, optional): The number of generations to run the algorithm. Defaults to 100.
        max_evaluations (int, optional): The maximum number of evaluations to run the algorithm. If None, the algorithm
            will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the
            algorithm will run until max_evaluations is reached.
        reference_vector_options (ReferenceVectorOptions, optional): The options for the reference vectors. Defaults to
            None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive
            version of the algorithm, using preferences provided by the user.
        forced_verbosity (int, optional): If not None, the verbosity of the algorithm is forced to this value. Defaults
            to None.

    Returns:
        tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the
            publisher object. The publisher object can be used to subscribe to the topics of the algorithm components,
            as well as to inject additional functionality such as archiving the results.
    """
    publisher = Publisher()
    evaluator = EMOEvaluator(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    selector = RVEASelector(
        problem=problem,
        publisher=publisher,
        reference_vector_options=reference_vector_options,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    # Note that the initial population size is equal to the number of reference vectors
    n_points = selector.reference_vectors.shape[0]

    generator = LHSGenerator(
        problem=problem,
        evaluator=evaluator,
        publisher=publisher,
        n_points=n_points,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    crossover = SimulatedBinaryCrossover(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    mutation = BoundedPolynomialMutation(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )

    if max_evaluations is not None:
        terminator = MaxEvaluationsTerminator(max_evaluations, publisher=publisher)
    else:
        terminator = MaxGenerationsTerminator(n_generations, publisher=publisher)

    components = [evaluator, generator, crossover, mutation, selector, terminator]
    [publisher.auto_subscribe(x) for x in components]
    [publisher.register_topics(x.provided_topics[x.verbosity], x.__class__.__name__) for x in components]

    return (
        partial(
            template1,
            evaluator=evaluator,
            crossover=crossover,
            mutation=mutation,
            generator=generator,
            selection=selector,
            terminator=terminator,
        ),
        publisher,
    )

rvea_mixed_integer

rvea_mixed_integer(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations=100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]

Implements the mixed-integer variant of RVEA, as well as its interactive version.

References

R. Cheng, Y. Jin, M. Olhofer and B. Sendhoff, "A Reference Vector Guided Evolutionary Algorithm for Many- Objective Optimization," in IEEE Transactions on Evolutionary Computation, vol. 20, no. 5, pp. 773-791, Oct. 2016, doi: 10.1109/TEVC.2016.2519378.

J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.

Parameters:

Name Type Description Default
problem Problem

The problem to be solved.

required
seed int

The seed for the random number generator. Defaults to 0.

0
n_generations int

The number of generations to run the algorithm. Defaults to 100.

100
max_evaluations int

The maximum number of evaluations to run the algorithm. If None, the algorithm will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the algorithm will run until max_evaluations is reached.

None
reference_vector_options ReferenceVectorOptions

The options for the reference vectors. Defaults to None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive version of the algorithm, using preferences provided by the user.

None
forced_verbosity int

If not None, the verbosity of the algorithm is forced to this value. Defaults to None.

None

Returns:

Type Description
tuple[Callable[[], EMOResult], Publisher]

tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the publisher object. The publisher object can be used to subscribe to the topics of the algorithm components, as well as to inject additional functionality such as archiving the results.

Source code in desdeo/emo/methods/EAs.py
def rvea_mixed_integer(
    *,
    problem: Problem,
    seed: int = 0,
    n_generations=100,
    max_evaluations: int | None = None,
    reference_vector_options: ReferenceVectorOptions = None,
    forced_verbosity: int | None = None,
) -> tuple[Callable[[], EMOResult], Publisher]:
    """Implements the mixed-integer variant of RVEA, as well as its interactive version.

    References:
        R. Cheng, Y. Jin, M. Olhofer and B. Sendhoff, "A Reference Vector Guided Evolutionary Algorithm for Many-
        Objective Optimization," in IEEE Transactions on Evolutionary Computation, vol. 20, no. 5, pp. 773-791,
        Oct. 2016, doi: 10.1109/TEVC.2016.2519378.

        J. Hakanen, T. Chugh, K. Sindhya, Y. Jin, and K. Miettinen, “Connections of reference vectors and different
        types of preference information in interactive multiobjective evolutionary algorithms,” in 2016 IEEE Symposium
        Series on Computational Intelligence (SSCI), Athens, Greece: IEEE, Dec. 2016, pp. 1-8.


    Args:
        problem (Problem): The problem to be solved.
        seed (int, optional): The seed for the random number generator. Defaults to 0.
        n_generations (int, optional): The number of generations to run the algorithm. Defaults to 100.
        max_evaluations (int, optional): The maximum number of evaluations to run the algorithm. If None, the algorithm
            will run for n_generations. Defaults to None. If both n_generations and max_evaluations are provided, the
            algorithm will run until max_evaluations is reached.
        reference_vector_options (ReferenceVectorOptions, optional): The options for the reference vectors. Defaults to
            None. See the ReferenceVectorOptions class for the defaults. This option can be used to run an interactive
            version of the algorithm, using preferences provided by the user.
        forced_verbosity (int, optional): If not None, the verbosity of the algorithm is forced to this value. Defaults
            to None.

    Returns:
        tuple[Callable[[], EMOResult], Publisher]: A tuple containing the function to run the algorithm and the
            publisher object. The publisher object can be used to subscribe to the topics of the algorithm components,
            as well as to inject additional functionality such as archiving the results.
    """
    publisher = Publisher()
    evaluator = EMOEvaluator(
        problem=problem,
        publisher=publisher,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    selector = RVEASelector(
        problem=problem,
        publisher=publisher,
        reference_vector_options=reference_vector_options,
        verbosity=forced_verbosity if forced_verbosity is not None else 2,
    )

    # Note that the initial population size is equal to the number of reference vectors
    n_points = selector.reference_vectors.shape[0]

    generator = RandomMixedIntegerGenerator(
        problem=problem,
        evaluator=evaluator,
        publisher=publisher,
        n_points=n_points,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    crossover = UniformMixedIntegerCrossover(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )
    mutation = MixedIntegerRandomMutation(
        problem=problem,
        publisher=publisher,
        seed=seed,
        verbosity=forced_verbosity if forced_verbosity is not None else 1,
    )

    if max_evaluations is not None:
        terminator = MaxEvaluationsTerminator(max_evaluations, publisher=publisher)
    else:
        terminator = MaxGenerationsTerminator(n_generations, publisher=publisher)

    components = [evaluator, generator, crossover, mutation, selector, terminator]
    [publisher.auto_subscribe(x) for x in components]
    [publisher.register_topics(x.provided_topics[x.verbosity], x.__class__.__name__) for x in components]

    return (
        partial(
            template1,
            evaluator=evaluator,
            crossover=crossover,
            mutation=mutation,
            generator=generator,
            selection=selector,
            terminator=terminator,
        ),
        publisher,
    )

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 🇵🇾func: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 :py:func:`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 🇵🇾func: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 :py:func:`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)

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=pl.concat([self.solutions, self.outputs], how="horizontal"),
                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=pl.concat([self.solutions, self.outputs], how="horizontal"),
            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=pl.concat([self.population, self.out], how="horizontal"),
                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=pl.concat([self.population, self.out], how="horizontal"),
            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.lhsrng = LatinHypercube(d=len(self.variable_symbols), seed=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.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.lhsrng = LatinHypercube(d=len(self.variable_symbols), seed=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.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
        # TODO(@light-weaver, @gialmisi): This can be so much more efficient.
        self.evaluator = lambda x: SimulatorEvaluator(problem).evaluate(
            {name.symbol: x[name.symbol].to_list() for name in problem.get_flattened_variables()}, flat=True
        )
        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.evaluator(population)
        # 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=pl.concat([self.population, self.out], how="horizontal"),
                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
    # TODO(@light-weaver, @gialmisi): This can be so much more efficient.
    self.evaluator = lambda x: SimulatorEvaluator(problem).evaluate(
        {name.symbol: x[name.symbol].to_list() for name in problem.get_flattened_variables()}, flat=True
    )
    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.evaluator(population)
    # 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=pl.concat([self.population, self.out], how="horizontal"),
            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):
        """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

    @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.
        """
__init__
__init__(
    problem: Problem, verbosity: int, publisher: Publisher
)

Initialize a crossover operator.

Source code in desdeo/emo/operators/crossover.py
def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
    """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
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.
    """

BlendAlphaCrossover

Bases: BaseCrossover

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

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

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the blend alpha crossover operator."""
        return {
            0: [],
            1: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                CrossoverMessageTopics.ALPHA,
            ],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                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,
        xover_probability: float = 1.0,
    ):
        """Initialize the blend alpha 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): 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 some outliers. Defaults to 0.5.
            xover_probability (float, optional): the crossover probability parameter.
                Ranges between 0 and 1.0. Defaults to 1.0.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

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

        if not 0 <= xover_probability <= 1:
            raise ValueError("Crossover probability must be in [0,1].")
        if alpha < 0:
            raise ValueError("Alpha must be non-negative.")

        self.alpha = alpha
        self.xover_probability = xover_probability
        self.seed = seed
        self.rng = np.random.default_rng(self.seed)

        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None

    def do(
        self,
        *,
        population: pl.DataFrame,
        to_mate: list[int] | None = None,
    ) -> pl.DataFrame:
        """Perform BLX-alpha 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.
        """
        self.parent_population = population
        pop_size = population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decision_vars = population[self.variable_symbols].to_numpy()
        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_size = len(shuffled_ids)
        original_pop_size = mating_pop_size
        if mating_pop_size % 2 == 1:
            shuffled_ids.append(shuffled_ids[0])
            mating_pop_size += 1

        mating_pop = parent_decision_vars[shuffled_ids]

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

        c_min = np.array(self.lower_bounds)
        c_max = np.array(self.upper_bounds)
        span = c_max - c_min

        lower = c_min - self.alpha * span
        upper = c_max + self.alpha * span

        uniform_1 = self.rng.random((mating_pop_size // 2, num_var))
        uniform_2 = self.rng.random((mating_pop_size // 2, num_var))

        offspring1 = lower + uniform_1 * (upper - lower)
        offspring2 = lower + uniform_2 * (upper - lower)

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

        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).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 blend-alpha 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.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.

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

Initialize the blend alpha 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

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 some outliers. Defaults to 0.5.

0.5
xover_probability float

the crossover probability parameter. Ranges between 0 and 1.0. Defaults to 1.0.

1.0
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    alpha: float = 0.5,
    xover_probability: float = 1.0,
):
    """Initialize the blend alpha 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): 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 some outliers. Defaults to 0.5.
        xover_probability (float, optional): the crossover probability parameter.
            Ranges between 0 and 1.0. Defaults to 1.0.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

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

    if not 0 <= xover_probability <= 1:
        raise ValueError("Crossover probability must be in [0,1].")
    if alpha < 0:
        raise ValueError("Alpha must be non-negative.")

    self.alpha = alpha
    self.xover_probability = xover_probability
    self.seed = seed
    self.rng = np.random.default_rng(self.seed)

    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 BLX-alpha 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 BLX-alpha 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.
    """
    self.parent_population = population
    pop_size = population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decision_vars = population[self.variable_symbols].to_numpy()
    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_size = len(shuffled_ids)
    original_pop_size = mating_pop_size
    if mating_pop_size % 2 == 1:
        shuffled_ids.append(shuffled_ids[0])
        mating_pop_size += 1

    mating_pop = parent_decision_vars[shuffled_ids]

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

    c_min = np.array(self.lower_bounds)
    c_max = np.array(self.upper_bounds)
    span = c_max - c_min

    lower = c_min - self.alpha * span
    upper = c_max + self.alpha * span

    uniform_1 = self.rng.random((mating_pop_size // 2, num_var))
    uniform_2 = self.rng.random((mating_pop_size // 2, num_var))

    offspring1 = lower + uniform_1 * (upper - lower)
    offspring2 = lower + uniform_2 * (upper - lower)

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

    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).select(
        pl.all().cast(pl.Float64)
    )
    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.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.xover_probability,
            )
        )
        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.

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

    @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 = 1.0,
        xover_probability: float = 1.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 1.0.
            xover_probability (float, optional): probability of applying crossover
                to each pair. Defaults to 1.0.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

        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].")

        self.lambda_ = lambda_
        self.xover_probability = xover_probability
        self.seed = seed
        self.rng = np.random.default_rng(self.seed)

        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None

    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.
        """
        self.parent_population = population
        pop_size = population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decision_vars = population[self.variable_symbols].to_numpy()
        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_size = len(shuffled_ids)
        original_pop_size = mating_pop_size
        if mating_pop_size % 2 == 1:
            shuffled_ids.append(shuffled_ids[0])
            mating_pop_size += 1

        mating_pop = parent_decision_vars[shuffled_ids]

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

        x_lower = np.array(self.lower_bounds)
        x_upper = np.array(self.upper_bounds)
        span = parents2 - parents1  # y_i - x_1

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

        exp_lower_1 = np.exp((x_lower - parents1) / (self.lambda_ * span))
        exp_upper_1 = np.exp((parents1 - x_upper) / (self.lambda_ * span))

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

        beta_1 = np.where(
            r_i <= 0.5,
            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,
            self.lambda_ * np.log(exp_lower_2 + u_i * (1 - exp_lower_2)),
            -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_2)),
        )

        offspring1 = parents1 + beta_1 * span
        offspring2 = parents2 + beta_2 * span

        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).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 getattr(self, "parent_population", None) 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:
            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 = 1.0,
    xover_probability: float = 1.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 1.0.

1.0
xover_probability float

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

1.0
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    verbosity: int,
    publisher: Publisher,
    seed: int,
    lambda_: float = 1.0,
    xover_probability: float = 1.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 1.0.
        xover_probability (float, optional): probability of applying crossover
            to each pair. Defaults to 1.0.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

    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].")

    self.lambda_ = lambda_
    self.xover_probability = xover_probability
    self.seed = seed
    self.rng = np.random.default_rng(self.seed)

    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 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.
    """
    self.parent_population = population
    pop_size = population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decision_vars = population[self.variable_symbols].to_numpy()
    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_size = len(shuffled_ids)
    original_pop_size = mating_pop_size
    if mating_pop_size % 2 == 1:
        shuffled_ids.append(shuffled_ids[0])
        mating_pop_size += 1

    mating_pop = parent_decision_vars[shuffled_ids]

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

    x_lower = np.array(self.lower_bounds)
    x_upper = np.array(self.upper_bounds)
    span = parents2 - parents1  # y_i - x_1

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

    exp_lower_1 = np.exp((x_lower - parents1) / (self.lambda_ * span))
    exp_upper_1 = np.exp((parents1 - x_upper) / (self.lambda_ * span))

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

    beta_1 = np.where(
        r_i <= 0.5,
        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,
        self.lambda_ * np.log(exp_lower_2 + u_i * (1 - exp_lower_2)),
        -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_2)),
    )

    offspring1 = parents1 + beta_1 * span
    offspring2 = parents2 + beta_2 * span

    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).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 getattr(self, "parent_population", None) 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:
        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.

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

    @property
    def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
        """The message topics provided by the local crossover operator."""
        return {
            0: [],
            1: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
            ],
            2: [
                CrossoverMessageTopics.XOVER_PROBABILITY,
                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,
        xover_probability: float = 1.0,
    ):
        """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.
            xover_probability (float): probability of performing crossover.
            seed (int): random seed for reproducibility.
        """
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

        if not 0 <= xover_probability <= 1:
            raise ValueError("Crossover probability must be in [0, 1].")

        self.xover_probability = xover_probability
        self.seed = seed
        self.rng = np.random.default_rng(self.seed)
        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None

    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.
        """
        self.parent_population = population
        pop_size = population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decision_vars = population[self.variable_symbols].to_numpy()

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

        mating_pop_size = len(shuffled_ids)
        if mating_pop_size % 2 == 1:
            shuffled_ids.append(shuffled_ids[0])
            mating_pop_size += 1

        mating_pop = parent_decision_vars[shuffled_ids]
        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):
            if self.rng.random() < self.xover_probability:
                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]
            else:
                offspring[2 * i] = parents1[i]
                offspring[2 * i + 1] = parents2[i]

        self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).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 >= 1:
            msgs.append(
                FloatMessage(
                    topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                    source=self.__class__.__name__,
                    value=self.xover_probability,
                )
            )
        if self.verbosity >= 2:
            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.

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

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
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 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.
        xover_probability (float): probability of performing crossover.
        seed (int): random seed for reproducibility.
    """
    super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)

    if not 0 <= xover_probability <= 1:
        raise ValueError("Crossover probability must be in [0, 1].")

    self.xover_probability = xover_probability
    self.seed = seed
    self.rng = np.random.default_rng(self.seed)
    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 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.
    """
    self.parent_population = population
    pop_size = population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decision_vars = population[self.variable_symbols].to_numpy()

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

    mating_pop_size = len(shuffled_ids)
    if mating_pop_size % 2 == 1:
        shuffled_ids.append(shuffled_ids[0])
        mating_pop_size += 1

    mating_pop = parent_decision_vars[shuffled_ids]
    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):
        if self.rng.random() < self.xover_probability:
            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]
        else:
            offspring[2 * i] = parents1[i]
            offspring[2 * i + 1] = parents2[i]

    self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).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 >= 1:
        msgs.append(
            FloatMessage(
                topic=CrossoverMessageTopics.XOVER_PROBABILITY,
                source=self.__class__.__name__,
                value=self.xover_probability,
            )
        )
    if self.verbosity >= 2:
        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.

Reference

Kalyanmoy Deb and Ram Bhushan Agrawal. 1995. Simulated binary crossover for continuous search space. Complex Systems 9, 2 (1995), 115-148.

Source code in desdeo/emo/operators/crossover.py
class SimulatedBinaryCrossover(BaseCrossover):
    """A class for creating a simulated binary crossover operator.

    Reference:
        Kalyanmoy Deb and Ram Bhushan Agrawal. 1995. Simulated binary crossover for continuous search space.
            Complex Systems 9, 2 (1995), 115-148.
    """

    @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,
        xover_probability: float = 1.0,
        xover_distribution: float = 30,
    ):
        """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.
            xover_probability (float, optional): the crossover probability
                parameter. Ranges between 0 and 1.0. Defaults to 1.0.
            xover_distribution (float, optional): the crossover distribution
                parameter. Must be positive. Defaults to 30.
        """
        # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
        super().__init__(problem, verbosity=verbosity, publisher=publisher)
        self.problem = problem

        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.xover_probability = xover_probability
        self.xover_distribution = xover_distribution
        self.parent_population: pl.DataFrame
        self.offspring_population: pl.DataFrame
        self.rng = np.random.default_rng(seed)
        self.seed = seed

    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.
        """
        self.parent_population = population
        pop_size = self.parent_population.shape[0]
        num_var = len(self.variable_symbols)

        parent_decvars = self.parent_population[self.variable_symbols].to_numpy()

        if to_mate is None:
            shuffled_ids = list(range(pop_size))
            self.rng.shuffle(shuffled_ids)
        else:
            shuffled_ids = to_mate
        mating_pop = parent_decvars[shuffled_ids]
        mate_size = len(shuffled_ids)

        if len(shuffled_ids) % 2 == 1:
            mating_pop = np.vstack((mating_pop, mating_pop[0]))
            mate_size += 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):
            beta = np.zeros(num_var)
            miu = self.rng.random(num_var)
            beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))
            beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))
            beta = beta * ((-1) ** self.rng.integers(low=0, high=2, size=num_var))
            beta[self.rng.random(num_var) > self.xover_probability] = 1
            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

        self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols)
        self.notify()

        return self.offspring_population

    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,
    xover_probability: float = 1.0,
    xover_distribution: float = 30,
)

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
xover_probability float

the crossover probability parameter. Ranges between 0 and 1.0. Defaults to 1.0.

1.0
xover_distribution float

the crossover distribution parameter. Must be positive. Defaults to 30.

30
Source code in desdeo/emo/operators/crossover.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    verbosity: int,
    publisher: Publisher,
    xover_probability: float = 1.0,
    xover_distribution: float = 30,
):
    """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.
        xover_probability (float, optional): the crossover probability
            parameter. Ranges between 0 and 1.0. Defaults to 1.0.
        xover_distribution (float, optional): the crossover distribution
            parameter. Must be positive. Defaults to 30.
    """
    # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
    super().__init__(problem, verbosity=verbosity, publisher=publisher)
    self.problem = problem

    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.xover_probability = xover_probability
    self.xover_distribution = xover_distribution
    self.parent_population: pl.DataFrame
    self.offspring_population: pl.DataFrame
    self.rng = np.random.default_rng(seed)
    self.seed = seed
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.
    """
    self.parent_population = population
    pop_size = self.parent_population.shape[0]
    num_var = len(self.variable_symbols)

    parent_decvars = self.parent_population[self.variable_symbols].to_numpy()

    if to_mate is None:
        shuffled_ids = list(range(pop_size))
        self.rng.shuffle(shuffled_ids)
    else:
        shuffled_ids = to_mate
    mating_pop = parent_decvars[shuffled_ids]
    mate_size = len(shuffled_ids)

    if len(shuffled_ids) % 2 == 1:
        mating_pop = np.vstack((mating_pop, mating_pop[0]))
        mate_size += 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):
        beta = np.zeros(num_var)
        miu = self.rng.random(num_var)
        beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))
        beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))
        beta = beta * ((-1) ** self.rng.integers(low=0, high=2, size=num_var))
        beta[self.rng.random(num_var) > self.xover_probability] = 1
        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

    self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols)
    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,
        ),
    ]
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.

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

    @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)

        if not 0 <= xover_probability <= 1:
            raise ValueError("Crossover probability must be in [0, 1].")

        self.xover_probability = xover_probability
        self.seed = seed
        self.parent_population: pl.DataFrame | None = None
        self.offspring_population: pl.DataFrame | None = None
        self.rng = np.random.default_rng(self.seed)

    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.
        """
        self.parent_population = population
        pop_size = population.shape[0]
        num_vars = len(self.variable_symbols)

        parents = population[self.variable_symbols].to_numpy()

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

        mating_pop_size = len(mating_indices)
        original_pop_size = mating_pop_size

        if mating_pop_size % 2 == 1:
            mating_indices.append(mating_indices[0])
            mating_pop_size += 1

        mating_pool = parents[mating_indices, :]

        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])

        # Use advanced indexing to set arithmetic crossover gene
        offspring1[row_idx, col_idx] = avg
        offspring2[row_idx, col_idx] = avg

        for i, k in zip(row_idx, col_idx, strict=True):
            offspring1[i, k + 1 :] = parents2[i, k + 1 :]
            offspring2[i, k + 1 :] = parents1[i, k + 1 :]
            offspring1[i, :k] = parents1[i, :k]
            offspring2[i, :k] = parents2[i, :k]

        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).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:  # 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)

    if not 0 <= xover_probability <= 1:
        raise ValueError("Crossover probability must be in [0, 1].")

    self.xover_probability = xover_probability
    self.seed = seed
    self.parent_population: pl.DataFrame | None = None
    self.offspring_population: pl.DataFrame | None = None
    self.rng = np.random.default_rng(self.seed)
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.
    """
    self.parent_population = population
    pop_size = population.shape[0]
    num_vars = len(self.variable_symbols)

    parents = population[self.variable_symbols].to_numpy()

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

    mating_pop_size = len(mating_indices)
    original_pop_size = mating_pop_size

    if mating_pop_size % 2 == 1:
        mating_indices.append(mating_indices[0])
        mating_pop_size += 1

    mating_pool = parents[mating_indices, :]

    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])

    # Use advanced indexing to set arithmetic crossover gene
    offspring1[row_idx, col_idx] = avg
    offspring2[row_idx, col_idx] = avg

    for i, k in zip(row_idx, col_idx, strict=True):
        offspring1[i, k + 1 :] = parents2[i, k + 1 :]
        offspring2[i, k + 1 :] = parents1[i, k + 1 :]
        offspring1[i, :k] = parents1[i, :k]
        offspring2[i, :k] = parents2[i, :k]

    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).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:  # 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.

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

    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)
        self.seed = seed

        self.parent_population: pl.DataFrame
        self.offspring_population: pl.DataFrame
        self.rng = np.random.default_rng(seed)
        self.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(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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
        parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

        cross_over_points = self.rng.integers(1, num_var - 1, 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,
        ).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="SimulatedBinaryCrossover",
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source="SimulatedBinaryCrossover",
                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)
    self.seed = seed

    self.parent_population: pl.DataFrame
    self.offspring_population: pl.DataFrame
    self.rng = np.random.default_rng(seed)
    self.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(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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
    parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

    cross_over_points = self.rng.integers(1, num_var - 1, 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,
    ).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="SimulatedBinaryCrossover",
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source="SimulatedBinaryCrossover",
            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."""

UniformIntegerCrossover

Bases: BaseCrossover

A class that defines the uniform integer crossover operation.

Source code in desdeo/emo/operators/crossover.py
class UniformIntegerCrossover(BaseCrossover):
    """A class that defines the uniform integer crossover operation."""

    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)
        self.seed = seed

        self.parent_population: pl.DataFrame
        self.offspring_population: pl.DataFrame
        self.rng = np.random.default_rng(seed)
        self.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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
        parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

        mask = self.rng.choice([True, False], size=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,
        ).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="SimulatedBinaryCrossover",
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source="SimulatedBinaryCrossover",
                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)
    self.seed = seed

    self.parent_population: pl.DataFrame
    self.offspring_population: pl.DataFrame
    self.rng = np.random.default_rng(seed)
    self.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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
    parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

    mask = self.rng.choice([True, False], size=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,
    ).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="SimulatedBinaryCrossover",
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source="SimulatedBinaryCrossover",
            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.

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...

Source code in desdeo/emo/operators/crossover.py
class UniformMixedIntegerCrossover(BaseCrossover):
    """A class that defines the uniform mixed-integer crossover operation.

    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...
    """

    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)
        self.seed = seed

        self.parent_population: pl.DataFrame
        self.offspring_population: pl.DataFrame
        self.rng = np.random.default_rng(seed)
        self.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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
        parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

        mask = self.rng.choice([True, False], size=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,
        ).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="SimulatedBinaryCrossover",
                value=self.parent_population,
            ),
            PolarsDataFrameMessage(
                topic=CrossoverMessageTopics.OFFSPRINGS,
                source="SimulatedBinaryCrossover",
                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)
    self.seed = seed

    self.parent_population: pl.DataFrame
    self.offspring_population: pl.DataFrame
    self.rng = np.random.default_rng(seed)
    self.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[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
    parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]

    mask = self.rng.choice([True, False], size=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,
    ).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="SimulatedBinaryCrossover",
            value=self.parent_population,
        ),
        PolarsDataFrameMessage(
            topic=CrossoverMessageTopics.OFFSPRINGS,
            source="SimulatedBinaryCrossover",
            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."""

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

    @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.
        """
__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
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.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
        self.offspring_original: pl.DataFrame
        self.parents: pl.DataFrame
        self.offspring: pl.DataFrame

    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).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.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
    self.offspring_original: pl.DataFrame
    self.parents: pl.DataFrame
    self.offspring: pl.DataFrame
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).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.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.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
        self.offspring_original: pl.DataFrame
        self.parents: pl.DataFrame
        self.offspring: pl.DataFrame

    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
        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)
        temp = np.logical_and((k <= self.mutation_probability), (miu < 0.5))  # noqa: PLR2004
        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((k <= self.mutation_probability), (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)
        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.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
    self.offspring_original: pl.DataFrame
    self.parents: pl.DataFrame
    self.offspring: pl.DataFrame
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
    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)
    temp = np.logical_and((k <= self.mutation_probability), (miu < 0.5))  # noqa: PLR2004
    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((k <= self.mutation_probability), (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)
    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.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.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
        self.offspring_original: pl.DataFrame
        self.parents: pl.DataFrame
        self.offspring: pl.DataFrame

    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).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.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
    self.offspring_original: pl.DataFrame
    self.parents: pl.DataFrame
    self.offspring: pl.DataFrame
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).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.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.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."""
        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)

        for i in range(population.shape[0]):
            for j, var in enumerate(self.problem.variables):
                if self.rng.random() < self.mutation_probability:
                    x = population[i, j]
                    lower_bound, upper_bound = var.lowerbound, var.upperbound
                    if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
                        # Round after float mutation to keep integer domain
                        population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
                    else:
                        population[i, j] = self._mutate_value(x, lower_bound, upper_bound)

        self.offspring = pl.from_numpy(population, schema=self.variable_symbols).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.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."""
    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)

    for i in range(population.shape[0]):
        for j, var in enumerate(self.problem.variables):
            if self.rng.random() < self.mutation_probability:
                x = population[i, j]
                lower_bound, upper_bound = var.lowerbound, var.upperbound
                if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
                    # Round after float mutation to keep integer domain
                    population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
                else:
                    population[i, j] = self._mutate_value(x, lower_bound, upper_bound)

    self.offspring = pl.from_numpy(population, schema=self.variable_symbols).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.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.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
        self.offspring_original: pl.DataFrame
        self.parents: pl.DataFrame
        self.offspring: pl.DataFrame

    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=var.lowerbound, high=var.upperbound, size=population.shape[0], 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=population.shape[0]).astype(
                    dtype=float
                )
                for var in self.problem.variables
            ]
        ).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).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.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
    self.offspring_original: pl.DataFrame
    self.parents: pl.DataFrame
    self.offspring: pl.DataFrame
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=var.lowerbound, high=var.upperbound, size=population.shape[0], 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=population.shape[0]).astype(
                dtype=float
            )
            for var in self.problem.variables
        ]
    ).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).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.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.

Source code in desdeo/emo/operators/mutation.py
class NonUniformMutation(BaseMutation):
    """Non-uniform mutation operator.

    The mutation strength decays over generations.
    """

    @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.OFFSPRINGS,
            ],
        }

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

    def __init__(
        self,
        *,
        problem: Problem,
        seed: int,
        max_generations: int,
        verbosity: int,
        publisher: Publisher,
        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.
            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.
            max_generations (int): Maximum number of generations in the evolutionary run. Used to scale mutation decay.
            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.rng = np.random.default_rng(seed)
        self.seed = seed
        self.b = b
        self.current_generation = 0
        self.max_generations = max_generations
        self.mutation_probability = (
            1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
        )

    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
        t = self.current_generation
        max_generations = self.max_generations
        b = self.b

        u_rand = self.rng.uniform(0, 1)  # Random number for mutation strength
        tau = (1 - t / max_generations) ** 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)

        for i in range(population.shape[0]):
            for j, var in enumerate(self.problem.variables):
                if self.rng.random() < self.mutation_probability:
                    x = population[i, j]
                    lower_bound, upper_bound = var.lowerbound, var.upperbound
                    if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
                        population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
                    else:
                        population[i, j] = self._mutate_value(x, lower_bound, upper_bound)

        self.offspring = pl.from_numpy(population, schema=self.variable_symbols).cast(pl.Float64)
        self.notify()

        return self.offspring

    def update(self, message: Message):
        """Update current generation (used to reduce mutation strength over time)."""
        if not isinstance(message.topic, TerminatorMessageTopics):
            return
        if not isinstance(message.value, int):
            return
        if message.topic != TerminatorMessageTopics.GENERATION:
            raise ValueError(f"Expected message topic {TerminatorMessageTopics.GENERATION}, got {message.topic}.")
        self.current_generation = message.value

    def state(self) -> Sequence[Message]:
        """Return state messages."""
        if self.verbosity == 0:
            return []
        return [
            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,
    max_generations: int,
    verbosity: int,
    publisher: Publisher,
    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
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
max_generations int

Maximum number of generations in the evolutionary run. Used to scale mutation decay.

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
Source code in desdeo/emo/operators/mutation.py
def __init__(
    self,
    *,
    problem: Problem,
    seed: int,
    max_generations: int,
    verbosity: int,
    publisher: Publisher,
    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.
        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.
        max_generations (int): Maximum number of generations in the evolutionary run. Used to scale mutation decay.
        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.rng = np.random.default_rng(seed)
    self.seed = seed
    self.b = b
    self.current_generation = 0
    self.max_generations = max_generations
    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
    t = self.current_generation
    max_generations = self.max_generations
    b = self.b

    u_rand = self.rng.uniform(0, 1)  # Random number for mutation strength
    tau = (1 - t / max_generations) ** 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)

    for i in range(population.shape[0]):
        for j, var in enumerate(self.problem.variables):
            if self.rng.random() < self.mutation_probability:
                x = population[i, j]
                lower_bound, upper_bound = var.lowerbound, var.upperbound
                if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
                    population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
                else:
                    population[i, j] = self._mutate_value(x, lower_bound, upper_bound)

    self.offspring = pl.from_numpy(population, schema=self.variable_symbols).cast(pl.Float64)
    self.notify()

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

Return state messages.

Source code in desdeo/emo/operators/mutation.py
def state(self) -> Sequence[Message]:
    """Return state messages."""
    if self.verbosity == 0:
        return []
    return [
        FloatMessage(
            topic=MutationMessageTopics.MUTATION_PROBABILITY,
            source=self.__class__.__name__,
            value=self.mutation_probability,
        ),
    ]
update
update(message: Message)

Update current generation (used to reduce mutation strength over time).

Source code in desdeo/emo/operators/mutation.py
def update(self, message: Message):
    """Update current generation (used to reduce mutation strength over time)."""
    if not isinstance(message.topic, TerminatorMessageTopics):
        return
    if not isinstance(message.value, int):
        return
    if message.topic != TerminatorMessageTopics.GENERATION:
        raise ValueError(f"Expected message topic {TerminatorMessageTopics.GENERATION}, got {message.topic}.")
    self.current_generation = message.value

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.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
        self.offspring_original: pl.DataFrame
        self.parents: pl.DataFrame
        self.offspring: pl.DataFrame

    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()

        for i, var in enumerate(self.problem.variables):
            lower_bound, upper_bound = var.lowerbound, var.upperbound
            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))

            # 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).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.offspring is None or 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
    self.offspring_original: pl.DataFrame
    self.parents: pl.DataFrame
    self.offspring: pl.DataFrame
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()

    for i, var in enumerate(self.problem.variables):
        lower_bound, upper_bound = var.lowerbound, var.upperbound
        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))

        # 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).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.offspring is None or 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.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))

    def do(
        self,
        offsprings: pl.DataFrame,
        parents: pl.DataFrame,
        step_sizes: np.ndarray | None = None,
    ) -> tuple[pl.DataFrame, np.ndarray]:
        """Apply self-adaptive Gaussian mutation.

        Args:
            offsprings (pl.DataFrame): Current offspring population.
            parents (pl.DataFrame): Parent population.
            step_sizes (np.ndarray | None): Step sizes for each gene of each individual.

        Returns:
            tuple:
                - Mutated offspring population as a Polars DataFrame.
                - Updated step sizes as a NumPy array.
        """
        self.offspring_original = offsprings
        self.parents = parents

        offspring_array = offsprings.to_numpy(writable=True).astype(float)

        if step_sizes is None:
            step_sizes = np.full_like(offspring_array, fill_value=0.1)

        new_offspring, new_eta = self._mutation(offspring_array, step_sizes)

        mutated_df = pl.from_numpy(new_offspring, schema=self.variable_symbols).cast(pl.Float64)
        self.offspring = mutated_df
        self.notify()

        return mutated_df, new_eta

    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

        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.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))
_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

    return new_variables, new_eta
do
do(
    offsprings: DataFrame,
    parents: DataFrame,
    step_sizes: ndarray | None = None,
) -> tuple[pl.DataFrame, np.ndarray]

Apply self-adaptive Gaussian mutation.

Parameters:

Name Type Description Default
offsprings DataFrame

Current offspring population.

required
parents DataFrame

Parent population.

required
step_sizes ndarray | None

Step sizes for each gene of each individual.

None

Returns:

Name Type Description
tuple tuple[DataFrame, ndarray]
  • Mutated offspring population as a Polars DataFrame.
  • Updated step sizes as a NumPy array.
Source code in desdeo/emo/operators/mutation.py
def do(
    self,
    offsprings: pl.DataFrame,
    parents: pl.DataFrame,
    step_sizes: np.ndarray | None = None,
) -> tuple[pl.DataFrame, np.ndarray]:
    """Apply self-adaptive Gaussian mutation.

    Args:
        offsprings (pl.DataFrame): Current offspring population.
        parents (pl.DataFrame): Parent population.
        step_sizes (np.ndarray | None): Step sizes for each gene of each individual.

    Returns:
        tuple:
            - Mutated offspring population as a Polars DataFrame.
            - Updated step sizes as a NumPy array.
    """
    self.offspring_original = offsprings
    self.parents = parents

    offspring_array = offsprings.to_numpy(writable=True).astype(float)

    if step_sizes is None:
        step_sizes = np.full_like(offspring_array, fill_value=0.1)

    new_offspring, new_eta = self._mutation(offspring_array, step_sizes)

    mutated_df = pl.from_numpy(new_offspring, schema=self.variable_symbols).cast(pl.Float64)
    self.offspring = mutated_df
    self.notify()

    return mutated_df, new_eta
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.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,
    ):
        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":
            raise NotImplementedError("Riesz s-energy criterion is not yet implemented.")

        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,
                ord=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_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
            else:
                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, ord: int) -> None:
        """Adapt reference vectors by using the information about non-preferred solution(s) selected by 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
            ord (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=ord, 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(
            [
                list(
                    map(
                        lambda solution: np.linalg.norm(solution - value, ord=2),
                        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 by using the information about the Decision maker's preferred range for each of the 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()
_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
        else:
            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, ord: int
) -> None

Adapt reference vectors by using the information about non-preferred solution(s) selected by 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
ord 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, ord: int) -> None:
    """Adapt reference vectors by using the information about non-preferred solution(s) selected by 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
        ord (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=ord, 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(
        [
            list(
                map(
                    lambda solution: np.linalg.norm(solution - value, ord=2),
                    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 by using the information about the Decision maker's preferred range for each of the 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 by using the information about the Decision maker's preferred range for each of the 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.

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.

    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):
        return {
            0: [],
            1: [SelectorMessageTopics.STATE],
            2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
        }

    @property
    def interested_topics(self):
        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.
        """
        # 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
        if self.constraints_symbols is not None:
            raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")

    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 self.constraints_symbols is not None:
            raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")
        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])

        # Adaptation
        target_vals = alltargets[self.target_symbols].to_numpy()
        target_min = np.min(target_vals, axis=0)
        target_max = np.max(target_vals, axis=0)
        # Scale the targets to the range [0, 1]
        target_vals = (target_vals - target_min) / (target_max - target_min)
        fitness_components = self.binary_indicator(target_vals)
        kappa_mult = np.max(np.abs(fitness_components))

        chosen = _ibea_select_all(
            fitness_components, population_size=self.population_size, kappa=kappa_mult * self.kappa
        )
        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.kappa * np.abs(fitness_components).max())

        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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
                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:
        pass
__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
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.
    """
    # 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
    if self.constraints_symbols is not None:
        raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")
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 self.constraints_symbols is not None:
        raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")
    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])

    # Adaptation
    target_vals = alltargets[self.target_symbols].to_numpy()
    target_min = np.min(target_vals, axis=0)
    target_max = np.max(target_vals, axis=0)
    # Scale the targets to the range [0, 1]
    target_vals = (target_vals - target_min) / (target_max - target_min)
    fitness_components = self.binary_indicator(target_vals)
    kappa_mult = np.max(np.abs(fitness_components))

    chosen = _ibea_select_all(
        fitness_components, population_size=self.population_size, kappa=kappa_mult * self.kappa
    )
    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.kappa * np.abs(fitness_components).max())

    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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
            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__,
        ),
    ]

NSGA2Selector

Bases: BaseSelector

Implements the selection operator defined for NSGA2.

Implements the selection operator defined for NSGA2, which included the crowding distance calculation.

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
class NSGA2Selector(BaseSelector):
    """Implements the selection operator defined for NSGA2.

    Implements the selection operator defined for NSGA2, which included the crowding
    distance calculation.

    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,
    ):
        super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
        if self.constraints_symbols is not None:
            print(
                "NSGA2 selector does not currently support constraints. "
                "Results may vary if used to solve constrainted problems."
            )
        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 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 = fast_non_dominated_sort(parents_a)

            # assign fitness according to non-dom rank (lower better)
            scores = np.arange(len(fronts))
            fitness_values = scores @ fronts
            self.fitness = fitness_values

            # 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 = fast_non_dominated_sort(r_targets_arr)
        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)

        self.fitness = 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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
                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:
        pass
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.

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 = fast_non_dominated_sort(parents_a)

        # assign fitness according to non-dom rank (lower better)
        scores = np.arange(len(fronts))
        fitness_values = scores @ fronts
        self.fitness = fitness_values

        # 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 = fast_non_dominated_sort(r_targets_arr)
    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)

    self.fitness = 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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
            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__,
        ),
    ]

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
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 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
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):
        return {
            0: [],
            1: [
                SelectorMessageTopics.STATE,
            ],
            2: [
                SelectorMessageTopics.REFERENCE_VECTORS,
                SelectorMessageTopics.STATE,
                SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        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): Options for the reference vectors. 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,
        )
        if self.constraints_symbols is not None:
            raise NotImplementedError("NSGA3 selector does not support constraints. Please use a different selector.")

        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(
        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])
        targets = alltargets[self.target_symbols].to_numpy()
        if self.constraints_symbols is None:
            constraints = None
        else:
            constraints = (
                parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_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
            else:
                fronts = fronts[: front_id + 1]
                selection = np.concatenate(fronts)
                break
        F = 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(F, 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)))
            if np.any(selection[last_front_selection_id] != last_front):
                print("error!!!")
            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]))
            if self.extreme_points is None:
                print("Error")
            if final_selection is None:
                print("Error")
        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):
        """Taken 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
        _F = F
        if extreme_points is not None:
            _F = np.concatenate([extreme_points, _F], axis=0)

        # use __F because we substitute small values to be 0
        __F = _F - ideal_point
        __F[__F < 1e-3] = 0

        # update the extreme points for the normalization having the highest asf value
        # each
        F_asf = np.max(__F * asf[:, None, :], axis=2)
        I = np.argmin(F_asf, axis=1)
        extreme_points = _F[I, :]
        return extreme_points

    def get_nadir_point(
        self,
        extreme_points,
        ideal_point,
        worst_point,
        worst_of_front,
        worst_of_population,
    ):
        LinAlgError = np.linalg.LinAlgError
        try:
            # find the intercepts using gaussian elimination
            M = extreme_points - ideal_point
            b = np.ones(extreme_points.shape[1])
            plane = np.linalg.solve(M, b)
            intercepts = 1 / plane

            nadir_point = ideal_point + intercepts

            if not np.allclose(np.dot(M, plane), b) or np.any(intercepts <= 1e-6) or np.any(nadir_point > worst_point):
                raise LinAlgError()

        except LinAlgError:
            nadir_point = worst_of_front

        b = nadir_point - ideal_point <= 1e-6
        nadir_point[b] = worst_of_population[b]
        return nadir_point

    def niching(self, F, n_remaining, niche_count, niche_of_individuals, dist_to_niche):
        survivors = []

        # boolean array of elements that are considered for each iteration
        mask = np.full(F.shape[0], 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 niche_count[next_niche] == 0:
                next_ind = next_ind[np.argmin(dist_to_niche[next_ind])]
            else:
                # already randomized through shuffling
                next_ind = 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):
        utopian_point = ideal_point - utopian_epsilon

        denom = nadir_point - utopian_point
        denom[denom == 0] = 1e-12

        # normalize by ideal point and intercepts
        N = (F - utopian_point) / denom
        # dist_matrix = self.calc_perpendicular_distance(N, ref_dirs)
        dist_matrix = jitted_calc_perpendicular_distance(N, 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):
        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, N, ref_dirs):
        if self.invert_reference_vectors:
            u = np.tile(-ref_dirs, (len(N), 1))
            v = np.repeat(1 - N, len(ref_dirs), axis=0)
        else:
            u = np.tile(ref_dirs, (len(N), 1))
            v = np.repeat(N, 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)
        matrix = np.reshape(val, (len(N), len(ref_dirs)))

        return matrix

    def state(self) -> Sequence[Message]:
        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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
                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__,
            )
        state_verbose = [
            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,
        ]
        return state_verbose

    def update(self, message: Message) -> None:
        pass
__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

Options for the reference vectors. 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): Options for the reference vectors. 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,
    )
    if self.constraints_symbols is not None:
        raise NotImplementedError("NSGA3 selector does not support constraints. Please use a different selector.")

    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
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])
    targets = alltargets[self.target_symbols].to_numpy()
    if self.constraints_symbols is None:
        constraints = None
    else:
        constraints = (
            parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_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
        else:
            fronts = fronts[: front_id + 1]
            selection = np.concatenate(fronts)
            break
    F = 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(F, 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)))
        if np.any(selection[last_front_selection_id] != last_front):
            print("error!!!")
        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]))
        if self.extreme_points is None:
            print("Error")
        if final_selection is None:
            print("Error")
    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)

Taken from pymoo

Source code in desdeo/emo/operators/selection.py
def get_extreme_points_c(self, F, ideal_point, extreme_points=None):
    """Taken 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
    _F = F
    if extreme_points is not None:
        _F = np.concatenate([extreme_points, _F], axis=0)

    # use __F because we substitute small values to be 0
    __F = _F - ideal_point
    __F[__F < 1e-3] = 0

    # update the extreme points for the normalization having the highest asf value
    # each
    F_asf = np.max(__F * asf[:, None, :], axis=2)
    I = np.argmin(F_asf, axis=1)
    extreme_points = _F[I, :]
    return extreme_points

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

Source code in desdeo/emo/operators/selection.py
class RVEASelector(BaseDecompositionSelector):
    @property
    def provided_topics(self):
        return {
            0: [],
            1: [
                SelectorMessageTopics.STATE,
            ],
            2: [
                SelectorMessageTopics.REFERENCE_VECTORS,
                SelectorMessageTopics.STATE,
                SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
            ],
        }

    @property
    def interested_topics(self):
        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,
    ):
        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=2,
            )
            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
        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)
            # Note that
            if self.ideal is None:
                # TODO: This breaks if there are no feasible solutions in the initial population
                self.ideal = np.min(targets[feasible], axis=0)
            else:
                self.ideal = np.min(np.vstack((self.ideal, np.min(targets[feasible], axis=0))), axis=0)
            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
            if message.topic == TerminatorMessageTopics.MAX_EVALUATIONS:
                self.denominator = message.value
        return

    def state(self) -> Sequence[Message]:
        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=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
                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__,
            )
        state_verbose = [
            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,
        ]
        return state_verbose

    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
_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)
        # Note that
        if self.ideal is None:
            # TODO: This breaks if there are no feasible solutions in the initial population
            self.ideal = np.min(targets[feasible], axis=0)
        else:
            self.ideal = np.min(np.vstack((self.ideal, np.min(targets[feasible], axis=0))), axis=0)
        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
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
        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".

_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):
        # 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
        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
        else:
            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

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, GeneratorMessageTopics.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) or isinstance(message.topic, 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) or isinstance(message.topic, 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