Skip to content

tools

Scalarization

Defines various functions for scalarizing multiobjective optimization problems.

Note that when scalarization functions are defined, they must add the post-fix '_min' to any symbol representing objective functions so that the maximization or minimization of the corresponding objective functions may be correctly accounted for when computing scalarization function values.

Op

Defines the supported operators in the MathJSON format.

Source code in desdeo/tools/scalarization.py
class Op:
    """Defines the supported operators in the MathJSON format."""

    # TODO: move this to problem/schema.py, make it use this, and import it here from there
    # Basic arithmetic operators
    NEGATE = "Negate"
    ADD = "Add"
    SUB = "Subtract"
    MUL = "Multiply"
    DIV = "Divide"

    # Exponentation and logarithms
    EXP = "Exp"
    LN = "Ln"
    LB = "Lb"
    LG = "Lg"
    LOP = "LogOnePlus"
    SQRT = "Sqrt"
    SQUARE = "Square"
    POW = "Power"

    # Rounding operators
    ABS = "Abs"
    CEIL = "Ceil"
    FLOOR = "Floor"

    # Trigonometric operations
    ARCCOS = "Arccos"
    ARCCOSH = "Arccosh"
    ARCSIN = "Arcsin"
    ARCSINH = "Arcsinh"
    ARCTAN = "Arctan"
    ARCTANH = "Arctanh"
    COS = "Cos"
    COSH = "Cosh"
    SIN = "Sin"
    SINH = "Sinh"
    TAN = "Tan"
    TANH = "Tanh"

    # Comparison operators
    EQUAL = "Equal"
    GREATER = "Greater"
    GREATER_EQUAL = "GreaterEqual"
    LESS = "Less"
    LESS_EQUAL = "LessEqual"
    NOT_EQUAL = "NotEqual"

    # Other operators
    MAX = "Max"
    RATIONAL = "Rational"

ScalarizationError

Bases: Exception

Raised when issues with creating or adding scalarization functions are encountered.

Source code in desdeo/tools/scalarization.py
class ScalarizationError(Exception):
    """Raised when issues with creating or adding scalarization functions are encountered."""

__create_HDF

__create_HDF(
    y: str,
    a: float,
    r: float,
    d1: float = 0.9,
    d2: float = 0.1,
) -> str

Create a Harrington's one-sided desirability function.

Harrington's desirability function is used to compute the desirability of a given value of an objective function based on its aspiration and reservation levels.

The desirability function is defined as follows:

where

The desirability function returns a value between 0 and 1, where higher values indicate more desirable outcomes. I took the equations from the following source: Wagner, T., and Trautmann, H. Integration of preference in hypervolume-based multiobjective evolutionary algorithms by means of desirability functions. IEEE Transactions on Evolutionary Computation 14, 5 (2010), 688-701.

Parameters:

Name Type Description Default
y str

The objective value to compute the desirability for.

required
a float

Aspiration level for the objective.

required
r float

Reservation level for the objective.

required
d1 float

The desirability for the aspiration level.

0.9
d2 float

The desirability for the reservation level.

0.1

Returns:

Name Type Description
callable Function

A function that computes the desirability for a given value.

Source code in desdeo/tools/scalarization.py
def __create_HDF(  # noqa: N802
    y: str,
    a: float,
    r: float,
    d1: float = 0.9,
    d2: float = 0.1,
) -> str:
    r"""Create a Harrington's one-sided desirability function.

    Harrington's desirability function is used to compute the desirability of a
    given value of an objective function based on its aspiration and reservation levels.

    The desirability function is defined as follows:
    \begin{equation}
        D(y) = \exp\left(-\exp\left(-b_0 - b_1 y\right)\right),
    \end{equation}

    where
    \begin{align*}
        b_0 &= -\log(-\log(d_1)) - b_1 a, \\
        b_1 &= \frac{\log(-\log(d_2)) - \log(-\log(d_1))}{r - a}.
    \end{align*}

    The desirability function returns a value between 0 and 1, where higher values indicate
    more desirable outcomes. I took the equations from the following source:
    Wagner, T., and Trautmann, H. Integration of preference in hypervolume-based
    multiobjective evolutionary algorithms by means of desirability functions.
    IEEE Transactions on Evolutionary Computation 14, 5 (2010), 688-701.

    Args:
        y (str): The objective value to compute the desirability for.
        a (float): Aspiration level for the objective.
        r (float): Reservation level for the objective.
        d1 (float): The desirability for the aspiration level.
        d2 (float): The desirability for the reservation level.

    Returns:
        callable (Function): A function that computes the desirability for a given value.
    """
    if not (0 < d1 < 1 and 0 < d2 < 1):
        raise ValueError("Desirability values must be between 0 and 1 (exclusive).")
    if not (a < r):
        raise ValueError("a must be less than r.")
    if not d2 < d1:
        raise ValueError("d2 must be less than d1. Higher desirability should correspond to lower values of y.")
    b1: float = -np.log(-np.log(d2)) + np.log(-np.log(d1)) / (r - a)
    b0: float = -np.log(-np.log(d1)) - b1 * a

    def __HDF(y: float):  # noqa: N802
        """Compute the desirability for a given value."""
        return np.exp(-np.exp(-(b0 + b1 * y)))

    return f"Exp(-Exp(-({b0} + {b1} * {y})))"

__create_MDF

__create_MDF(
    y: str,
    a: float,
    r: float,
    d1: float = 0.9,
    d2: float = 0.1,
) -> str

Create MaoMao's desirability function.

Distinctions form MaoMao's original function: - The upper and lower bounds of desirability are fixed to 0 and 1, respectively.

Parameters:

Name Type Description Default
y str

The objective value to compute the desirability for.

required
a float

Aspiration level for the objective.

required
r float

Reservation level for the objective.

required
d1 float

The desirability for the aspiration level.

0.9
d2 float

The desirability for the reservation level.

0.1

Returns:

Name Type Description
callable Function

A function that computes the desirability for a given value.

Source code in desdeo/tools/scalarization.py
def __create_MDF(y: str, a: float, r: float, d1: float = 0.9, d2: float = 0.1) -> str:  # noqa: N802
    """Create MaoMao's desirability function.

    Distinctions form MaoMao's original function:
    - The upper and lower bounds of desirability are fixed to 0 and 1, respectively.

    Args:
        y (str): The objective value to compute the desirability for.
        a (float): Aspiration level for the objective.
        r (float): Reservation level for the objective.
        d1 (float): The desirability for the aspiration level.
        d2 (float): The desirability for the reservation level.

    Returns:
        callable (Function): A function that computes the desirability for a given value.
    """
    if not (0 < d1 < 1 and 0 < d2 < 1):
        raise ValueError("Desirability values must be between 0 and 1 (exclusive).")
    if not (a < r):
        raise ValueError("a must be less than r.")
    if not d2 < d1:
        raise ValueError("d2 must be less than d1. Higher desirability should correspond to lower values of y.")
    ea = 1 - d1
    er = d2
    m1 = -ea * ea * (a - r) / (d1 - d2)
    b1 = -a + ea * (a - r) / (d1 - d2)
    m2 = (d1 - d2) / (a - r)
    b2 = (d2 * a - d1 * r) / (a - r)
    m3 = -er * er * (a - r) / (d1 - d2)
    b3 = -r - er * (a - r) / (d1 - d2)

    def MDF1(y):  # noqa: N802
        """Compute the desirability for a given value."""
        if isinstance(y, np.ndarray):
            return np.array([MDF1(yi) for yi in y])
        if y < a:
            return 1 + m1 / (y + b1)
        if a <= y <= r:
            return m2 * y + b2
        return m3 / (y + b3)

    def MDF(y):  # noqa: N802
        """Compute the desirability for a given value."""
        # Same but without the if statements
        if isinstance(y, np.ndarray):
            return np.array([MDF(yi) for yi in y])
        return (
            max(a - y, 0) * (1 + m1 / (y + b1)) / (a - y)
            + max(y - r, 0) * (m3 / (y + b3)) / (y - r)
            + max(y - a, 0) * max(r - y, 0) * (m2 * y + b2) / ((y - a) * (r - y))
        )

    return (
        f"Max({a} - {y}, 0) * (1 + {m1} / ({y} + {b1})) / ({a} - {y}) + "
        f"Max({y} - {r}, 0) * ({m3} / ({y} + {b3})) / ({y} - {r}) + "
        f"Max({y} - {a}, 0) * Max({r} - {y}, 0) * ({m2} * {y} + {b2}) / "
        f"(({y} - {a}) * ({r} - {y}))"
    )

add_asf_diff

add_asf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the achievement scalarizing function.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^\text{nad} - z_i^{\star\star}} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - \bar{z}_i}{z_i^\text{nad} - z_i^{\star\star}} - \alpha \leq 0,\\ & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(z_i^{\star\star} = z_i^\star - \delta\) is a component of the utopian point, \(\bar{z}_i\) is a component of the reference point, \(\rho\) and \(\delta\) are small scalar values, \(S\) is the feasible solution space of the original problem, and \(\alpha\) is an auxiliary variable.

References

Wierzbicki, A. P. (1982). A mathematical basis for satisficing decision making. Mathematical modelling, 3(5), 391-405.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar to define the utopian point. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Todo

Add reference in augmentation term option!

Source code in desdeo/tools/scalarization.py
def add_asf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the achievement scalarizing function.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^\text{nad} - z_i^{\star\star}} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - \bar{z}_i}{z_i^\text{nad}
        - z_i^{\star\star}} - \alpha \leq 0,\\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $z_i^{\star\star} = z_i^\star - \delta$ is
    a component of the utopian point, $\bar{z}_i$ is a component of the reference point,
    $\rho$ and $\delta$ are small scalar values, $S$ is the feasible solution
    space of the original problem, and $\alpha$ is an auxiliary variable.

    References:
        Wierzbicki, A. P. (1982). A mathematical basis for satisficing decision
            making. Mathematical modelling, 3(5), 391-405.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar to define the utopian point. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.

    Todo:
        Add reference in augmentation term option!
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing value for one or more objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # define the objective function of the scalarization
    aug_expr = " + ".join(
        [
            (f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta})")
            for obj in problem.objectives
        ]
    )

    target_expr = f"_alpha + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="ASF scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []

    for obj in problem.objectives:
        expr = (
            f"({obj.symbol}_min - {corrected_rp[obj.symbol]}) / "
            f"({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta}) - _alpha"
        )

        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_asf_generic_diff

add_asf_generic_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float],
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the generic achievement scalarizing function.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{w_i} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0,\\ & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(q_i\) is a component of the reference point, and \(w_i\) are components of the weight vector (which are assumed to be positive), \(\rho\) and \(\delta\) are small scalar values, \(S\) is the feasible solution space of the original problem, and \(\alpha\) is an auxiliary variable. The summation term in the scalarization is known as the augmentation term. If a reference point is chosen to be used in the augmentation term, e.g., a separate reference point for the augmentation term is given (reference_point_aug), then the reference point components are subtracted from the objective function values in the nominator of the augmentation term. That is:

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x}) - q_i}{w_i} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0,\\ & \mathbf{x} \in S, \end{align*}\]
References

Wierzbicki, A. P. (1982). A mathematical basis for satisficing decision making. Mathematical modelling, 3(5), 391-405.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
weights dict[str, float]

the weights to be used in the scalarization function. Must be positive.

required
reference_point_aug dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components for the augmentation term, i.e., aspiration levels. Defeults to None.

None
weights_aug dict[str, float]

the weights to be used in the scalarization function's augmentation term. Must be positive. Defaults to None.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/scalarization.py
def add_asf_generic_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float],
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the generic achievement scalarizing function.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{w_i} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0,\\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $q_i$ is a component of the reference point,
    and $w_i$ are components of the weight vector (which are assumed to be positive),
    $\rho$ and $\delta$ are small scalar values, $S$ is the feasible solution
    space of the original problem, and $\alpha$ is an auxiliary variable.
    The summation term in the scalarization is known as the _augmentation term_.
    If a reference point is chosen to be used in the augmentation term, e.g., a separate
    reference point for the augmentation term is given (`reference_point_aug`), then
    the reference point components are subtracted from the objective function values
    in the nominator of the augmentation term. That is:

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x}) - q_i}{w_i} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0,\\
        & \mathbf{x} \in S,
    \end{align*}

    References:
        Wierzbicki, A. P. (1982). A mathematical basis for satisficing decision
            making. Mathematical modelling, 3(5), 391-405.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        weights (dict[str, float]): the weights to be used in the scalarization function. Must be positive.
        reference_point_aug (dict[str, float], optional): a dict with keys corresponding to objective
            function symbols and values to reference point components for the augmentation term, i.e.,
            aspiration levels. Defeults to None.
        weights_aug (dict[str, float], optional): the weights to be used in the scalarization function's
            augmentation term. Must be positive. Defaults to None.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    # check augmentation term reference point
    if reference_point_aug is not None and not objective_dict_has_all_symbols(problem, reference_point_aug):
        msg = (
            f"The given reference point for the augmentation term {reference_point_aug} "
            "does not have a component defined for all the objectives."
        )
        raise ScalarizationError(msg)

    # check the weight vector
    if not objective_dict_has_all_symbols(problem, weights):
        msg = f"The given weight vector {weights} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    # check the weight vector for the augmentation term
    if weights_aug is not None and not objective_dict_has_all_symbols(problem, weights_aug):
        msg = f"The given weight vector {weights_aug} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)
    if reference_point_aug is not None:
        corrected_rp_aug = flip_maximized_objective_values(problem, reference_point_aug)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # define the augmentation term
    if reference_point_aug is None and weights_aug is None:
        # no reference point in augmentation term
        # same weights for both terms
        aug_expr = " + ".join([f"({obj.symbol}_min / {weights[obj.symbol]})" for obj in problem.objectives])
    elif reference_point_aug is None and weights_aug is not None:
        # different weights provided for augmentation term
        aug_expr = " + ".join([f"({obj.symbol}_min / {weights_aug[obj.symbol]})" for obj in problem.objectives])
    elif reference_point_aug is not None and weights_aug is None:
        # reference point in augmentation term
        aug_expr = " + ".join(
            [
                f"(({obj.symbol}_min - {corrected_rp_aug[obj.symbol]}) / {weights[obj.symbol]})"
                for obj in problem.objectives
            ]
        )
    else:
        aug_expr = " + ".join(
            [
                f"(({obj.symbol}_min - {corrected_rp_aug[obj.symbol]}) / {weights_aug[obj.symbol]})"
                for obj in problem.objectives
            ]
        )

    target_expr = f"_alpha + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="Generic ASF scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []

    for obj in problem.objectives:
        expr = f"({obj.symbol}_min - {corrected_rp[obj.symbol]}) / {weights[obj.symbol]} - _alpha"

        # since we are subtracting a constant value, the linearity, convexity,
        # and differentiability of the objective function, and hence the
        # constraint, should not change.
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_asf_generic_nondiff

add_asf_generic_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float],
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the generic achievement scalarizing function to a problem with the given reference point, and weights.

This is the non-differentiable variant of the generic achievement scalarizing function, which means the resulting scalarization function is non-differentiable. Compared to add_asf_nondiff, this variant is useful, when the problem being scalarized does not have a defined ideal or nadir point, or both. The weights should be non-zero to avoid zero division.

The scalarization is defined as follows:

\[\begin{equation} \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{w}) = \underset{i=1,\ldots,k}{\text{max}} \left[ \frac{f_i(\mathbf{x}) - q_i}{w_i} \right] + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x})}{w_i}, \end{equation}\]

where \(\mathbf{q} = [q_1,\dots,q_k]\) is a reference point, \(\mathbf{w} = [w_1,\dots,w_k]\) are weights, \(k\) is the number of objective functions, and \(\delta\) and \(\rho\) are small scalar values. The summation term in the scalarization is known as the augmentation term. If a reference point is chosen to be used in the augmentation term, e.g., a separate reference point for the augmentation term is given (reference_point_aug), then the reference point components are subtracted from the objective function values in the nominator of the augmentation term. That is:

\[\begin{equation} \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{w}) = \underset{i=1,\ldots,k}{\text{max}} \left[ \frac{f_i(\mathbf{x}) - q_i}{w_i} \right] + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x}) - q_i}{w_i}. \end{equation}\]

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
reference_point dict[str, float]

a reference point with as many components as there are objectives.

required
weights dict[str, float]

the weights to be used in the scalarization function. must be positive.

required
reference_point_aug dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components for the augmentation term, i.e., aspiration levels. Defeults to None.

None
weights_aug dict[str, float]

the weights to be used in the scalarization function's augmentation term. Must be positive. Defaults to None.

None
rho float

the weight factor used in the augmentation term. Defaults to 0.000001.

1e-06

Raises:

Type Description
ScalarizationError

If either the reference point or the weights given are missing any of the objective components.

ScalarizationError

If any of the ideal or nadir point values are undefined (None).

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/scalarization.py
def add_asf_generic_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float],
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 0.000001,
) -> tuple[Problem, str]:
    r"""Adds the generic achievement scalarizing function to a problem with the given reference point, and weights.

    This is the non-differentiable variant of the generic achievement scalarizing function, which
    means the resulting scalarization function is non-differentiable. Compared to `add_asf_nondiff`, this
    variant is useful, when the problem being scalarized does not have a defined ideal or nadir point,
    or both. The weights should be non-zero to avoid zero division.

    The scalarization is defined as follows:

    \begin{equation}
        \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{w}) =
        \underset{i=1,\ldots,k}{\text{max}}
        \left[
        \frac{f_i(\mathbf{x}) - q_i}{w_i}
        \right]
        + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x})}{w_i},
    \end{equation}

    where $\mathbf{q} = [q_1,\dots,q_k]$ is a reference point, $\mathbf{w} =
    [w_1,\dots,w_k]$ are weights, $k$ is the number of objective functions, and
    $\delta$ and $\rho$ are small scalar values. The summation term in the
    scalarization is known as the _augmentation term_. If a reference point is
    chosen to be used in the augmentation term, e.g., a separate
    reference point for the augmentation term is given (`reference_point_aug`), then
    the reference point components are subtracted from the objective function values
    in the nominator of the augmentation term. That is:

    \begin{equation}
        \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{w}) =
        \underset{i=1,\ldots,k}{\text{max}}
        \left[
        \frac{f_i(\mathbf{x}) - q_i}{w_i}
        \right]
        + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x}) - q_i}{w_i}.
    \end{equation}

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        reference_point (dict[str, float]): a reference point with as many components as there are objectives.
        weights (dict[str, float]): the weights to be used in the scalarization function. must be positive.
        reference_point_aug (dict[str, float], optional): a dict with keys corresponding to objective
            function symbols and values to reference point components for the augmentation term, i.e.,
            aspiration levels. Defeults to None.
        weights_aug (dict[str, float], optional): the weights to be used in the scalarization function's
            augmentation term. Must be positive. Defaults to None.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 0.000001.

    Raises:
        ScalarizationError: If either the reference point or the weights given are missing any of the objective
            components.
        ScalarizationError: If any of the ideal or nadir point values are undefined (None).

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    # check augmentation term reference point
    if reference_point_aug is not None and not objective_dict_has_all_symbols(problem, reference_point_aug):
        msg = (
            f"The given reference point for the augmentation term {reference_point_aug} "
            "does not have a component defined for all the objectives."
        )
        raise ScalarizationError(msg)

    # check the weight vector
    if not objective_dict_has_all_symbols(problem, weights):
        msg = f"The given weight vector {weights} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    # check the weight vector for the augmentation term
    if weights_aug is not None and not objective_dict_has_all_symbols(problem, weights_aug):
        msg = f"The given weight vector {weights_aug} is missing a value for one or more objectives."
        raise ScalarizationError(msg)

    # get the corrected reference point
    corrected_rp = flip_maximized_objective_values(problem, reference_point)
    if reference_point_aug is not None:
        corrected_rp_aug = flip_maximized_objective_values(problem, reference_point_aug)

    # Build the max term
    max_operands = [
        (f"({obj.symbol}_min - {corrected_rp[obj.symbol]}) / ({weights[obj.symbol]})") for obj in problem.objectives
    ]
    max_term = f"{Op.MAX}({', '.join(max_operands)})"

    # Build the augmentation term
    if reference_point_aug is None and weights_aug is None:
        # no reference point in augmentation term
        # same weights for both terms
        aug_expr = " + ".join([f"({obj.symbol}_min / {weights[obj.symbol]})" for obj in problem.objectives])
    elif reference_point_aug is None and weights_aug is not None:
        # different weights provided for augmentation term
        aug_expr = " + ".join([f"({obj.symbol}_min / {weights_aug[obj.symbol]})" for obj in problem.objectives])
    elif reference_point_aug is not None and weights_aug is None:
        # reference point in augmentation term
        aug_expr = " + ".join(
            [
                f"(({obj.symbol}_min - {corrected_rp_aug[obj.symbol]}) / {weights[obj.symbol]})"
                for obj in problem.objectives
            ]
        )
    else:
        aug_expr = " + ".join(
            [
                f"(({obj.symbol}_min - {corrected_rp_aug[obj.symbol]}) / {weights_aug[obj.symbol]})"
                for obj in problem.objectives
            ]
        )

    # Collect the terms
    sf = f"{max_term} + {rho} * ({aug_expr})"

    # Add the function to the problem
    scalarization_function = ScalarizationFunction(
        name="Generic achievement scalarizing function",
        symbol=symbol,
        func=sf,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )
    return problem.add_scalarization(scalarization_function), symbol

add_asf_nondiff

add_asf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 1e-06,
    rho: float = 1e-06,
    *,
    reference_in_aug=False,
) -> tuple[Problem, str]

Add the achievement scalarizing function for a problem with the reference point.

This is the non-differentiable variant of the achievement scalarizing function, which means the resulting scalarization function is non-differentiable. Requires that the ideal and nadir point have been defined for the problem.

The scalarization is defined as follows:

\[\begin{equation} \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{z}^\star, \mathbf{z}^\text{nad}) = \underset{i=1,\ldots,k}{\text{max}} \left[ \frac{f_i(\mathbf{x}) - q_i}{z^\text{nad}_i - (z_i^\star - \delta)} \right] + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x})}{z_i^\text{nad} - (z_i^\star - \delta)}, \end{equation}\]

where \(\mathbf{q} = [q_1,\dots,q_k]\) is a reference point, \(\mathbf{z^\star} = [z_1^\star,\dots,z_k^\star]\) is the ideal point, \(\mathbf{z}^\text{nad} = [z_1^\text{nad},\dots,z_k^\text{nad}]\) is the nadir point, \(k\) is the number of objective functions, and \(\delta\) and \(\rho\) are small scalar values. The summation term in the scalarization is known as the augmentation term. If the reference point is chosen to be used in the augmentation term (reference_in_aug=True), then the reference point components are subtracted from the objective function values in the nominator of the augmentation term. That is:

\[\begin{equation} \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{z}^\star, \mathbf{z}^\text{nad}) = \underset{i=1,\ldots,k}{\text{max}} \left[ \frac{f_i(\mathbf{x}) - q_i}{z^\text{nad}_i - (z_i^\star - \delta)} \right] + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x}) - q_i}{z_i^\text{nad} - (z_i^\star - \delta)}. \end{equation}\]

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
reference_point dict[str, float]

a reference point as an objective dict.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

the scalar value used to define the utopian point (ideal - delta). Defaults to 0.000001.

1e-06
rho float

the weight factor used in the augmentation term. Defaults to 0.000001.

1e-06
reference_in_aug bool

whether the reference point should be used in the augmentation term as well. Defaults to False.

False

Raises:

Type Description
ScalarizationError

there are missing elements in the reference point, or if any of the ideal or nadir point values are undefined (None).

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/scalarization.py
def add_asf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 0.000001,
    rho: float = 0.000001,
    *,
    reference_in_aug=False,
) -> tuple[Problem, str]:
    r"""Add the achievement scalarizing function for a problem with the reference point.

    This is the non-differentiable variant of the achievement scalarizing function, which
    means the resulting scalarization function is non-differentiable.
    Requires that the ideal and nadir point have been defined for the problem.

    The scalarization is defined as follows:

    \begin{equation}
        \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{z}^\star, \mathbf{z}^\text{nad}) =
        \underset{i=1,\ldots,k}{\text{max}}
        \left[
        \frac{f_i(\mathbf{x}) - q_i}{z^\text{nad}_i - (z_i^\star - \delta)}
        \right]
        + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x})}{z_i^\text{nad} - (z_i^\star - \delta)},
    \end{equation}

    where $\mathbf{q} = [q_1,\dots,q_k]$ is a reference point, $\mathbf{z^\star} = [z_1^\star,\dots,z_k^\star]$
    is the ideal point, $\mathbf{z}^\text{nad} = [z_1^\text{nad},\dots,z_k^\text{nad}]$ is the nadir point, $k$
    is the number of objective functions, and $\delta$ and $\rho$ are small scalar values. The summation term
    in the scalarization is known as the _augmentation term_. If the reference point is chosen to
    be used in the augmentation term (`reference_in_aug=True`), then
    the reference point components are subtracted from the objective function values in the nominator
    of the augmentation term. That is:

    \begin{equation}
        \mathcal{S}_\text{ASF}(F(\mathbf{x}); \mathbf{q}, \mathbf{z}^\star, \mathbf{z}^\text{nad}) =
        \underset{i=1,\ldots,k}{\text{max}}
        \left[
        \frac{f_i(\mathbf{x}) - q_i}{z^\text{nad}_i - (z_i^\star - \delta)}
        \right]
        + \rho\sum_{i=1}^{k} \frac{f_i(\mathbf{x}) - q_i}{z_i^\text{nad} - (z_i^\star - \delta)}.
    \end{equation}

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        reference_point (dict[str, float]): a reference point as an objective dict.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): the scalar value used to define the utopian point (ideal - delta).
            Defaults to 0.000001.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 0.000001.
        reference_in_aug (bool): whether the reference point should be used in
            the augmentation term as well. Defaults to False.

    Raises:
        ScalarizationError: there are missing elements in the reference point, or if any of the ideal or nadir
            point values are undefined (None).

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check that the reference point has all the objective components
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The given reference point {reference_point} does not have a component defined for all the objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    if any(value is None for value in ideal_point.values()) or any(value is None for value in nadir_point.values()):
        msg = f"There are undefined values in either the ideal ({ideal_point}) or the nadir point ({nadir_point})."
        raise ScalarizationError(msg)

    # Build the max term
    max_operands = [
        (
            f"({obj.symbol}_min - {reference_point[obj.symbol]}{' * -1' if obj.maximize else ''}) "
            f"/ ({nadir_point[obj.symbol]} - ({ideal_point[obj.symbol]} - {delta}))"
        )
        for obj in problem.objectives
    ]
    max_term = f"{Op.MAX}({', '.join(max_operands)})"

    # Build the augmentation term
    if not reference_in_aug:
        aug_operands = [
            f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - ({ideal_point[obj.symbol]} - {delta}))"
            for obj in problem.objectives
        ]
    else:
        aug_operands = [
            (
                f"({obj.symbol}_min - {reference_point[obj.symbol]}{' * -1' if obj.maximize else 1}) "
                f"/ ({nadir_point[obj.symbol]} - ({ideal_point[obj.symbol]} - {delta}))"
            )
            for obj in problem.objectives
        ]

    aug_term = " + ".join(aug_operands)

    asf_function = f"{max_term} + {rho} * ({aug_term})"

    # Add the function to the problem
    scalarization_function = ScalarizationFunction(
        name="Achievement scalarizing function",
        symbol=symbol,
        func=asf_function,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )
    return problem.add_scalarization(scalarization_function), symbol

add_desirability_funcs

add_desirability_funcs(
    problem: Problem,
    aspiration_levels: dict[str, float],
    reservation_levels: dict[str, float],
    desirability_levels: dict[str, tuple[float, float]]
    | None = None,
    desirability_func: Literal[
        "Harrington", "MaoMao"
    ] = "Harrington",
) -> tuple[Problem, list[str]]

Adds desirability functions to the problem based on the given aspiration and reservation levels.

Note that the desirability functions are added as scalarization functions to the problem. They are also multiplied by -1 to ensure that "desirability" values can be minimized, as is assumed by the optimizers.

Parameters:

Name Type Description Default
problem Problem

The problem to which the desirability functions should be added.

required
aspiration_levels dict[str, float]

A dictionary with keys corresponding to objective function symbols and values to aspiration levels.

required
reservation_levels dict[str, float]

A dictionary with keys corresponding to objective function symbols and values to reservation levels.

required
desirability_levels dict[str, tuple[float, float]] | None

A dictionary with keys corresponding to objective function symbols and values to desirability levels, where each value is a tuple of (d1, d2). If not given, the default values for d1 and d2 are used, which are 0.9 and 0.1 respectively. Defaults to None.

None
desirability_func str

The type of desirability function to use. Currently, only "Harrington" or "MaoMao" is supported. Defaults to "Harrington".

'Harrington'

Returns:

Name Type Description
Problem Problem

A copy of the problem with the added desirability functions as scalarization functions.

list[str]

list[str]: A list of symbols of the added desirability functions.

Source code in desdeo/tools/scalarization.py
def add_desirability_funcs(
    problem: Problem,
    aspiration_levels: dict[str, float],
    reservation_levels: dict[str, float],
    desirability_levels: dict[str, tuple[float, float]] | None = None,
    desirability_func: Literal["Harrington", "MaoMao"] = "Harrington",
) -> tuple[Problem, list[str]]:
    """Adds desirability functions to the problem based on the given aspiration and reservation levels.

    Note that the desirability functions are added as scalarization functions to the problem. They are also multiplied
    by -1 to ensure that "desirability" values can be minimized, as is assumed by the optimizers.

    Args:
        problem (Problem): The problem to which the desirability functions should be added.
        aspiration_levels (dict[str, float]): A dictionary with keys corresponding to objective function symbols
            and values to aspiration levels.
        reservation_levels (dict[str, float]): A dictionary with keys corresponding to objective function symbols
            and values to reservation levels.
        desirability_levels (dict[str, tuple[float, float]] | None, optional): A dictionary with keys corresponding to
            objective function symbols and values to desirability levels, where each value is a tuple of (d1, d2). If
            not given, the default values for d1 and d2 are used, which are 0.9 and 0.1 respectively. Defaults to None.
        desirability_func (str, optional): The type of desirability function to use. Currently, only "Harrington" or
            "MaoMao" is supported. Defaults to "Harrington".

    Returns:
        Problem: A copy of the problem with the added desirability functions as scalarization functions.
        list[str]: A list of symbols of the added desirability functions.
    """
    if desirability_func == "Harrington":
        create_func = __create_HDF
    elif desirability_func == "MaoMao":
        create_func = __create_MDF
    else:
        raise ScalarizationError(f"Desirability function {desirability_func} is not supported.")

    if desirability_levels is None:
        desirability_levels = {obj.symbol: (0.9, 0.1) for obj in problem.objectives}

    # check that all objectives have aspiration and reservation levels defined
    for obj in problem.objectives:
        if obj.symbol not in aspiration_levels or obj.symbol not in reservation_levels:
            raise ScalarizationError(
                f"Objective {obj.symbol} does not have both aspiration and reservation levels defined."
            )
    maximize: dict[str, int] = {obj.symbol: -1 if obj.maximize else 1 for obj in problem.objectives}
    symbols = []
    problem_: Problem = problem.model_copy(deep=True)
    for obj in problem.objectives:
        d1, d2 = desirability_levels[obj.symbol]
        func = (
            "- ("
            + create_func(
                obj.symbol + "_min",
                aspiration_levels[obj.symbol] * maximize[obj.symbol],
                reservation_levels[obj.symbol] * maximize[obj.symbol],
                d1,
                d2,
            )
            + ")"
        )
        symbols.append(f"{obj.symbol}_d")
        scalarization = ScalarizationFunction(
            name=f"Desirability function for {obj.symbol}",
            symbol=f"{obj.symbol}_d",
            func=func,
            is_linear=False,
            is_convex=False,
            is_twice_differentiable=obj.is_twice_differentiable,
        )
        problem_ = problem_.add_scalarization(scalarization)

    return problem_, symbols

add_epsilon_constraints

add_epsilon_constraints(
    problem: Problem,
    symbol: str,
    constraint_symbols: dict[str, str],
    objective_symbol: str,
    epsilons: dict[str, float],
) -> tuple[Problem, str, list[str]]

Creates expressions for an epsilon constraints scalarization and constraints.

It is assumed that epsilon have been given in a format where each objective is to be minimized.

The scalarization is defined as follows:

\[\begin{equation} \begin{aligned} & \operatorname{min}_{\mathbf{x} \in S} & & f_t(\mathbf{x}) \\ & \text{s.t.} & & f_j(\mathbf{x}) \leq \epsilon_j \text{ for all } j = 1, \ldots ,k, \; j \neq t, \end{aligned} \end{equation}\]

where \(\epsilon_j\) are the epsilon bounds used in the epsilon constraints \(f_j(\mathbf{x}) \leq \epsilon_j\), and \(k\) is the number of objective functions.

Parameters:

Name Type Description Default
problem Problem

the problem to scalarize.

required
symbol str

the symbol of the added objective function to be optimized.

required
constraint_symbols dict[str, str]

a dict with the symbols to be used with the added constraints. The key indicates the name of the objective function the constraint is related to, and the value is the symbol to be used when defining the constraint.

required
objective_symbol str

the objective used as the objective in the epsilon constraint scalarization.

required
epsilons dict[str, float]

the epsilon constraint values in a dict with each key being an objective's symbol. The corresponding value is then used as the epsilon value for the respective objective function.

required

Raises:

Type Description
ScalarizationError

objective_symbol not found in problem definition.

Returns:

Type Description
tuple[Problem, str, list[str]]

tuple[Problem, str, list[str]]: A triple with the first element being a copy of the problem with the added epsilon constraints. The second element is the symbol of the objective to be optimized. The last element is a list with the symbols of the added constraints to the problem.

Source code in desdeo/tools/scalarization.py
def add_epsilon_constraints(
    problem: Problem, symbol: str, constraint_symbols: dict[str, str], objective_symbol: str, epsilons: dict[str, float]
) -> tuple[Problem, str, list[str]]:
    r"""Creates expressions for an epsilon constraints scalarization and constraints.

    It is assumed that epsilon have been given in a format where each objective is to be minimized.

    The scalarization is defined as follows:

    \begin{equation}
    \begin{aligned}
    & \operatorname{min}_{\mathbf{x} \in S}
    & & f_t(\mathbf{x}) \\
    & \text{s.t.}
    & & f_j(\mathbf{x}) \leq \epsilon_j \text{ for all } j = 1, \ldots ,k, \; j \neq t,
    \end{aligned}
    \end{equation}

    where $\epsilon_j$ are the epsilon bounds used in the epsilon constraints $f_j(\mathbf{x}) \leq \epsilon_j$,
    and $k$ is the number of objective functions.

    Args:
        problem (Problem): the problem to scalarize.
        symbol (str): the symbol of the added objective function to be optimized.
        constraint_symbols (dict[str, str]): a dict with the symbols to be used with the added
            constraints. The key indicates the name of the objective function the constraint
            is related to, and the value is the symbol to be used when defining the constraint.
        objective_symbol (str): the objective used as the objective in the epsilon constraint scalarization.
        epsilons (dict[str, float]): the epsilon constraint values in a dict
            with each key being an objective's symbol. The corresponding value
            is then used as the epsilon value for the respective objective function.

    Raises:
        ScalarizationError: `objective_symbol` not found in problem definition.

    Returns:
        tuple[Problem, str, list[str]]: A triple with the first element being a copy of the
            problem with the added epsilon constraints. The second element is the symbol of
            the objective to be optimized. The last element is a list with the symbols
            of the added constraints to the problem.
    """
    if objective_symbol not in (correct_symbols := [objective.symbol for objective in problem.objectives]):
        msg = f"The given objective symbol {objective_symbol} should be one of {correct_symbols}."
        raise ScalarizationError(msg)

    _problem, _ = add_objective_as_scalarization(problem, symbol, objective_symbol)

    # the epsilons must be given such that each objective function is to be minimized
    constraints = [
        Constraint(
            name=f"Epsilon for {obj.symbol}",
            symbol=constraint_symbols[obj.symbol],
            func=["Add", f"{obj.symbol}_min", ["Negate", epsilons[obj.symbol]]],
            cons_type=ConstraintTypeEnum.LTE,
            is_linear=obj.is_linear,
            is_convex=obj.is_convex,
            is_twice_differentiable=obj.is_twice_differentiable,
        )
        for obj in problem.objectives
        if obj.symbol != objective_symbol
    ]

    _problem = _problem.add_constraints(constraints)

    return _problem, symbol, [con.symbol for con in constraints]

add_guess_sf_diff

add_guess_sf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the GUESS scalarizing function.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - \bar{z}_i}, \quad & \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^{nad}}{z_i^{nad} - \bar{z}_i} - \alpha \leq 0 \quad & \forall i \notin I^{\diamond},\\ & \mathbf{x} \in S, \end{align*}\]

where \(f_{i}\) are objective functions, \(z_{i}^{nad}\) is a component of the nadir point, \(\bar{z}_{i}\) is a component of the reference point, \(\rho\) is a small scalar value, and \(S\) is the feasible solution space of the original problem. The index set \(I^\diamond\) represents objective vectors whose values are free to change. The indices belonging to this set are interpreted as those objective vectors whose components in the reference point is set to be the the respective nadir point component of the problem. Note that in Buchanan (1997), the GUESS method considers all objective functions, i.e. \(I^\diamond\) is an empty set. The functionality to have free-to-change objectives was added in Miettinen & Mäkelä (2006).

References

Buchanan, J. T. (1997). A naive approach for solving MCDM problems: The GUESS method. Journal of the Operational Research Society, 48, 202-206.

Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive multiobjective optimization. European Journal of Operational Research, 170(3), 909-922.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/scalarization.py
def add_guess_sf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the GUESS scalarizing function.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - \bar{z}_i},
        \quad & \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^{nad}}{z_i^{nad} - \bar{z}_i}
         - \alpha \leq 0 \quad & \forall i \notin I^{\diamond},\\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_{i}$ are objective functions, $z_{i}^{nad}$ is a component of the
    nadir point, $\bar{z}_{i}$
    is a component of the reference point, $\rho$ is a small scalar
    value, and $S$ is the feasible solution space of the original problem. The
    index set $I^\diamond$ represents objective vectors whose values are free to
    change. The indices belonging to this set are interpreted as those objective
    vectors whose components in the reference point is set to be the the
    respective nadir point component of the problem. Note that in Buchanan (1997),
    the GUESS method considers all objective functions, i.e. $I^\diamond$ is
    an empty set. The functionality to have free-to-change objectives was added
    in Miettinen & Mäkelä (2006).

    References:
        Buchanan, J. T. (1997). A naive approach for solving MCDM problems: The
        GUESS method. Journal of the Operational Research Society, 48, 202-206.

        Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive
        multiobjective optimization. European Journal of Operational Research,
        170(3), 909-922.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing value for one or more objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)

    # the indices that are free to change, set if component of reference point
    # has the corresponding nadir value, or if it is greater than the nadir value
    free_to_change = [
        sym
        for sym in corrected_rp
        if np.isclose(corrected_rp[sym], nadir_point[sym]) or corrected_rp[sym] > nadir_point[sym]
    ]

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # define the objective function of the scalarization
    aug_expr = " + ".join(
        [
            (  # Technically delta should be included (according to the paper), but I'm a rebel and don't want to add it
                f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {ideal_point[obj.symbol]})"
                if obj.symbol in free_to_change
                else f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {corrected_rp[obj.symbol]})"
            )
            for obj in problem.objectives
        ]
    )

    target_expr = f"_alpha + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="GUESS scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []

    for obj in problem.objectives:
        if obj.symbol in free_to_change:
            # if free to change, then do not add a constraint
            continue

        # not free to change, add constraint
        expr = (
            f"({obj.symbol}_min - {nadir_point[obj.symbol]}) / "
            f"({nadir_point[obj.symbol]} - {corrected_rp[obj.symbol]}) - _alpha"
        )

        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_guess_sf_nondiff

add_guess_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the non-differentiable variant of the GUESS scalarizing function.

\[\begin{align*} \underset{\mathbf{x}}{\min}\quad & \underset{i \notin I^\diamond}{\max} \left[ \frac{f_i(\mathbf{x}) - z_i^{nad}}{z_i^{nad} - \bar{z}_i} \right] + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - \bar{z}_i}, \quad & \\ \text{s.t.}\quad & \mathbf{x} \in S, \end{align*}\]

where \(f_{i}\) are objective functions, \(z_{i}^{nad}\) is a component of the nadir point, \(\bar{z}_{i}\) is a component of the reference point, \(\rho\) is a small scalar value, and \(S\) is the feasible solution space of the original problem. The index set \(I^\diamond\) represents objective vectors whose values are free to change. The indices belonging to this set are interpreted as those objective vectors whose components in the reference point is set to be the the respective nadir point component of the problem. Note that in Buchanan (1997), the GUESS method considers all objective functions, i.e. \(I^\diamond\) is an empty set. The functionality to have free-to-change objectives was added in Miettinen & Mäkelä (2006).

References

Buchanan, J. T. (1997). A naive approach for solving MCDM problems: The GUESS method. Journal of the Operational Research Society, 48, 202-206.

Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive multiobjective optimization. European Journal of Operational Research, 170(3), 909-922.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/scalarization.py
def add_guess_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the non-differentiable variant of the GUESS scalarizing function.

    \begin{align*}
        \underset{\mathbf{x}}{\min}\quad & \underset{i \notin I^\diamond}{\max}
        \left[
        \frac{f_i(\mathbf{x}) - z_i^{nad}}{z_i^{nad} - \bar{z}_i}
        \right]
        + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - \bar{z}_i},
        \quad & \\
        \text{s.t.}\quad
        & \mathbf{x} \in S,
    \end{align*}

    where $f_{i}$ are objective functions, $z_{i}^{nad}$ is a component of the
    nadir point, $\bar{z}_{i}$
    is a component of the reference point, $\rho$ is a small scalar
    value, and $S$ is the feasible solution space of the original problem. The
    index set $I^\diamond$ represents objective vectors whose values are free to
    change. The indices belonging to this set are interpreted as those objective
    vectors whose components in the reference point is set to be the the
    respective nadir point component of the problem. Note that in Buchanan (1997),
    the GUESS method considers all objective functions, i.e. $I^\diamond$ is
    an empty set. The functionality to have free-to-change objectives was added
    in Miettinen & Mäkelä (2006).

    References:
        Buchanan, J. T. (1997). A naive approach for solving MCDM problems: The
        GUESS method. Journal of the Operational Research Society, 48, 202-206.

        Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive
        multiobjective optimization. European Journal of Operational Research,
        170(3), 909-922.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing value for one or more objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)

    # the indices that are free to change, set if component of reference point
    # has the corresponding nadir value, or if it is greater than the nadir value
    free_to_change = [
        sym
        for sym in corrected_rp
        if np.isclose(corrected_rp[sym], nadir_point[sym]) or corrected_rp[sym] > nadir_point[sym]
    ]

    # define the max expression of the scalarization
    # if the objective symbol belongs to the class I^diamond, then do not add it
    # to the max expression
    max_expr = ", ".join(
        [
            (
                f"({obj.symbol}_min - {(nadir_point[obj.symbol])}) / "
                f"({nadir_point[obj.symbol]} - {(corrected_rp[obj.symbol])})"
            )
            for obj in problem.objectives
            if obj.symbol not in free_to_change
        ]
    )

    # define the augmentation term
    aug_expr = " + ".join(
        [
            (  # Technically delta should be included (according to the paper), but I'm a rebel and don't want to add it
                f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {ideal_point[obj.symbol]})"
                if obj.symbol in free_to_change
                else f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {corrected_rp[obj.symbol]})"
            )
            for obj in problem.objectives
        ]
    )

    target_expr = f"{Op.MAX}({max_expr}) + {rho}*({aug_expr})"
    scalarization = ScalarizationFunction(
        name="GUESS scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )

    return problem.add_scalarization(scalarization), symbol

add_iopis_funcs

add_iopis_funcs(
    problem: Problem,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, list[str]]

Add the IOPIS GUESS and STOM scalarization functions to the problem.

Source code in desdeo/tools/scalarization.py
def add_iopis_funcs(
    problem: Problem,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> tuple[Problem, list[str]]:
    """Add the IOPIS GUESS and STOM scalarization functions to the problem."""
    symbols = ["iopis_guess", "iopis_stom"]
    _problem, _ = add_guess_sf_nondiff(
        problem=problem,
        symbol=symbols[0],
        reference_point=reference_point,
        ideal=ideal,
        nadir=nadir,
        rho=rho,
    )

    _problem, _ = add_stom_sf_nondiff(
        problem=_problem,
        symbol=symbols[1],
        reference_point=reference_point,
        ideal=ideal,
        delta=delta,
    )
    return _problem, symbols

add_nimbus_sf_diff

add_nimbus_sf_diff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 1e-06,
    rho: float = 1e-06,
) -> Problem

Implements the differentiable variant of the NIMBUS scalarization function.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i =1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - z_i^{\star\star}} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^*}{z_i^{nad} - z_i^{\star\star}} - \alpha \leq 0 \quad & \forall i \in I^< \\ & \frac{f_i(\mathbf{x}) - \hat{z}_i}{z_i^{nad} - z_i^{\star\star}} - \alpha \leq 0 \quad & \forall i \in I^\leq \\ & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0 \quad & \forall i \in I^< \cup I^\leq \cup I^= \\ & f_i(\mathbf{x}) - \epsilon_i \leq 0 \quad & \forall i \in I^\geq \\ & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(f_i(\mathbf{x_c})\) is a component of the current objective function, \(\hat{z}_i\) is an aspiration level, \(\varepsilon_i\) is a reservation level, \(z_i^\star\) is a component of the ideal point, \(z_i^{\star\star} = z_i^\star - \delta\) is a component of the utopian point, \(z_i^\text{nad}\) is a component of the nadir point, \(\rho\) is a small scalar, \(S\) is the feasible solution space of the problem (i.e., it means the other constraints of the problem being solved should be accounted for as well), and \(\alpha\) is an auxiliary variable.

The \(I\)-sets are related to the classifications given to each objective function value in respect to the current objective vector (e.g., by a decision maker). They are as follows:

  • \(I^{<}\): values that should improve,
  • \(I^{\leq}\): values that should improve until a given aspiration level \(\hat{z}_i\),
  • \(I^{=}\): values that are fine as they are,
  • \(I^{\geq}\): values that can be impaired until some reservation level \(\varepsilon_i\), and
  • \(I^{\diamond}\): values that are allowed to change freely (not present explicitly in this scalarization function).

The aspiration levels and the reservation levels are supplied for each classification, when relevant, in the argument classifications as follows:

classifications = {
    "f_1": ("<", None),
    "f_2": ("<=", 42.1),
    "f_3": (">=", 22.2),
    "f_4": ("0", None)
    }

Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple consists of a pair where the left element is the classification (self explanatory, '0' is for objective values that may change freely), the right element is either None or an aspiration or a reservation level depending on the classification.

References

Miettinen, K., & Mäkelä, M. M. (2002). On scalarizing functions in multiobjective optimization. OR Spectrum, 24(2), 193-213.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the scalarization function, i.e., target of the optimization.

required
classifications dict[str, tuple[str, float | None]]

a dict, where the key is a symbol of an objective function, and the value is a tuple with a classification and an aspiration or a reservation level, or None, depending on the classification. See above for an explanation.

required
current_objective_vector dict[str, float]

the current objective vector that corresponds to a Pareto optimal solution. The classifications are assumed to been given in respect to this vector.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 0.000001.

1e-06
rho float

a small scalar used in the augmentation term. Defaults to 0.000001.

1e-06

Returns:

Type Description
Problem

tuple[Problem, str]: a tuple with a copy of the problem with the added scalarizations and the symbol of the scalarization.

Source code in desdeo/tools/scalarization.py
def add_nimbus_sf_diff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 0.000001,
    rho: float = 0.000001,
) -> Problem:
    r"""Implements the differentiable variant of the NIMBUS scalarization function.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i =1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - z_i^{\star\star}} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^*}{z_i^{nad} - z_i^{\star\star}} -
            \alpha \leq 0 \quad & \forall i \in I^< \\
        & \frac{f_i(\mathbf{x}) - \hat{z}_i}{z_i^{nad} - z_i^{\star\star}} - \alpha \leq 0 \quad &
        \forall i \in I^\leq \\
        & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0 \quad & \forall i \in I^< \cup I^\leq \cup I^= \\
        & f_i(\mathbf{x}) - \epsilon_i \leq 0 \quad & \forall i \in I^\geq \\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $f_i(\mathbf{x_c})$ is a component of
    the current objective function, $\hat{z}_i$ is an aspiration level,
    $\varepsilon_i$ is a reservation level, $z_i^\star$ is a component of the
    ideal point, $z_i^{\star\star} = z_i^\star - \delta$ is a component of the
    utopian point, $z_i^\text{nad}$ is a component of the nadir point, $\rho$ is
    a small scalar, $S$ is the feasible solution space of the problem (i.e., it
    means the other constraints of the problem being solved should be accounted
    for as well), and $\alpha$ is an auxiliary variable.

    The $I$-sets are related to the classifications given to each objective function value
    in respect to  the current objective vector (e.g., by a decision maker). They
    are as follows:

    - $I^{<}$: values that should improve,
    - $I^{\leq}$: values that should improve until a given aspiration level $\hat{z}_i$,
    - $I^{=}$: values that are fine as they are,
    - $I^{\geq}$: values that can be impaired until some reservation level $\varepsilon_i$, and
    - $I^{\diamond}$: values that are allowed to change freely (not present explicitly in this scalarization function).

    The aspiration levels and the reservation levels are supplied for each classification, when relevant, in
    the argument `classifications` as follows:

    ```python
    classifications = {
        "f_1": ("<", None),
        "f_2": ("<=", 42.1),
        "f_3": (">=", 22.2),
        "f_4": ("0", None)
        }
    ```

    Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple
    consists of a pair where the left element is the classification (self explanatory, '0' is for objective values
    that may change freely), the right element is either `None` or an aspiration or a reservation level
    depending on the classification.

    References:
        Miettinen, K., & Mäkelä, M. M. (2002). On scalarizing functions in
            multiobjective optimization. OR Spectrum, 24(2), 193-213.


    Args:
        problem (Problem): the problem to be scalarized.
        symbol (str): the symbol given to the scalarization function, i.e., target of the optimization.
        classifications (dict[str, tuple[str, float  |  None]]): a dict, where the key is a symbol
            of an objective function, and the value is a tuple with a classification and an aspiration
            or a reservation level, or `None`, depending on the classification. See above for an
            explanation.
        current_objective_vector (dict[str, float]): the current objective vector that corresponds to
            a Pareto optimal solution. The classifications are assumed to been given in respect to
            this vector.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 0.000001.
        rho (float, optional): a small scalar used in the augmentation term. Defaults to 0.000001.

    Returns:
        tuple[Problem, str]: a tuple with a copy of the problem with the added scalarizations and the
            symbol of the scalarization.
    """
    # check that classifications have been provided for all objective functions
    if not objective_dict_has_all_symbols(problem, classifications):
        msg = (
            f"The given classifications {classifications} do not define "
            "a classification for all the objective functions."
        )
        raise ScalarizationError(msg)

    # check that at least one objective function is allowed to be improved and one is
    # allowed to worsen
    if not any(classifications[obj.symbol][0] in ["<", "<="] for obj in problem.objectives) or not any(
        classifications[obj.symbol][0] in [">=", "0"] for obj in problem.objectives
    ):
        msg = (
            f"The given classifications {classifications} should allow at least one objective function value "
            "to improve and one to worsen."
        )
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # define the objective function of the scalarization
    aug_expr = " + ".join(
        [
            f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta})"
            for obj in problem.objectives
        ]
    )

    target_expr = f"_alpha + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []

    # create all the constraints
    for obj in problem.objectives:
        _symbol = obj.symbol
        match classifications[_symbol]:
            case ("<", _):
                expr = (
                    f"({_symbol}_min - {ideal_point[_symbol]}) / "
                    f"({nadir_point[_symbol] - (ideal_point[_symbol] - delta)}) - _alpha"
                )
                constraints.append(
                    Constraint(
                        name=f"improvement constraint for {_symbol}",
                        symbol=f"{_symbol}_lt",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

                # if obj is to be maximized, then the current objective vector value needs to be multiplied by -1
                expr = f"{_symbol}_min - {current_objective_vector[_symbol]}{' * -1' if obj.maximize else ''}"
                constraints.append(
                    Constraint(
                        name=f"stay at least equal constraint for {_symbol}",
                        symbol=f"{_symbol}_eq",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case ("<=", aspiration):
                # if obj is to be maximized, then the current reservation value needs to be multiplied by -1
                expr = (
                    f"({_symbol}_min - {aspiration}{' * -1' if obj.maximize else ''}) / "
                    f"({nadir_point[_symbol]} - {ideal_point[_symbol] - delta}) - _alpha"
                )
                constraints.append(
                    Constraint(
                        name=f"improvement until constraint for {_symbol}",
                        symbol=f"{_symbol}_lte",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

                # if obj is to be maximized, then the current objective vector value needs to be multiplied by -1
                expr = f"{_symbol}_min - {current_objective_vector[_symbol]}{' * -1' if obj.maximize else ''}"
                constraints.append(
                    Constraint(
                        name=f"stay at least equal constraint for {_symbol}",
                        symbol=f"{_symbol}_eq",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case ("=", _):
                # if obj is to be maximized, then the current objective vector value needs to be multiplied by -1
                expr = f"{_symbol}_min - {current_objective_vector[_symbol]}{' * -1' if obj.maximize else ''}"
                constraints.append(
                    Constraint(
                        name=f"stay at least equal constraint for {_symbol}",
                        symbol=f"{_symbol}_eq",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case (">=", reservation):
                # if obj is to be maximized, then the reservation value needs to be multiplied by -1
                expr = f"{_symbol}_min - {reservation}{' * -1' if obj.maximize else ''}"
                constraints.append(
                    Constraint(
                        name=f"worsen until constriant for {_symbol}",
                        symbol=f"{_symbol}_gte",
                        func=expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case ("0", _):
                # not relevant for this scalarization
                pass
            case (c, _):
                msg = (
                    f"Warning! The classification {c} was supplied, but it is not supported."
                    "Must be one of ['<', '<=', '0', '=', '>=']"
                )

    # add the auxiliary variable, scalarization, and constraints
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_nimbus_sf_nondiff

add_nimbus_sf_nondiff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 1e-06,
    rho: float = 1e-06,
) -> Problem

Implements the non-differentiable variant of the NIMBUS scalarization function.

\[\begin{align*} \underset{\mathbf{x}}{\min} \underset{\substack{j \in I^\leq \\i \in I^<}}{\max} &\left[ \frac{f_i(\mathbf{x}) - z_i^\star}{z_i^\text{nad} - z_i^{\star\star}}, \frac{f_j(\mathbf{x}) - \hat{z}_j}{z_j^\text{nad} - x_j^{\star\star}} \right] +\rho \sum_{i =1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - z_i^{\star\star}} \\ \text{s.t.} \quad & f_i(\mathbf{x}) - f_i(\mathbf{x}^c) \leq 0\quad&\forall i \in I^< \cup I^\leq \cup I^=,\\ & f_i(\mathbf{x}) - \epsilon_i \leq 0\quad&\forall i \in I^\geq,\\ & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(f_i(\mathbf{x_c})\) is a component of the current objective function, \(\hat{z}_i\) is an aspiration level, \(\varepsilon_i\) is a reservation level, \(z_i^\star\) is a component of the ideal point, \(z_i^{\star\star} = z_i^\star - \delta\) is a component of the utopian point, \(z_i^\text{nad}\) is a component of the nadir point, \(\rho\) is a small scalar, and \(S\) is the feasible solution space of the problem (i.e., it means the other constraints of the problem being solved should be accounted for as well).

The \(I\)-sets are related to the classifications given to each objective function value in respect to the current objective vector (e.g., by a decision maker). They are as follows:

  • \(I^{<}\): values that should improve,
  • \(I^{\leq}\): values that should improve until a given aspiration level \(\hat{z}_i\),
  • \(I^{=}\): values that are fine as they are,
  • \(I^{\geq}\): values that can be impaired until some reservation level \(\varepsilon_i\), and
  • \(I^{\diamond}\): values that are allowed to change freely (not present explicitly in this scalarization function).

The aspiration levels and the reservation levels are supplied for each classification, when relevant, in the argument classifications as follows:

classifications = {
    "f_1": ("<", None),
    "f_2": ("<=", 42.1),
    "f_3": (">=", 22.2),
    "f_4": ("0", None)
    }

Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple consists of a pair where the left element is the classification (self explanatory, '0' is for objective values that may change freely), the right element is either None or an aspiration or a reservation level depending on the classification.

References

Miettinen, K., & Mäkelä, M. M. (2002). On scalarizing functions in multiobjective optimization. OR Spectrum, 24(2), 193-213.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the scalarization function, i.e., target of the optimization.

required
classifications dict[str, tuple[str, float | None]]

a dict, where the key is a symbol of an objective function, and the value is a tuple with a classification and an aspiration or a reservation level, or None, depending on the classification. See above for an explanation.

required
current_objective_vector dict[str, float]

the current objective vector that corresponds to a Pareto optimal solution. The classifications are assumed to been given in respect to this vector.

required
ideal dict[str, float]

optional ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

optional nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 0.000001.

1e-06
rho float

a small scalar used in the augmentation term. Defaults to 0.000001.

1e-06

Returns:

Type Description
Problem

tuple[Problem, str]: a tuple with a copy of the problem with the added scalarizations and the symbol of the scalarization.

Source code in desdeo/tools/scalarization.py
def add_nimbus_sf_nondiff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 0.000001,
    rho: float = 0.000001,
) -> Problem:
    r"""Implements the non-differentiable variant of the NIMBUS scalarization function.

    \begin{align*}
        \underset{\mathbf{x}}{\min}
        \underset{\substack{j \in I^\leq \\i \in I^<}}{\max}
        &\left[ \frac{f_i(\mathbf{x}) - z_i^\star}{z_i^\text{nad} - z_i^{\star\star}},
        \frac{f_j(\mathbf{x}) - \hat{z}_j}{z_j^\text{nad} - x_j^{\star\star}} \right]
        +\rho \sum_{i =1}^k \frac{f_i(\mathbf{x})}{z_i^{nad} - z_i^{\star\star}} \\
        \text{s.t.} \quad & f_i(\mathbf{x}) - f_i(\mathbf{x}^c) \leq 0\quad&\forall i \in I^< \cup I^\leq \cup I^=,\\
        & f_i(\mathbf{x}) - \epsilon_i \leq 0\quad&\forall i \in I^\geq,\\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $f_i(\mathbf{x_c})$ is a component of
    the current objective function, $\hat{z}_i$ is an aspiration level,
    $\varepsilon_i$ is a reservation level, $z_i^\star$ is a component of the
    ideal point, $z_i^{\star\star} = z_i^\star - \delta$ is a component of the
    utopian point, $z_i^\text{nad}$ is a component of the nadir point, $\rho$ is
    a small scalar, and $S$ is the feasible solution space of the problem (i.e., it
    means the other constraints of the problem being solved should be accounted
    for as well).

    The $I$-sets are related to the classifications given to each objective function value
    in respect to  the current objective vector (e.g., by a decision maker). They
    are as follows:

    - $I^{<}$: values that should improve,
    - $I^{\leq}$: values that should improve until a given aspiration level $\hat{z}_i$,
    - $I^{=}$: values that are fine as they are,
    - $I^{\geq}$: values that can be impaired until some reservation level $\varepsilon_i$, and
    - $I^{\diamond}$: values that are allowed to change freely (not present explicitly in this scalarization function).

    The aspiration levels and the reservation levels are supplied for each classification, when relevant, in
    the argument `classifications` as follows:

    ```python
    classifications = {
        "f_1": ("<", None),
        "f_2": ("<=", 42.1),
        "f_3": (">=", 22.2),
        "f_4": ("0", None)
        }
    ```

    Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple
    consists of a pair where the left element is the classification (self explanatory, '0' is for objective values
    that may change freely), the right element is either `None` or an aspiration or a reservation level
    depending on the classification.

    References:
        Miettinen, K., & Mäkelä, M. M. (2002). On scalarizing functions in
            multiobjective optimization. OR Spectrum, 24(2), 193-213.


    Args:
        problem (Problem): the problem to be scalarized.
        symbol (str): the symbol given to the scalarization function, i.e., target of the optimization.
        classifications (dict[str, tuple[str, float  |  None]]): a dict, where the key is a symbol
            of an objective function, and the value is a tuple with a classification and an aspiration
            or a reservation level, or `None`, depending on the classification. See above for an
            explanation.
        current_objective_vector (dict[str, float]): the current objective vector that corresponds to
            a Pareto optimal solution. The classifications are assumed to been given in respect to
            this vector.
        ideal (dict[str, float], optional): optional ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): optional nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 0.000001.
        rho (float, optional): a small scalar used in the augmentation term. Defaults to 0.000001.

    Returns:
        tuple[Problem, str]: a tuple with a copy of the problem with the added scalarizations and the
            symbol of the scalarization.
    """
    # check that classifications have been provided for all objective functions
    if not objective_dict_has_all_symbols(problem, classifications):
        msg = (
            f"The given classifications {classifications} do not define "
            "a classification for all the objective functions."
        )
        raise ScalarizationError(msg)

    # check that at least one objective function is allowed to be improved and one is
    # allowed to worsen
    if not any(classifications[obj.symbol][0] in ["<", "<="] for obj in problem.objectives) or not any(
        classifications[obj.symbol][0] in [">=", "0"] for obj in problem.objectives
    ):
        msg = (
            f"The given classifications {classifications} should allow at least one objective function value "
            "to improve and one to worsen."
        )
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)

    # max term and constraints
    max_args = []
    constraints = []

    for obj in problem.objectives:
        _symbol = obj.symbol
        match classifications[_symbol]:
            case ("<", _):
                max_expr = (
                    f"({_symbol}_min - {ideal_point[_symbol]}) / "
                    f"({nadir_point[_symbol]} - {ideal_point[_symbol] - delta})"
                )
                max_args.append(max_expr)

                con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                constraints.append(
                    Constraint(
                        name=f"improvement constraint for {_symbol}",
                        symbol=f"{_symbol}_lt",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

            case ("<=", aspiration):
                # if obj is to be maximized, then the current reservation value needs to be multiplied by -1
                max_expr = (
                    f"({_symbol}_min - {aspiration * -1 if obj.maximize else aspiration}) / "
                    f"({nadir_point[_symbol]} - {ideal_point[_symbol] - delta})"
                )
                max_args.append(max_expr)

                con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                constraints.append(
                    Constraint(
                        name=f"improvement until constraint for {_symbol}",
                        symbol=f"{_symbol}_lte",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

            case ("=", _):
                con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                constraints.append(
                    Constraint(
                        name=f"Stay at least as good constraint for {_symbol}",
                        symbol=f"{_symbol}_eq",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case (">=", reservation):
                con_expr = f"{_symbol}_min - {-1 * reservation if obj.maximize else reservation}"
                constraints.append(
                    Constraint(
                        name=f"Worsen until constraint for {_symbol}",
                        symbol=f"{_symbol}_gte",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case ("0", _):
                # not relevant for this scalarization
                pass
            case (c, _):
                msg = (
                    f"Warning! The classification {c} was supplied, but it is not supported."
                    "Must be one of ['<', '<=', '0', '=', '>=']"
                )

    max_expr = f"Max({','.join(max_args)})"

    # define the objective function of the scalarization
    aug_expr = " + ".join(
        [
            f"{obj.symbol}_min / ({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta})"
            for obj in problem.objectives
        ]
    )

    target_expr = f"{max_expr} + {rho}*({aug_expr})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )

    _problem = problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_objective_as_scalarization

add_objective_as_scalarization(
    problem: Problem, symbol: str, objective_symbol: str
) -> tuple[Problem, str]

Creates a scalarization where one of the problem's objective functions is optimized.

The scalarization is defined as follows:

\[\begin{equation} \operatorname{min}_{\mathbf{x} \in S} f_t(\mathbf{x}), \end{equation}\]

where \(f_t(\mathbf{x})\) is the objective function to be minimized.

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
objective_symbol str

the symbol of the objective function to be optimized.

required

Raises:

Type Description
ScalarizationError

the given objective_symbol does not exist in the problem.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/scalarization.py
def add_objective_as_scalarization(problem: Problem, symbol: str, objective_symbol: str) -> tuple[Problem, str]:
    r"""Creates a scalarization where one of the problem's objective functions is optimized.

    The scalarization is defined as follows:

    \begin{equation}
        \operatorname{min}_{\mathbf{x} \in S} f_t(\mathbf{x}),
    \end{equation}

    where $f_t(\mathbf{x})$ is the objective function to be minimized.

    Args:
        problem (Problem): the problem to which the scalarization should be added.
        symbol (str): the symbol to reference the added scalarization function.
        objective_symbol (str): the symbol of the objective function to be optimized.

    Raises:
        ScalarizationError: the given objective_symbol does not exist in the problem.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check that symbol exists
    if problem.get_objective(objective_symbol, copy=False) is None:
        msg = f"The given objective symbol {objective_symbol} is not defined in the problem.."
        raise ScalarizationError(msg)

    sf = ["Multiply", 1, f"{objective_symbol}_min"]

    original_objective = problem.get_objective(objective_symbol, copy=False)

    # Add the function to the problem
    scalarization_function = ScalarizationFunction(
        name=f"Objective {objective_symbol}",
        symbol=symbol,
        func=sf,
        is_linear=original_objective.is_linear,
        is_convex=original_objective.is_convex,
        is_twice_differentiable=original_objective.is_twice_differentiable,
    )
    return problem.add_scalarization(scalarization_function), symbol

add_stom_sf_diff

add_stom_sf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the STOM scalarizing function.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{\bar{z}_i - z_i^{\star\star}} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^{\star\star}}{\bar{z}_i - z_i^{\star\star}} - \alpha \leq 0 \quad & \forall i = 1,\dots,k\\ & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(z_i^{\star\star} = z_i^\star - \delta\) is a component of the utopian point, \(\bar{z}_i\) is a component of the reference point, \(\rho\) and \(\delta\) are small scalar values, \(S\) is the feasible solution space of the original problem, and \(\alpha\) is an auxiliary variable.

References

H. Nakayama, Y. Sawaragi, Satisficing trade-off method for multiobjective programming, in: M. Grauer, A.P. Wierzbicki (Eds.), Interactive Decision Analysis, Springer Verlag, Berlin, 1984, pp. 113-122.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/scalarization.py
def add_stom_sf_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the STOM scalarizing function.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{\bar{z}_i - z_i^{\star\star}} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^{\star\star}}{\bar{z}_i
        - z_i^{\star\star}} - \alpha \leq 0 \quad & \forall i = 1,\dots,k\\
        & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $z_i^{\star\star} = z_i^\star - \delta$ is
    a component of the utopian point, $\bar{z}_i$ is a component of the reference point,
    $\rho$ and $\delta$ are small scalar values, $S$ is the feasible solution
    space of the original problem,  and $\alpha$ is an auxiliary variable.

    References:
        H. Nakayama, Y. Sawaragi, Satisficing trade-off method for
            multiobjective programming, in: M. Grauer, A.P. Wierzbicki (Eds.),
            Interactive Decision Analysis, Springer Verlag, Berlin, 1984, pp.
            113-122.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing value for one or more objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # define the objective function of the scalarization
    aug_expr = " + ".join(
        [
            f"{obj.symbol}_min / ({(reference_point[obj.symbol] - ideal_point[obj.symbol]) + delta})"
            for obj in problem.objectives
        ]
    )

    target_expr = f"_alpha + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="STOM scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_twice_differentiable=problem.is_twice_differentiable,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
    )

    constraints = []

    for obj in problem.objectives:
        expr = (
            f"({obj.symbol}_min - {ideal_point[obj.symbol] - delta}) / "
            f"({(corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta}) - _alpha"
        )
        constraints.append(
            Constraint(
                name=f"Max constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_maxcon",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_twice_differentiable=obj.is_twice_differentiable,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_stom_sf_nondiff

add_stom_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, str]

Adds the non-differentiable variant of the STOM scalarizing function.

\[\begin{align*} \underset{\mathbf{x}}{\min} \quad & \underset{i=1,\dots,k}{\max}\left[ \frac{f_i(\mathbf{x}) - z_i^{\star\star}}{\bar{z}_i - z_i^{\star\star}} \right] + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{\bar{z}_i - z_i^{\star\star}} \\ \text{s.t.}\quad & \mathbf{x} \in S, \end{align*}\]

where \(f_i\) are objective functions, \(z_i^{\star\star} = z_i^\star - \delta\) is a component of the utopian point, \(\bar{z}_i\) is a component of the reference point, \(\rho\) and \(\delta\) are small scalar values, and \(S\) is the feasible solution space of the original problem.

References

H. Nakayama, Y. Sawaragi, Satisficing trade-off method for multiobjective programming, in: M. Grauer, A.P. Wierzbicki (Eds.), Interactive Decision Analysis, Springer Verlag, Berlin, 1984, pp. 113-122.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_point dict[str, float]

a dict with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/scalarization.py
def add_stom_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the non-differentiable variant of the STOM scalarizing function.

    \begin{align*}
        \underset{\mathbf{x}}{\min} \quad & \underset{i=1,\dots,k}{\max}\left[
            \frac{f_i(\mathbf{x}) - z_i^{\star\star}}{\bar{z}_i - z_i^{\star\star}}
            \right]
            + \rho \sum_{i=1}^k \frac{f_i(\mathbf{x})}{\bar{z}_i - z_i^{\star\star}} \\
        \text{s.t.}\quad & \mathbf{x} \in S,
    \end{align*}

    where $f_i$ are objective functions, $z_i^{\star\star} = z_i^\star - \delta$ is
    a component of the utopian point, $\bar{z}_i$ is a component of the reference point,
    $\rho$ and $\delta$ are small scalar values, and $S$ is the feasible solution
    space of the original problem.

    References:
        H. Nakayama, Y. Sawaragi, Satisficing trade-off method for
            multiobjective programming, in: M. Grauer, A.P. Wierzbicki (Eds.),
            Interactive Decision Analysis, Springer Verlag, Berlin, 1984, pp.
            113-122.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_point (dict[str, float]): a dict with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference point
    if not objective_dict_has_all_symbols(problem, reference_point):
        msg = f"The give reference point {reference_point} is missing value for one or more objectives."
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    corrected_rp = flip_maximized_objective_values(problem, reference_point)

    # define the objective function of the scalarization
    max_expr = ", ".join(
        [
            (
                f"({obj.symbol}_min - {ideal_point[obj.symbol] - delta}) / "
                f"({(corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta})"
            )
            for obj in problem.objectives
        ]
    )
    aug_expr = " + ".join(
        [
            f"{obj.symbol}_min / ({(reference_point[obj.symbol] - ideal_point[obj.symbol]) + delta})"
            for obj in problem.objectives
        ]
    )

    target_expr = f"{Op.MAX}({max_expr}) + {rho}*" + f"({aug_expr})"
    scalarization = ScalarizationFunction(
        name="STOM scalarization objective function",
        symbol=symbol,
        func=target_expr,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )

    return problem.add_scalarization(scalarization), symbol

add_weighted_sums

add_weighted_sums(
    problem: Problem, symbol: str, weights: dict[str, float]
) -> tuple[Problem, str]

Add the weighted sums scalarization to a problem with the given weights.

It is assumed that the weights add to 1.

The scalarization is defined as follows:

\[\begin{equation} \begin{aligned} & \mathcal{S}_\text{WS}(F(\mathbf{x});\mathbf{w}) = \sum_{i=1}^{k} w_i f_i(\mathbf{x}) \\ & \text{s.t.} \sum_{i=1}^{k} w_i = 1, \end{aligned} \end{equation}\]

where \(\mathbf{w} = [w_1,\dots,w_k]\) are the weights and \(k\) is the number of objective functions.

Warning

The weighted sums scalarization is often not capable of finding most Pareto optimal solutions when optimized. It is advised to utilize some better scalarization functions.

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
weights dict[str, float]

the weights. For the method to work, the weights should sum to 1. However, this is not a condition that is checked.

required

Raises:

Type Description
ScalarizationError

if the weights are missing any of the objective components.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/scalarization.py
def add_weighted_sums(problem: Problem, symbol: str, weights: dict[str, float]) -> tuple[Problem, str]:
    r"""Add the weighted sums scalarization to a problem with the given weights.

    It is assumed that the weights add to 1.

    The scalarization is defined as follows:

    \begin{equation}
        \begin{aligned}
        & \mathcal{S}_\text{WS}(F(\mathbf{x});\mathbf{w}) = \sum_{i=1}^{k} w_i f_i(\mathbf{x}) \\
        & \text{s.t.} \sum_{i=1}^{k} w_i = 1,
        \end{aligned}
    \end{equation}

    where $\mathbf{w} = [w_1,\dots,w_k]$ are the weights and $k$ is the number of
    objective functions.

    Warning:
        The weighted sums scalarization is often not capable of finding most Pareto optimal
            solutions when optimized. It is advised to utilize some better scalarization
            functions.

    Args:
        problem (Problem): the problem to which the scalarization should be added.
        symbol (str): the symbol to reference the added scalarization function.
        weights (dict[str, float]): the weights. For the method to work, the weights
            should sum to 1. However, this is not a condition that is checked.

    Raises:
        ScalarizationError: if the weights are missing any of the objective components.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check that the weights have all the objective components
    if not all(obj.symbol in weights for obj in problem.objectives):
        msg = f"The given weight vector {weights} does not have a component defined for all the objectives."
        raise ScalarizationError(msg)

    # Build the sum
    sum_terms = [f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives]

    # aggregate the terms
    sf = " + ".join(sum_terms)

    # Add the function to the problem
    scalarization_function = ScalarizationFunction(
        name="Weighted sums scalarization function",
        symbol=symbol,
        func=sf,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    return problem.add_scalarization(scalarization_function), symbol

create_epsilon_constraints_json

create_epsilon_constraints_json(
    problem: Problem,
    objective_symbol: str,
    epsilons: dict[str, float],
) -> tuple[list[str | int | float], list[str]]

Creates JSON expressions for an epsilon constraints scalarization and constraints.

It is assumed that epsilon have been given in a format where each objective is to be minimized.

Warning

To be deprecated.

Parameters:

Name Type Description Default
problem Problem

the problem to scalarize.

required
objective_symbol str

the objective used as the objective in the epsilon constraint scalarization.

required
epsilons dict[str, float]

the epsilon constraint values in a dict with each key being an objective's symbol.

required

Raises:

Type Description
ScalarizationError

objective_symbol not found in problem definition.

Returns:

Type Description
tuple[list[str | int | float], list[str]]

tuple[list, list]: the first element is the expression of the scalarized objective expressed in MathJSON format. The second element is a list of expressions of the constraints expressed in MathJSON format. The constraints are in less than or equal format.

Source code in desdeo/tools/scalarization.py
def create_epsilon_constraints_json(
    problem: Problem, objective_symbol: str, epsilons: dict[str, float]
) -> tuple[list[str | int | float], list[str]]:
    """Creates JSON expressions for an epsilon constraints scalarization and constraints.

    It is assumed that epsilon have been given in a format where each objective is to be minimized.

    Warning:
        To be deprecated.

    Args:
        problem (Problem): the problem to scalarize.
        objective_symbol (str): the objective used as the objective in the epsilon constraint scalarization.
        epsilons (dict[str, float]): the epsilon constraint values in a dict
            with each key being an objective's symbol.

    Raises:
        ScalarizationError: `objective_symbol` not found in problem definition.

    Returns:
        tuple[list, list]: the first element is the expression of the scalarized objective expressed in MathJSON format.
            The second element is a list of expressions of the constraints expressed in MathJSON format.
            The constraints are in less than or equal format.
    """
    correct_symbols = [objective.symbol for objective in problem.objectives]
    if objective_symbol not in correct_symbols:
        msg = f"The given objective symbol {objective_symbol} should be one of {correct_symbols}."
        raise ScalarizationError(msg)
    correct_symbols.remove(objective_symbol)

    scalarization_expr = ["Multiply", 1, f"{objective_symbol}_min"]

    # the epsilons must be given such that each objective function is to be minimized
    constraint_exprs = [["Add", f"{obj}_min", ["Negate", epsilons[obj]]] for obj in correct_symbols]

    return scalarization_expr, constraint_exprs

objective_dict_has_all_symbols

objective_dict_has_all_symbols(
    problem: Problem, obj_dict: dict[str, float]
) -> bool

Check that a dict has all the objective function symbols of a problem as its keys.

Parameters:

Name Type Description Default
problem Problem

the problem with the objective symbols.

required
obj_dict dict[str, float]

a dict that should have a key for each objective symbol.

required

Returns:

Name Type Description
bool bool

whether all the symbols are present or not.

Source code in desdeo/tools/scalarization.py
def objective_dict_has_all_symbols(problem: Problem, obj_dict: dict[str, float]) -> bool:
    """Check that a dict has all the objective function symbols of a problem as its keys.

    Args:
        problem (Problem): the problem with the objective symbols.
        obj_dict (dict[str, float]): a dict that should have a key for each objective symbol.

    Returns:
        bool: whether all the symbols are present or not.
    """
    return all(obj.symbol in obj_dict for obj in problem.objectives)

Partial scalarization

Scalarization functions that operate on a subset of the problem's objectives.

_build_aug_expr

_build_aug_expr(
    weights_aug: dict[str, float] | None,
    active_objectives: list,
    default_weights: dict[str, float],
    all_objectives: list,
    corrected_rp_aug: dict[str, float] | None,
) -> str

Build the augmentation sum expression string.

Source code in desdeo/tools/partial_scalarization.py
def _build_aug_expr(
    weights_aug: dict[str, float] | None,
    active_objectives: list,
    default_weights: dict[str, float],
    all_objectives: list,
    corrected_rp_aug: dict[str, float] | None,
) -> str:
    """Build the augmentation sum expression string."""
    if weights_aug is not None:
        objectives = [obj for obj in all_objectives if obj.symbol in weights_aug and weights_aug[obj.symbol] != 0]
        weights = weights_aug
    else:
        objectives = active_objectives
        weights = default_weights

    if not objectives:
        return "0"
    terms = []
    for obj in objectives:
        w = weights[obj.symbol]
        obj_term = f"(-1 * {obj.symbol})" if obj.maximize else obj.symbol
        if corrected_rp_aug is not None and obj.symbol in corrected_rp_aug:
            terms.append(f"({obj_term} - {corrected_rp_aug[obj.symbol]}) / ({w})")
        else:
            terms.append(f"({obj_term}) / ({w})")
    return " + ".join(terms)

add_asf_partial_diff

add_asf_partial_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 0.001,
) -> tuple[Problem, str]

Adds a differentiable generic ASF that scalarizes only the objectives in reference_point.

This is the partial version of add_asf_generic_diff. The subset of objectives to scalarize is determined by the keys of reference_point; objectives not present in reference_point are left out of both the max-term constraints and the augmentation sum.

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i \in I} \frac{f_i(\mathbf{x})}{w_i} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0, \quad i \in I,\\ & \mathbf{x} \in S, \end{align*}\]

where \(I\) is the index set of objectives whose symbols appear in reference_point, \(q_i\) are the corresponding aspiration levels, and \(w_i\) are the weights.

When weights is None the weights default to nadir_i - ideal_i (in the minimization-corrected space) for each active objective. In that case every active objective must have both ideal and nadir defined on its Objective instance, otherwise a ScalarizationError is raised.

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization function.

required
reference_point dict[str, float]

maps objective symbols to aspiration levels. Only objectives whose symbols appear here are included in the scalarization.

required
weights dict[str, float] | None

maps the same objective symbols to positive weight values. If None, defaults to nadir - ideal for each active objective (or 1.0 if ideal equals nadir).

None
reference_point_aug dict[str, float] | None

optional separate reference point for the augmentation term. Must cover the same subset as reference_point when provided.

None
weights_aug dict[str, float] | None

optional separate weights for the augmentation term, must be positive. The augmentation term sign-flips maximized objectives internally, so unlike the pre-correction f_i values these weights should never be negative. Must cover the same subset as reference_point when provided.

None
rho float

small scalar multiplier for the augmentation sum. Defaults to 1e-6.

0.001

Returns:

Type Description
tuple[Problem, str]

A tuple of the updated Problem and the symbol of the added scalarization.

Raises:

Type Description
ScalarizationError

if any key in reference_point is not a valid objective symbol in the problem.

ScalarizationError

if weights is provided but does not cover all active objectives.

ScalarizationError

if weights is provided and contains a non-positive value for an active objective.

ScalarizationError

if weights is None and any active objective is missing an ideal or nadir value.

ScalarizationError

if reference_point_aug or weights_aug are provided but do not cover the same subset as reference_point.

Source code in desdeo/tools/partial_scalarization.py
def add_asf_partial_diff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-3,
) -> tuple[Problem, str]:
    r"""Adds a differentiable generic ASF that scalarizes only the objectives in ``reference_point``.

    This is the partial version of `add_asf_generic_diff`.
    The subset of objectives to scalarize is determined by the keys of ``reference_point``;
    objectives not present in ``reference_point`` are left out of both the max-term constraints
    and the augmentation sum.

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i \in I} \frac{f_i(\mathbf{x})}{w_i} \\
        \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - q_i}{w_i} - \alpha \leq 0, \quad i \in I,\\
        & \mathbf{x} \in S,
    \end{align*}

    where $I$ is the index set of objectives whose symbols appear in ``reference_point``,
    $q_i$ are the corresponding aspiration levels, and $w_i$ are the weights.

    When ``weights`` is ``None`` the weights default to ``nadir_i - ideal_i`` (in the
    minimization-corrected space) for each active objective.  In that case every active
    objective must have both ``ideal`` and ``nadir`` defined on its ``Objective`` instance,
    otherwise a `ScalarizationError` is raised.

    Args:
        problem: the problem the scalarization is added to.
        symbol: the symbol given to the added scalarization function.
        reference_point: maps objective symbols to aspiration levels.  Only objectives
            whose symbols appear here are included in the scalarization.
        weights: maps the same objective symbols to positive weight values.  If ``None``,
            defaults to ``nadir - ideal`` for each active objective (or ``1.0`` if ``ideal``
            equals ``nadir``).
        reference_point_aug: optional separate reference point for the augmentation term.
            Must cover the same subset as ``reference_point`` when provided.
        weights_aug: optional separate weights for the augmentation term, must be positive.
            The augmentation term sign-flips maximized objectives internally, so unlike the
            pre-correction ``f_i`` values these weights should never be negative.
            Must cover the same subset as ``reference_point`` when provided.
        rho: small scalar multiplier for the augmentation sum. Defaults to 1e-6.

    Returns:
        A tuple of the updated Problem and the symbol of the added scalarization.

    Raises:
        ScalarizationError: if any key in ``reference_point`` is not a valid objective
            symbol in the problem.
        ScalarizationError: if ``weights`` is provided but does not cover all active objectives.
        ScalarizationError: if ``weights`` is provided and contains a non-positive value for
            an active objective.
        ScalarizationError: if ``weights`` is ``None`` and any active objective is missing
            an ``ideal`` or ``nadir`` value.
        ScalarizationError: if ``reference_point_aug`` or ``weights_aug`` are provided but
            do not cover the same subset as ``reference_point``.
    """
    active_objectives, effective_weights, corrected_rp, corrected_rp_aug = _resolve_partial_asf_params(
        problem, reference_point, weights, reference_point_aug, weights_aug
    )

    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    aug_expr = _build_aug_expr(
        weights_aug, active_objectives, effective_weights, list(problem.objectives), corrected_rp_aug
    )

    target_expr = f"_alpha + {rho}*({aug_expr})"
    scalarization = ScalarizationFunction(
        name="Partial generic ASF scalarization",
        symbol=symbol,
        func=target_expr,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []
    for obj in active_objectives:
        obj_term = f"(-1 * {obj.symbol})" if obj.maximize else obj.symbol
        expr = f"({obj_term} - {corrected_rp[obj.symbol]}) / {effective_weights[obj.symbol]} - _alpha"
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_asf_partial_nondiff

add_asf_partial_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds a non-differentiable partial ASF that scalarizes only the objectives in reference_point.

This is the non-differentiable counterpart of add_asf_partial_diff. The max operator is expressed directly in the objective function rather than being linearised with an auxiliary variable and constraints, making the scalarization non-differentiable.

\[\begin{align*} \min \quad & \underset{i \in I}{\max} \left[\frac{f_i(\mathbf{x}) - q_i}{w_i}\right] + \rho \sum_{i \in I} \frac{f_i(\mathbf{x})}{w_i} \end{align*}\]

where \(I\) is the index set of objectives whose symbols appear in reference_point, \(q_i\) are the corresponding aspiration levels, and \(w_i\) are the weights. When reference_point_aug is provided the augmentation numerator becomes \(f_i(\mathbf{x}) - q_i^{\text{aug}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization function.

required
reference_point dict[str, float]

maps objective symbols to aspiration levels. Only objectives whose symbols appear here are included in the scalarization.

required
weights dict[str, float] | None

maps the same objective symbols to positive weight values. If None, defaults to nadir - ideal for each active objective (or 1.0 if ideal equals nadir).

None
reference_point_aug dict[str, float] | None

optional separate reference point for the augmentation term. Must cover the same subset as reference_point when provided.

None
weights_aug dict[str, float] | None

optional separate weights for the augmentation term, must be positive. The augmentation term sign-flips maximized objectives internally, so unlike the pre-correction f_i values these weights should never be negative. Must cover the same subset as reference_point when provided.

None
rho float

small scalar multiplier for the augmentation sum. Defaults to 1e-6.

1e-06

Returns:

Type Description
tuple[Problem, str]

A tuple of the updated Problem and the symbol of the added scalarization.

Raises:

Type Description
ScalarizationError

if any key in reference_point is not a valid objective symbol.

ScalarizationError

if weights is provided but does not cover all active objectives.

ScalarizationError

if weights is provided and contains a non-positive value for an active objective.

ScalarizationError

if weights is None and any active objective is missing an ideal or nadir value.

ScalarizationError

if reference_point_aug or weights_aug are provided but do not cover the same subset as reference_point.

Source code in desdeo/tools/partial_scalarization.py
def add_asf_partial_nondiff(
    problem: Problem,
    symbol: str,
    reference_point: dict[str, float],
    weights: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds a non-differentiable partial ASF that scalarizes only the objectives in ``reference_point``.

    This is the non-differentiable counterpart of `add_asf_partial_diff`.  The max
    operator is expressed directly in the objective function rather than being linearised
    with an auxiliary variable and constraints, making the scalarization non-differentiable.

    \begin{align*}
        \min \quad & \underset{i \in I}{\max}
            \left[\frac{f_i(\mathbf{x}) - q_i}{w_i}\right]
            + \rho \sum_{i \in I} \frac{f_i(\mathbf{x})}{w_i}
    \end{align*}

    where $I$ is the index set of objectives whose symbols appear in ``reference_point``,
    $q_i$ are the corresponding aspiration levels, and $w_i$ are the weights.  When
    ``reference_point_aug`` is provided the augmentation numerator becomes
    $f_i(\mathbf{x}) - q_i^{\text{aug}}$.

    Args:
        problem: the problem the scalarization is added to.
        symbol: the symbol given to the added scalarization function.
        reference_point: maps objective symbols to aspiration levels.  Only objectives
            whose symbols appear here are included in the scalarization.
        weights: maps the same objective symbols to positive weight values.  If ``None``,
            defaults to ``nadir - ideal`` for each active objective (or ``1.0`` if ``ideal``
            equals ``nadir``).
        reference_point_aug: optional separate reference point for the augmentation term.
            Must cover the same subset as ``reference_point`` when provided.
        weights_aug: optional separate weights for the augmentation term, must be positive.
            The augmentation term sign-flips maximized objectives internally, so unlike the
            pre-correction ``f_i`` values these weights should never be negative.
            Must cover the same subset as ``reference_point`` when provided.
        rho: small scalar multiplier for the augmentation sum. Defaults to 1e-6.

    Returns:
        A tuple of the updated Problem and the symbol of the added scalarization.

    Raises:
        ScalarizationError: if any key in ``reference_point`` is not a valid objective symbol.
        ScalarizationError: if ``weights`` is provided but does not cover all active objectives.
        ScalarizationError: if ``weights`` is provided and contains a non-positive value for
            an active objective.
        ScalarizationError: if ``weights`` is ``None`` and any active objective is missing
            an ``ideal`` or ``nadir`` value.
        ScalarizationError: if ``reference_point_aug`` or ``weights_aug`` are provided but
            do not cover the same subset as ``reference_point``.
    """
    active_objectives, effective_weights, corrected_rp, corrected_rp_aug = _resolve_partial_asf_params(
        problem, reference_point, weights, reference_point_aug, weights_aug
    )

    # Build the max term over active objectives only.
    max_operands = []
    for obj in active_objectives:
        obj_term = f"(-1 * {obj.symbol})" if obj.maximize else obj.symbol
        max_operands.append(f"({obj_term} - {corrected_rp[obj.symbol]}) / ({effective_weights[obj.symbol]})")
    max_term = f"{Op.MAX}({', '.join(max_operands)})"

    aug_expr = _build_aug_expr(
        weights_aug, active_objectives, effective_weights, list(problem.objectives), corrected_rp_aug
    )

    sf = f"{max_term} + {rho} * ({aug_expr})"
    scalarization = ScalarizationFunction(
        name="Partial non-differentiable ASF scalarization",
        symbol=symbol,
        func=sf,
        is_linear=False,
        is_convex=False,
        is_twice_differentiable=False,
    )
    return problem.add_scalarization(scalarization), symbol

add_cumulonimbus_diff

add_cumulonimbus_diff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    delta: float = 1e-06,
    rho: float = 0.001,
) -> tuple[Problem, str]

Adds a differentiable partial NIMBUS scalarization that only covers classified objectives.

This is the partial variant of add_nimbus_sf_diff. Unlike the full NIMBUS scalarization, classifications does not need to cover every objective in the problem — objectives whose symbols are absent from classifications are left unconstrained (treated as free, equivalent to the "0" class).

\[\begin{align*} \min \quad & \alpha + \rho \sum_{i \in I} \frac{f_i(\mathbf{x}) - q_i^{\text{aug}}}{w_i^{\text{aug}}} \\ \text{s.t.} \quad & \frac{f_i(\mathbf{x}) - z_i^\star}{z_i^{\text{nad}} - z_i^{\star\star}} - \alpha \leq 0 & \forall i \in I^< \\ & \frac{f_i(\mathbf{x}) - \hat{z}_i}{z_i^{\text{nad}} - z_i^{\star\star}} - \alpha \leq 0 & \forall i \in I^\leq \\ & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0 & \forall i \in I^< \cup I^\leq \cup I^= \\ & f_i(\mathbf{x}) - \varepsilon_i \leq 0 & \forall i \in I^\geq \\ & \mathbf{x} \in S, \end{align*}\]

where \(I\) is the index set of classified objectives (keys of classifications), \(z_i^{\star\star} = z_i^\star - \delta\) is the utopian point component, \(w_i^{\text{aug}}\) are the augmentation weights (defaulting to \(z_i^{\text{nad}} - z_i^{\star\star}\)), \(q_i^{\text{aug}}\) is the optional augmentation reference point (omitted when not provided), and all other notation follows add_nimbus_sf_diff.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the added scalarization function.

required
classifications dict[str, tuple[str, float | None]]

maps objective symbols to (class, level) tuples. Only objectives listed here participate in the scalarization; others are free. Valid classes: "<", "<=", "=", ">=" (with a level), "0".

required
current_objective_vector dict[str, float]

current objective values needed for <, <=, and = constraints. Must contain entries for every objective classified as "<", "<=", or "=".

required
ideal dict[str, float] | None

ideal point override. If None, derived from problem.

None
nadir dict[str, float] | None

nadir point override. If None, derived from problem.

None
reference_point_aug dict[str, float] | None

optional reference point for the augmentation term. When provided, each augmentation term becomes (f_i - q_i^aug) / w_i^aug instead of f_i / w_i^aug. Must cover every objective present in classifications.

None
weights_aug dict[str, float] | None

optional weights for the augmentation term, must be positive. Replaces the default nadir - utopian weights. The augmentation term sign-flips maximized objectives internally, so these weights should never be negative. Must cover every objective present in classifications.

None
delta float

small scalar for the utopian offset. Defaults to 1e-6.

1e-06
rho float

small scalar for the augmentation term. Defaults to 1e-3.

0.001

Returns:

Type Description
tuple[Problem, str]

A tuple of the updated Problem and the symbol of the added scalarization.

Raises:

Type Description
ScalarizationError

if any key in classifications is not a valid objective symbol.

ScalarizationError

if an objective classified as "<", "<=", or "=" is missing from current_objective_vector.

ScalarizationError

if ideal or nadir cannot be determined for an active objective.

ScalarizationError

if reference_point_aug or weights_aug are provided but do not cover all objectives present in classifications.

Source code in desdeo/tools/partial_scalarization.py
def add_cumulonimbus_diff(
    problem: Problem,
    symbol: str,
    classifications: dict[str, tuple[str, float | None]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    reference_point_aug: dict[str, float] | None = None,
    weights_aug: dict[str, float] | None = None,
    delta: float = 1e-6,
    rho: float = 1e-3,
) -> tuple[Problem, str]:
    r"""Adds a differentiable partial NIMBUS scalarization that only covers classified objectives.

    This is the partial variant of `add_nimbus_sf_diff`.
    Unlike the full NIMBUS scalarization, ``classifications`` does not need to cover every
    objective in the problem — objectives whose symbols are absent from ``classifications``
    are left unconstrained (treated as free, equivalent to the ``"0"`` class).

    \begin{align*}
        \min \quad & \alpha + \rho \sum_{i \in I} \frac{f_i(\mathbf{x}) - q_i^{\text{aug}}}{w_i^{\text{aug}}} \\
        \text{s.t.} \quad
        & \frac{f_i(\mathbf{x}) - z_i^\star}{z_i^{\text{nad}} - z_i^{\star\star}} - \alpha \leq 0
            & \forall i \in I^< \\
        & \frac{f_i(\mathbf{x}) - \hat{z}_i}{z_i^{\text{nad}} - z_i^{\star\star}} - \alpha \leq 0
            & \forall i \in I^\leq \\
        & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0
            & \forall i \in I^< \cup I^\leq \cup I^= \\
        & f_i(\mathbf{x}) - \varepsilon_i \leq 0
            & \forall i \in I^\geq \\
        & \mathbf{x} \in S,
    \end{align*}

    where $I$ is the index set of classified objectives (keys of ``classifications``),
    $z_i^{\star\star} = z_i^\star - \delta$ is the utopian point component,
    $w_i^{\text{aug}}$ are the augmentation weights (defaulting to $z_i^{\text{nad}} - z_i^{\star\star}$),
    $q_i^{\text{aug}}$ is the optional augmentation reference point (omitted when not provided),
    and all other notation follows `add_nimbus_sf_diff`.

    Args:
        problem: the problem to be scalarized.
        symbol: the symbol given to the added scalarization function.
        classifications: maps objective symbols to ``(class, level)`` tuples.
            Only objectives listed here participate in the scalarization; others are free.
            Valid classes: ``"<"``, ``"<="``, ``"="``, ``">="`` (with a level), ``"0"``.
        current_objective_vector: current objective values needed for ``<``, ``<=``, and ``=`` constraints.
            Must contain entries for every objective classified as ``"<"``, ``"<="``, or ``"="``.
        ideal: ideal point override. If ``None``, derived from ``problem``.
        nadir: nadir point override. If ``None``, derived from ``problem``.
        reference_point_aug: optional reference point for the augmentation term. When provided,
            each augmentation term becomes ``(f_i - q_i^aug) / w_i^aug`` instead of
            ``f_i / w_i^aug``. Must cover every objective present in ``classifications``.
        weights_aug: optional weights for the augmentation term, must be positive. Replaces
            the default ``nadir - utopian`` weights. The augmentation term sign-flips
            maximized objectives internally, so these weights should never be negative.
            Must cover every objective present in ``classifications``.
        delta: small scalar for the utopian offset. Defaults to 1e-6.
        rho: small scalar for the augmentation term. Defaults to 1e-3.

    Returns:
        A tuple of the updated Problem and the symbol of the added scalarization.

    Raises:
        ScalarizationError: if any key in ``classifications`` is not a valid objective symbol.
        ScalarizationError: if an objective classified as ``"<"``, ``"<="``, or ``"="`` is
            missing from ``current_objective_vector``.
        ScalarizationError: if ideal or nadir cannot be determined for an active objective.
        ScalarizationError: if ``reference_point_aug`` or ``weights_aug`` are provided but
            do not cover all objectives present in ``classifications``.
    """
    valid_symbols = {obj.symbol for obj in problem.objectives}
    invalid = set(classifications) - valid_symbols
    if invalid:
        msg = f"classifications contains keys that are not objective symbols: {invalid}."
        raise ScalarizationError(msg)

    active_symbols = set(classifications)
    active_objectives = [obj for obj in problem.objectives if obj.symbol in active_symbols]

    need_current = {sym for sym, (cls, _) in classifications.items() if cls in ("<", "<=", "=")}
    missing_current = need_current - set(current_objective_vector)
    if missing_current:
        msg = f"current_objective_vector is missing entries for objectives: {missing_current}."
        raise ScalarizationError(msg)

    _validate_aug_params(active_symbols, reference_point_aug, weights_aug)

    ideal_point, nadir_point = _resolve_ideal_nadir(problem, ideal, nadir)

    corrected_rp_aug = (
        {
            obj.symbol: reference_point_aug[obj.symbol] * -1 if obj.maximize else reference_point_aug[obj.symbol]
            for obj in active_objectives
        }
        if reference_point_aug is not None
        else None
    )
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    default_aug_weights = {
        obj.symbol: nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta) for obj in active_objectives
    }
    aug_expr = _build_aug_expr(
        weights_aug, active_objectives, default_aug_weights, list(problem.objectives), corrected_rp_aug
    )
    scalarization = ScalarizationFunction(
        name="Cumulonimbus scalarization objective function",
        symbol=symbol,
        func=f"_alpha + {rho}*({aug_expr})",
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []
    for obj in active_objectives:
        cls, level = classifications[obj.symbol]
        range_ = f"{nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta}"
        maximize_flip = " * -1" if obj.maximize else ""
        current_val = current_objective_vector.get(obj.symbol, 0.0)
        constraints.extend(
            _classification_constraints(
                obj, cls, level, range_, current_val, maximize_flip, ideal_point[obj.symbol], problem
            )
        )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

Group scalarization

Group scalarization functions split from scalarization.py.

This module contains all functions with 'group' in their name, previously located in scalarization.py.

add_group_asf

add_group_asf(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Add the achievement scalarizing function for multiple decision makers.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
reference_points list[dict[str, float]]

a list of reference points as objective dicts.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 1e-6.

1e-06
rho float

the weight factor used in the augmentation term. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_asf(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the achievement scalarizing function for multiple decision makers.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        reference_points (list[dict[str, float]]): a list of reference points as objective dicts.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 1e-6.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing a value for one or more objectives."
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # form the max and augmentation terms
    max_terms = []
    aug_exprs = []
    for i in range(len(reference_points)):
        corrected_rp = flip_maximized_objective_values(problem, reference_points[i])
        for obj in problem.objectives:
            max_terms.append(f"({weights[obj.symbol]}) * ({obj.symbol}_min - {corrected_rp[obj.symbol]})")

        aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    max_terms = ", ".join(max_terms)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="Achievement scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=False,
    )
    problem = problem.add_scalarization(scalarization_function)
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        constraints = []
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
        problem = problem.add_constraints(constraints)

    return problem, symbol

add_group_asf_agg

add_group_asf_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Add the achievement scalarizing function for multiple decision makers.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels (min aspirations).

required
agg_bounds dict[str, float]

a dictionary of aggregated bounds (max bounds).

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 1e-6.

1e-06
rho float

the weight factor used in the augmentation term. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_asf_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the achievement scalarizing function for multiple decision makers.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels (min aspirations).
        agg_bounds (dict[str, float]): a dictionary of aggregated bounds (max bounds).
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 1e-6.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)
    agg_bounds = flip_maximized_objective_values(problem, agg_bounds)  # calculate the weights

    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # form the max and augmentation terms
    max_terms = []
    aug_exprs = []
    for obj in problem.objectives:
        max_terms.append(f"({weights[obj.symbol]}) * ({obj.symbol}_min - {agg_aspirations[obj.symbol]})")

    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    max_terms = ", ".join(max_terms)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="Achievement scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=False,
    )

    constraints = []

    for obj in problem.objectives:
        expr = f"({obj.symbol}_min - {agg_bounds[obj.symbol]})"
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    problem = problem.add_constraints(constraints)
    problem = problem.add_scalarization(scalarization_function)

    return problem, symbol

add_group_asf_agg_diff

add_group_asf_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1000000.0,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Add the differentiable variant of the achievement scalarizing function for multiple decision makers.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required. The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels (min aspirations).

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 1e-6.

1000000.0
rho float

the weight factor used in the augmentation term. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_asf_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e6,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the differentiable variant of the achievement scalarizing function for multiple decision makers.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.
    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels (min aspirations).
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 1e-6.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )
    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)
    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # form the constaint and augmentation expressions
    # constraint expressions are formed into a list of lists
    con_terms = []
    for obj in problem.objectives:
        con_terms.append(f"(({weights[obj.symbol]}) * ({obj.symbol}_min - {agg_aspirations[obj.symbol]})) - _alpha")
    aug_exprs = []
    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"_alpha + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="Differentiable achievement scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for obj in problem.objectives:
        if type(delta) is dict:
            expr = (
                f"({obj.symbol}_min - {agg_aspirations[obj.symbol]}) / "
                f"({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta[obj.symbol]}) - _alpha"
            )
        else:
            expr = (
                f"({obj.symbol}_min - {agg_aspirations[obj.symbol]}) / "
                f"({nadir_point[obj.symbol]} - {ideal_point[obj.symbol] - delta}) - _alpha"
            )
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_maxcon",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization_function)
    return _problem.add_constraints(constraints), symbol

add_group_asf_diff

add_group_asf_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1000000.0,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Add the differentiable variant of the achievement scalarizing function for multiple decision makers.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem to which the scalarization function should be added.

required
symbol str

the symbol to reference the added scalarization function.

required
reference_points list[dict[str, float]]

a list of reference points as objective dicts.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 1e-6.

1000000.0
rho float

the weight factor used in the augmentation term. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_asf_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e6,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the differentiable variant of the achievement scalarizing function for multiple decision makers.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-\overline{z}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem to which the scalarization function should be added.
        symbol (str): the symbol to reference the added scalarization function.
        reference_points (list[dict[str, float]]): a list of reference points as objective dicts.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 1e-6.
        rho (float, optional): the weight factor used in the augmentation term. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing a value for one or more objectives."
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # form the constaint and augmentation expressions
    # constraint expressions are formed into a list of lists
    con_terms = []
    aug_exprs = []
    for i in range(len(reference_points)):
        corrected_rp = flip_maximized_objective_values(problem, reference_points[i])
        rp = {}
        for obj in problem.objectives:
            rp[obj.symbol] = f"(({weights[obj.symbol]}) * ({obj.symbol}_min - {corrected_rp[obj.symbol]})) - _alpha"
        con_terms.append(rp)
        aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"_alpha + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="Differentiable achievement scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for i in range(len(reference_points)):
        for obj in problem.objectives:
            # since we are subtracting a constant value, the linearity, convexity,
            # and differentiability of the objective function, and hence the
            # constraint, should not change.
            constraints.append(
                Constraint(
                    name=f"Constraint for {obj.symbol}",
                    symbol=f"{obj.symbol}_con_{i + 1}",
                    func=con_terms[i][obj.symbol],
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )

    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization_function)
    return _problem.add_constraints(constraints), symbol

add_group_guess

add_group_guess(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the non-differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of dicts with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_guess(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the non-differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of dicts with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing value for one or more objectives."
            raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # calculate the weights
    weights = []
    for reference_point in reference_points:
        corrected_rp = flip_maximized_objective_values(problem, reference_point)
        if type(delta) is dict:
            weights.append(
                {
                    obj.symbol: 1 / ((nadir_point[obj.symbol] + delta[obj.symbol]) - (corrected_rp[obj.symbol]))
                    for obj in problem.objectives
                }
            )
        else:
            weights.append(
                {
                    obj.symbol: 1 / ((nadir_point[obj.symbol] + delta) - (corrected_rp[obj.symbol]))
                    for obj in problem.objectives
                }
            )

    # form the max term
    max_terms = []
    for i in range(len(reference_points)):
        corrected_rp = flip_maximized_objective_values(problem, reference_points[i])
        for obj in problem.objectives:
            if type(delta) is dict:
                max_terms.append(
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta[obj.symbol]} )"
                )
            else:
                max_terms.append(f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta})")
    max_terms = ", ".join(max_terms)

    # form the augmentation term
    aug_exprs = []
    for i in range(len(reference_points)):
        aug_expr = " + ".join([f"({weights[i][obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho}*({aug_exprs})"
    scalarization_function = ScalarizationFunction(
        name="GUESS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=False,
    )
    problem = problem.add_scalarization(scalarization_function)
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        constraints = []
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
        problem = problem.add_constraints(constraints)

    return problem, symbol

add_group_guess_agg

add_group_guess_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the multiple decision maker variant of the GUESS scalarizing function.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels, i.e., min aspirations.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_guess_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the multiple decision maker variant of the GUESS scalarizing function.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels, i.e., min aspirations.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)
    agg_bounds = flip_maximized_objective_values(problem, agg_bounds)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / ((nadir_point[obj.symbol] + delta[obj.symbol]) - (agg_aspirations[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / ((nadir_point[obj.symbol] + delta) - (agg_aspirations[obj.symbol]))
            for obj in problem.objectives
        }

    # form the max and augmentation terms
    max_terms = []
    aug_exprs = []
    for obj in problem.objectives:
        if type(delta) is dict:
            max_terms.append(
                f"{weights[obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta[obj.symbol]} )"
            )
        else:
            max_terms.append(f"{weights[obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta})")

    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    max_terms = ", ".join(max_terms)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="GUESS scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=False,
    )

    constraints = []

    for obj in problem.objectives:
        expr = f"({obj.symbol}_min - {agg_bounds[obj.symbol]})"
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    problem = problem.add_constraints(constraints)
    problem = problem.add_scalarization(scalarization_function)

    return problem, symbol

add_group_guess_agg_diff

add_group_guess_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required. The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels, i.e., min aspirations.

required
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_guess_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.
    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels, i.e., min aspirations.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / ((nadir_point[obj.symbol] + delta[obj.symbol]) - (agg_aspirations[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / ((nadir_point[obj.symbol] + delta) - (agg_aspirations[obj.symbol]))
            for obj in problem.objectives
        }

    # form the max term
    con_terms = []
    for obj in problem.objectives:
        if type(delta) is dict:
            con_terms.append(
                f"{weights[obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta[obj.symbol]}) - _alpha"
            )
        else:
            con_terms.append(f"{weights[obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta}) - _alpha")

    # form the augmentation term
    aug_exprs = []
    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for obj in problem.objectives:
        expr = (
            f"({obj.symbol}_min - {nadir_point[obj.symbol]}) / "
            f"({nadir_point[obj.symbol]} - {agg_aspirations[obj.symbol]}) - _alpha"
        )
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_maxcon",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]} - _alpha)"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    func = f"_alpha + {rho}*({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="Differentiable GUESS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_guess_diff

add_group_guess_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of dicts with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_guess_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the multiple decision maker variant of the GUESS scalarizing function.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{nad}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - \overline{z}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of dicts with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing value for one or more objectives."
            raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # calculate the weights
    weights = []
    for reference_point in reference_points:
        corrected_rp = flip_maximized_objective_values(problem, reference_point)
        if type(delta) is dict:
            weights.append(
                {
                    obj.symbol: 1 / ((nadir_point[obj.symbol] + delta[obj.symbol]) - (corrected_rp[obj.symbol]))
                    for obj in problem.objectives
                }
            )
        else:
            weights.append(
                {
                    obj.symbol: 1 / ((nadir_point[obj.symbol] + delta) - (corrected_rp[obj.symbol]))
                    for obj in problem.objectives
                }
            )

    # form the max term
    con_terms = []
    for i in range(len(reference_points)):
        corrected_rp = flip_maximized_objective_values(problem, reference_points[i])
        rp = {}
        for obj in problem.objectives:
            if type(delta) is dict:
                rp[obj.symbol] = (
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta[obj.symbol]})"
                    " - _alpha"
                )
            else:
                rp[obj.symbol] = (
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {nadir_point[obj.symbol] + delta}) - _alpha"
                )
        con_terms.append(rp)

    # form the augmentation term
    aug_exprs = []
    for i in range(len(reference_points)):
        aug_expr = " + ".join([f"({weights[i][obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for i in range(len(reference_points)):
        for obj in problem.objectives:
            # since we are subtracting a constant value, the linearity, convexity,
            # and differentiability of the objective function, and hence the
            # constraint, should not change.
            constraints.append(
                Constraint(
                    name=f"Constraint for {obj.symbol}",
                    symbol=f"{obj.symbol}_con_{i + 1}",
                    func=con_terms[i][obj.symbol],
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]} - _alpha)"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    func = f"_alpha + {rho}*({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="Differentiable GUESS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_nimbus

add_group_nimbus(
    problem: Problem,
    symbol: str,
    classifications_list: list[
        dict[str, tuple[str, float | None]]
    ],
    current_objective_vector: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Implements the multiple decision maker variant of the NIMBUS scalarization function.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i\in I^<,j\in I^\leq,d} [w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}), w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\), and \(w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}\).

The \(I\)-sets are related to the classifications given to each objective function value in respect to the current objective vector (e.g., by a decision maker). They are as follows:

  • \(I^{<}\): values that should improve,
  • \(I^{\leq}\): values that should improve until a given aspiration level \(\hat{z}_i\),
  • \(I^{=}\): values that are fine as they are,
  • \(I^{\geq}\): values that can be impaired until some reservation level \(\varepsilon_i\), and
  • \(I^{\diamond}\): values that are allowed to change freely (not present explicitly in this scalarization function).

The aspiration levels and the reservation levels are supplied for each classification, when relevant, in the argument classifications as follows:

classifications = {
    "f_1": ("<", None),
    "f_2": ("<=", 42.1),
    "f_3": (">=", 22.2),
    "f_4": ("0", None)
    }

Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple consists of a pair where the left element is the classification (self explanatory, '0' is for objective values that may change freely), the right element is either None or an aspiration or a reservation level depending on the classification.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the scalarization function, i.e., target of the optimization.

required
classifications_list list[dict[str, tuple[str, float | None]]]

a list of dicts, where the key is a symbol of an objective function, and the value is a tuple with a classification and an aspiration or a reservation level, or None, depending on the classification. See above for an explanation.

required
current_objective_vector dict[str, float]

the current objective vector that corresponds to a Pareto optimal solution. The classifications are assumed to been given in respect to this vector.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 0.000001.

1e-06
rho float

a small scalar used in the augmentation term. Defaults to 0.000001.

1e-06

Raises:

Type Description
ScalarizationError

any of the given classifications do not define a classification for all the objective functions or any of the given classifications do not allow at least one objective function value to improve and one to worsen.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_nimbus(  # noqa: C901
    problem: Problem,
    symbol: str,
    classifications_list: list[dict[str, tuple[str, float | None]]],
    current_objective_vector: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 0.000001,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 0.000001,
) -> tuple[Problem, str]:
    r"""Implements the multiple decision maker variant of the NIMBUS scalarization function.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i\in I^<,j\in I^\leq,d} [w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}),
        w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$, and $w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}$.

    The $I$-sets are related to the classifications given to each objective function value
    in respect to  the current objective vector (e.g., by a decision maker). They
    are as follows:

    - $I^{<}$: values that should improve,
    - $I^{\leq}$: values that should improve until a given aspiration level $\hat{z}_i$,
    - $I^{=}$: values that are fine as they are,
    - $I^{\geq}$: values that can be impaired until some reservation level $\varepsilon_i$, and
    - $I^{\diamond}$: values that are allowed to change freely (not present explicitly in this scalarization function).

    The aspiration levels and the reservation levels are supplied for each classification, when relevant, in
    the argument `classifications` as follows:

    ```python
    classifications = {
        "f_1": ("<", None),
        "f_2": ("<=", 42.1),
        "f_3": (">=", 22.2),
        "f_4": ("0", None)
        }
    ```

    Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple
    consists of a pair where the left element is the classification (self explanatory, '0' is for objective values
    that may change freely), the right element is either `None` or an aspiration or a reservation level
    depending on the classification.

    Args:
        problem (Problem): the problem to be scalarized.
        symbol (str): the symbol given to the scalarization function, i.e., target of the optimization.
        classifications_list (list[dict[str, tuple[str, float  |  None]]]): a list of dicts, where the key is a symbol
            of an objective function, and the value is a tuple with a classification and an aspiration
            or a reservation level, or `None`, depending on the classification. See above for an
            explanation.
        current_objective_vector (dict[str, float]): the current objective vector that corresponds to
            a Pareto optimal solution. The classifications are assumed to been given in respect to
            this vector.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 0.000001.
        rho (float, optional): a small scalar used in the augmentation term. Defaults to 0.000001.

    Raises:
        ScalarizationError: any of the given classifications do not define a classification
            for all the objective functions or any of the given classifications do not allow at
            least one objective function value to improve and one to worsen.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check that classifications have been provided for all objective functions
    for classifications in classifications_list:
        if not objective_dict_has_all_symbols(problem, classifications):
            msg = (
                f"The given classifications {classifications} do not define "
                "a classification for all the objective functions."
            )
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)
    bounds = flip_maximized_objective_values(problem, agg_bounds)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # max term and constraints
    max_args = []
    constraints = []

    for i in range(len(classifications_list)):
        classifications = classifications_list[i]
        for obj in problem.objectives:
            _symbol = obj.symbol
            match classifications[_symbol]:
                case ("<", _):
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {ideal_point[_symbol]})"
                    max_args.append(max_expr)

                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("<=", aspiration):
                    # if obj is to be maximized, then the current aspiration value needs to be multiplied by -1
                    max_expr = (
                        f"{weights[_symbol]} * ({_symbol}_min - {aspiration * -1 if obj.maximize else aspiration})"
                    )
                    max_args.append(max_expr)

                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("=", _):
                    # not relevant for this group scalarization
                    pass

                case ">=":
                    con_expr = f"{_symbol}_min - {bounds[_symbol]} "
                    constraints.append(
                        Constraint(
                            name=f"Worsen until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_gte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("0", _):
                    # not relevant for this scalarization
                    pass
                case (c, _):
                    msg = (
                        f"Warning! The classification {c} was supplied, but it is not supported."
                        "Must be one of ['<', '<=', '0', '=', '>=']"
                    )
    max_expr = f"Max({','.join(max_args)})"

    # form the augmentation term
    aug_exprs = []
    for _ in range(len(classifications_list)):
        aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{max_expr} + {rho} * ({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=False,
    )

    _problem = problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_nimbus_compromise

add_group_nimbus_compromise(
    problem: Problem,
    symbol: str,
    group_classification: dict[
        str,
        tuple[
            Literal["improve", "worsen", "conflict"],
            list[float],
        ],
    ],
    current_objective_vector: dict[str, float],
    *,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    find_compromise: bool = True,
) -> tuple[Problem, str]

Add a group-based NIMBUS scalarization (multiple decision-maker variant) to a Problem.

This function constructs and attaches a NIMBUS-style scalarization objective and corresponding constraints derived from a group-level classification of objectives. It supports three group classification types for each objective: - "improve": the group wants the objective to improve relative to the provided current objective vector; - "worsen": the group accepts a worsening of the objective but enforces an aggregate bound (agg_bounds); - "conflict": the group contains conflicting preferences; when find_compromise is True a compromise target is formed (median) and used like an "improve" preference if the compromise is an improvement, otherwise the original current point is enforced as in the "improve" fallback when find_compromise is False. Behavior summary - Validates that a classification is provided for every objective in the problem. - Ensures an ideal and nadir point are available (uses corrected problem values if not supplied; raises ScalarizationError otherwise). - Converts objective values for maximization problems to the same minimization convention used internally. - Computes normalization weights for each objective using nadir, ideal, and the provided delta (scalar or per-objective dict), i.e. weight_i = 1 / (nadir_i - (ideal_i - delta_i)). - For each objective, depending on the group classification: - "improve": may add a term to the scalarization's max(...) expression if the chosen target represents an improvement; always adds an improvement constraint that enforces the objective to be at least as good as the current point. - "worsen": adds a constraint preventing the objective from exceeding the provided agg_bounds value. - "conflict": if find_compromise is True, selects the median target from the group's values and treats it like an "improve" (if it improves); otherwise enforces the current point via an improvement constraint. - Constructs a scalarization objective of the form: Max(weight_i * (obj_i_min - ideal_i) for selected i) + rho * sum(weight_j * obj_j_min) where obj_k_min denotes the (possibly flipped) objective expression used for minimization in the scalarization and rho is the small augmentation coefficient. - Creates Constraint objects (with names and symbols derived from each objective) and appends them to the problem along with the new ScalarizationFunction. Parameters - problem (Problem): The problem instance to which the scalarization and constraints will be added. The function calls problem.add_scalarization(...) and problem.add_constraints(...). - symbol (str): Symbol/name for the new scalarization (target of optimization). - group_classification (dict[str, tuple[str, list[float]]]): A mapping from objective symbol -> (classification, group_targets). The classification must be one of: "improve", "worsen", "conflict". The second element is a list of numerical target values provided by the group members for that objective. Interpretation: - For "improve": the most ambitious group target is taken (currently the maximum for maximization problems or minimum for minimization problems). - For "worsen": the strictest bound from the group is used to form a bound constraint (implementation currently uses agg_bounds instead). - For "conflict": the median of the group targets is used when find_compromise is True; otherwise treated like enforcing the current point. - current_objective_vector (dict[str, float]): Objective values corresponding to a (reference) Pareto-optimal solution; used as baseline for improvement constraints. - agg_bounds (dict[str, float]): Aggregate bounds that must not be violated for objectives marked as "worsen" (values are converted appropriately for maximization objectives). - delta (dict[str, float] | float, optional): Small utopian offset used to compute normalization weights. If a dict is given it should map objective symbols to deltas; if a scalar is given the same delta is used for all objectives. Default: 1e-6. - ideal (dict[str, float] | None, optional): Ideal point values. If None, the function attempts to obtain a corrected ideal point from the problem instance. - nadir (dict[str, float] | None, optional): Nadir point values. If None, the function attempts to obtain a corrected nadir point from the problem instance. - rho (float, optional): Small augmentation coefficient multiplied by the linear sum of weighted objectives to break ties and enforce weak Pareto optimality. Default: 1e-6. - find_compromise (bool, optional): If True, conflicting objectives use a median compromise target; otherwise conflicts are enforced to keep current values. Default: True.

  • tuple[Problem, str]: A tuple containing: - A new Problem instance (or the original problem mutated/augmented depending on Problem.add_scalarization/Problem.add_constraints semantics) with the scalarization objective and additional constraints appended. - The symbol (str) of the added scalarization.
  • ScalarizationError: if - the group_classification mapping does not provide a classification for every objective in the problem, or - neither an explicit ideal nor a computable corrected ideal is available, or - neither an explicit nadir nor a computable corrected nadir is available.
  • KeyError: if group_classification does not contain entries for objective symbols referenced in the problem (note: this will typically surface as KeyError during processing). Notes and implementation details
  • The function internally flips maximization objectives into a minimization form using flip_maximized_objective_values(...) so all scalarization math assumes minimization semantics.
  • Weight computation uses (nadir - (ideal - delta)); ensure delta is chosen so denominator is positive.
  • The "max" term in the scalarization is constructed from selected improving objectives only; the augmentation term is the (weighted) sum over all objectives.
  • Constraint objects are created with ConstraintTypeEnum.LTE and names/symbols formatted like "improvement constraint for {symbol}" or "Worsen until constraint for {symbol}".
  • The function currently prints group_classification (left for debugging) — this side effect may be removed in production code.
Source code in desdeo/tools/group_scalarization.py
def add_group_nimbus_compromise(
    problem: Problem,
    symbol: str,
    group_classification: dict[str, tuple[Literal["improve", "worsen", "conflict"], list[float]]],
    current_objective_vector: dict[str, float],
    *,
    delta: dict[str, float] | float = 0.000001,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 0.000001,
    find_compromise: bool = True,
) -> tuple[Problem, str]:
    """Add a group-based NIMBUS scalarization (multiple decision-maker variant) to a Problem.

    This function constructs and attaches a NIMBUS-style scalarization objective and
    corresponding constraints derived from a group-level classification of objectives.
    It supports three group classification types for each objective:
    - "improve": the group wants the objective to improve relative to the provided
        current objective vector;
    - "worsen": the group accepts a worsening of the objective but enforces an
        aggregate bound (agg_bounds);
    - "conflict": the group contains conflicting preferences; when find_compromise
        is True a compromise target is formed (median) and used like an "improve"
        preference if the compromise is an improvement, otherwise the original current
        point is enforced as in the "improve" fallback when find_compromise is False.
    Behavior summary
    - Validates that a classification is provided for every objective in the problem.
    - Ensures an ideal and nadir point are available (uses corrected problem values if
        not supplied; raises ScalarizationError otherwise).
    - Converts objective values for maximization problems to the same minimization
        convention used internally.
    - Computes normalization weights for each objective using nadir, ideal, and the
        provided delta (scalar or per-objective dict), i.e. weight_i = 1 / (nadir_i - (ideal_i - delta_i)).
    - For each objective, depending on the group classification:
            - "improve": may add a term to the scalarization's max(...) expression if the
                chosen target represents an improvement; always adds an improvement constraint
                that enforces the objective to be at least as good as the current point.
            - "worsen": adds a constraint preventing the objective from exceeding the
                provided agg_bounds value.
            - "conflict": if find_compromise is True, selects the median target from the
                group's values and treats it like an "improve" (if it improves); otherwise
                enforces the current point via an improvement constraint.
    - Constructs a scalarization objective of the form:
            Max(weight_i * (obj_i_min - ideal_i) for selected i) + rho * sum(weight_j * obj_j_min)
        where obj_k_min denotes the (possibly flipped) objective expression used for
        minimization in the scalarization and rho is the small augmentation coefficient.
    - Creates Constraint objects (with names and symbols derived from each objective)
        and appends them to the problem along with the new ScalarizationFunction.
    Parameters
    - problem (Problem): The problem instance to which the scalarization and constraints
        will be added. The function calls problem.add_scalarization(...) and
        problem.add_constraints(...).
    - symbol (str): Symbol/name for the new scalarization (target of optimization).
    - group_classification (dict[str, tuple[str, list[float]]]):
            A mapping from objective symbol -> (classification, group_targets).
            The classification must be one of: "improve", "worsen", "conflict".
            The second element is a list of numerical target values provided by the group
            members for that objective. Interpretation:
                - For "improve": the most ambitious group target is taken (currently the
                    maximum for maximization problems or minimum for minimization problems).
                - For "worsen": the strictest bound from the group is used to form a bound
                    constraint (implementation currently uses agg_bounds instead).
                - For "conflict": the median of the group targets is used when find_compromise
                    is True; otherwise treated like enforcing the current point.
    - current_objective_vector (dict[str, float]): Objective values corresponding to a
        (reference) Pareto-optimal solution; used as baseline for improvement constraints.
    - agg_bounds (dict[str, float]): Aggregate bounds that must not be violated for
        objectives marked as "worsen" (values are converted appropriately for
        maximization objectives).
    - delta (dict[str, float] | float, optional): Small utopian offset used to compute
        normalization weights. If a dict is given it should map objective symbols to
        deltas; if a scalar is given the same delta is used for all objectives.
        Default: 1e-6.
    - ideal (dict[str, float] | None, optional): Ideal point values. If None, the
        function attempts to obtain a corrected ideal point from the problem instance.
    - nadir (dict[str, float] | None, optional): Nadir point values. If None, the
        function attempts to obtain a corrected nadir point from the problem instance.
    - rho (float, optional): Small augmentation coefficient multiplied by the linear
        sum of weighted objectives to break ties and enforce weak Pareto optimality.
        Default: 1e-6.
    - find_compromise (bool, optional): If True, conflicting objectives use a median
        compromise target; otherwise conflicts are enforced to keep current values.
        Default: True.

    Returns:
    - tuple[Problem, str]: A tuple containing:
            - A new Problem instance (or the original problem mutated/augmented depending
                on Problem.add_scalarization/Problem.add_constraints semantics) with the
                scalarization objective and additional constraints appended.
            - The symbol (str) of the added scalarization.

    Raises:
    - ScalarizationError: if
            - the group_classification mapping does not provide a classification for every
                objective in the problem, or
            - neither an explicit ideal nor a computable corrected ideal is available, or
            - neither an explicit nadir nor a computable corrected nadir is available.
    - KeyError: if group_classification does not contain entries for objective symbols
        referenced in the problem (note: this will typically surface as KeyError during
        processing).
    Notes and implementation details
    - The function internally flips maximization objectives into a minimization form
        using flip_maximized_objective_values(...) so all scalarization math assumes
        minimization semantics.
    - Weight computation uses (nadir - (ideal - delta)); ensure delta is chosen so
        denominator is positive.
    - The "max" term in the scalarization is constructed from selected improving
        objectives only; the augmentation term is the (weighted) sum over all objectives.
    - Constraint objects are created with ConstraintTypeEnum.LTE and names/symbols
        formatted like "improvement constraint for {symbol}" or "Worsen until constraint
        for {symbol}".
    - The function currently prints group_classification (left for debugging) — this
        side effect may be removed in production code.
    """
    # check that classifications have been provided for all objective functions
    if not objective_dict_has_all_symbols(problem, group_classification):
        msg = (
            f"The given classifications {group_classification} do not define "
            "a classification for all the objective functions."
        )
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # max term and constraints
    max_args = []
    constraints = []

    # Derive the group classifications

    for obj in problem.objectives:
        _symbol = obj.symbol
        match group_classification[_symbol][0]:
            case "improve":
                # Take the most ambitious target among the group (could be changed to something else as well)
                target = (
                    -max(group_classification[_symbol][1]) if obj.maximize else min(group_classification[_symbol][1])
                )
                if target < corrected_current_point[_symbol]:
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {target})"
                    max_args.append(max_expr)

                con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                constraints.append(
                    Constraint(
                        name=f"improvement constraint for {_symbol}",
                        symbol=f"{_symbol}_lt",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

            case "worsen":
                # Take the strictest constraint given by the group (could be changed to something else as well)
                target = (
                    -max(group_classification[_symbol][1]) if obj.maximize else min(group_classification[_symbol][1])
                )
                con_expr = f"{_symbol}_min - {target} "
                constraints.append(
                    Constraint(
                        name=f"Worsen until constraint for {_symbol}",
                        symbol=f"{_symbol}_gte",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case "conflict":
                if find_compromise:
                    # Take the median target from the group (could be changed to something else as well)
                    target = np.median(group_classification[_symbol][1])
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {target})"
                    max_args.append(max_expr)
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]} - {
                        (nadir_point[_symbol] - ideal_point[_symbol]) / 20
                    }"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                else:
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
            case _:
                msg = (
                    f"Warning! The classification {group_classification[_symbol]} was "
                    "supplied, but it is not supported. "
                    "Must be one of ['improve', 'worsen', 'conflict']"
                )
    max_expr = f"Max({','.join(max_args)})"

    # form the augmentation term
    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])

    func = f"{max_expr} + {rho} * ({aug_expr})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=False,
    )

    _problem = problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_nimbus_compromise_diff

add_group_nimbus_compromise_diff(
    problem: Problem,
    symbol: str,
    group_classification: dict[
        str,
        tuple[
            Literal["improve", "worsen", "conflict"],
            list[float],
        ],
    ],
    current_objective_vector: dict[str, float],
    *,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    find_compromise: bool = True,
) -> tuple[Problem, str]

Add a group-based NIMBUS scalarization (multiple decision-maker variant) to a Problem.

This function constructs and attaches a NIMBUS-style scalarization objective and corresponding constraints derived from a group-level classification of objectives. It supports three group classification types for each objective: - "improve": the group wants the objective to improve relative to the provided current objective vector; - "worsen": the group accepts a worsening of the objective but enforces an aggregate bound (agg_bounds); - "conflict": the group contains conflicting preferences; when find_compromise is True a compromise target is formed (median) and used like an "improve" preference if the compromise is an improvement, otherwise the original current point is enforced as in the "improve" fallback when find_compromise is False. Behavior summary - Validates that a classification is provided for every objective in the problem. - Ensures an ideal and nadir point are available (uses corrected problem values if not supplied; raises ScalarizationError otherwise). - Converts objective values for maximization problems to the same minimization convention used internally. - Computes normalization weights for each objective using nadir, ideal, and the provided delta (scalar or per-objective dict), i.e. weight_i = 1 / (nadir_i - (ideal_i - delta_i)). - For each objective, depending on the group classification: - "improve": may add a term to the scalarization's max(...) expression if the chosen target represents an improvement; always adds an improvement constraint that enforces the objective to be at least as good as the current point. - "worsen": adds a constraint preventing the objective from exceeding the provided agg_bounds value. - "conflict": if find_compromise is True, selects the median target from the group's values and treats it like an "improve" (if it improves); otherwise enforces the current point via an improvement constraint. - Constructs a scalarization objective of the form: Max(weight_i * (obj_i_min - ideal_i) for selected i) + rho * sum(weight_j * obj_j_min) where obj_k_min denotes the (possibly flipped) objective expression used for minimization in the scalarization and rho is the small augmentation coefficient. - Creates Constraint objects (with names and symbols derived from each objective) and appends them to the problem along with the new ScalarizationFunction. Parameters - problem (Problem): The problem instance to which the scalarization and constraints will be added. The function calls problem.add_scalarization(...) and problem.add_constraints(...). - symbol (str): Symbol/name for the new scalarization (target of optimization). - group_classification (dict[str, tuple[str, list[float]]]): A mapping from objective symbol -> (classification, group_targets). The classification must be one of: "improve", "worsen", "conflict". The second element is a list of numerical target values provided by the group members for that objective. Interpretation: - For "improve": the most ambitious group target is taken (currently the maximum for maximization problems or minimum for minimization problems). - For "worsen": the strictest bound from the group is used to form a bound constraint (implementation currently uses agg_bounds instead). - For "conflict": the median of the group targets is used when find_compromise is True; otherwise treated like enforcing the current point. - current_objective_vector (dict[str, float]): Objective values corresponding to a (reference) Pareto-optimal solution; used as baseline for improvement constraints. - agg_bounds (dict[str, float]): Aggregate bounds that must not be violated for objectives marked as "worsen" (values are converted appropriately for maximization objectives). - delta (dict[str, float] | float, optional): Small utopian offset used to compute normalization weights. If a dict is given it should map objective symbols to deltas; if a scalar is given the same delta is used for all objectives. Default: 1e-6. - ideal (dict[str, float] | None, optional): Ideal point values. If None, the function attempts to obtain a corrected ideal point from the problem instance. - nadir (dict[str, float] | None, optional): Nadir point values. If None, the function attempts to obtain a corrected nadir point from the problem instance. - rho (float, optional): Small augmentation coefficient multiplied by the linear sum of weighted objectives to break ties and enforce weak Pareto optimality. Default: 1e-6. - find_compromise (bool, optional): If True, conflicting objectives use a median compromise target; otherwise conflicts are enforced to keep current values. Default: True.

  • tuple[Problem, str]: A tuple containing: - A new Problem instance (or the original problem mutated/augmented depending on Problem.add_scalarization/Problem.add_constraints semantics) with the scalarization objective and additional constraints appended. - The symbol (str) of the added scalarization.
  • ScalarizationError: if - the group_classification mapping does not provide a classification for every objective in the problem, or - neither an explicit ideal nor a computable corrected ideal is available, or - neither an explicit nadir nor a computable corrected nadir is available.
  • KeyError: if group_classification does not contain entries for objective symbols referenced in the problem (note: this will typically surface as KeyError during processing). Notes and implementation details
  • The function internally flips maximization objectives into a minimization form using flip_maximized_objective_values(...) so all scalarization math assumes minimization semantics.
  • Weight computation uses (nadir - (ideal - delta)); ensure delta is chosen so denominator is positive.
  • The "max" term in the scalarization is constructed from selected improving objectives only; the augmentation term is the (weighted) sum over all objectives.
  • Constraint objects are created with ConstraintTypeEnum.LTE and names/symbols formatted like "improvement constraint for {symbol}" or "Worsen until constraint for {symbol}".
  • The function currently prints group_classification (left for debugging) — this side effect may be removed in production code.
Source code in desdeo/tools/group_scalarization.py
def add_group_nimbus_compromise_diff(
    problem: Problem,
    symbol: str,
    group_classification: dict[str, tuple[Literal["improve", "worsen", "conflict"], list[float]]],
    current_objective_vector: dict[str, float],
    *,
    delta: dict[str, float] | float = 0.000001,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 0.000001,
    find_compromise: bool = True,
) -> tuple[Problem, str]:
    """Add a group-based NIMBUS scalarization (multiple decision-maker variant) to a Problem.

    This function constructs and attaches a NIMBUS-style scalarization objective and
    corresponding constraints derived from a group-level classification of objectives.
    It supports three group classification types for each objective:
    - "improve": the group wants the objective to improve relative to the provided
        current objective vector;
    - "worsen": the group accepts a worsening of the objective but enforces an
        aggregate bound (agg_bounds);
    - "conflict": the group contains conflicting preferences; when find_compromise
        is True a compromise target is formed (median) and used like an "improve"
        preference if the compromise is an improvement, otherwise the original current
        point is enforced as in the "improve" fallback when find_compromise is False.
    Behavior summary
    - Validates that a classification is provided for every objective in the problem.
    - Ensures an ideal and nadir point are available (uses corrected problem values if
        not supplied; raises ScalarizationError otherwise).
    - Converts objective values for maximization problems to the same minimization
        convention used internally.
    - Computes normalization weights for each objective using nadir, ideal, and the
        provided delta (scalar or per-objective dict), i.e. weight_i = 1 / (nadir_i - (ideal_i - delta_i)).
    - For each objective, depending on the group classification:
            - "improve": may add a term to the scalarization's max(...) expression if the
                chosen target represents an improvement; always adds an improvement constraint
                that enforces the objective to be at least as good as the current point.
            - "worsen": adds a constraint preventing the objective from exceeding the
                provided agg_bounds value.
            - "conflict": if find_compromise is True, selects the median target from the
                group's values and treats it like an "improve" (if it improves); otherwise
                enforces the current point via an improvement constraint.
    - Constructs a scalarization objective of the form:
            Max(weight_i * (obj_i_min - ideal_i) for selected i) + rho * sum(weight_j * obj_j_min)
        where obj_k_min denotes the (possibly flipped) objective expression used for
        minimization in the scalarization and rho is the small augmentation coefficient.
    - Creates Constraint objects (with names and symbols derived from each objective)
        and appends them to the problem along with the new ScalarizationFunction.
    Parameters
    - problem (Problem): The problem instance to which the scalarization and constraints
        will be added. The function calls problem.add_scalarization(...) and
        problem.add_constraints(...).
    - symbol (str): Symbol/name for the new scalarization (target of optimization).
    - group_classification (dict[str, tuple[str, list[float]]]):
            A mapping from objective symbol -> (classification, group_targets).
            The classification must be one of: "improve", "worsen", "conflict".
            The second element is a list of numerical target values provided by the group
            members for that objective. Interpretation:
                - For "improve": the most ambitious group target is taken (currently the
                    maximum for maximization problems or minimum for minimization problems).
                - For "worsen": the strictest bound from the group is used to form a bound
                    constraint (implementation currently uses agg_bounds instead).
                - For "conflict": the median of the group targets is used when find_compromise
                    is True; otherwise treated like enforcing the current point.
    - current_objective_vector (dict[str, float]): Objective values corresponding to a
        (reference) Pareto-optimal solution; used as baseline for improvement constraints.
    - agg_bounds (dict[str, float]): Aggregate bounds that must not be violated for
        objectives marked as "worsen" (values are converted appropriately for
        maximization objectives).
    - delta (dict[str, float] | float, optional): Small utopian offset used to compute
        normalization weights. If a dict is given it should map objective symbols to
        deltas; if a scalar is given the same delta is used for all objectives.
        Default: 1e-6.
    - ideal (dict[str, float] | None, optional): Ideal point values. If None, the
        function attempts to obtain a corrected ideal point from the problem instance.
    - nadir (dict[str, float] | None, optional): Nadir point values. If None, the
        function attempts to obtain a corrected nadir point from the problem instance.
    - rho (float, optional): Small augmentation coefficient multiplied by the linear
        sum of weighted objectives to break ties and enforce weak Pareto optimality.
        Default: 1e-6.
    - find_compromise (bool, optional): If True, conflicting objectives use a median
        compromise target; otherwise conflicts are enforced to keep current values.
        Default: True.

    Returns:
    - tuple[Problem, str]: A tuple containing:
            - A new Problem instance (or the original problem mutated/augmented depending
                on Problem.add_scalarization/Problem.add_constraints semantics) with the
                scalarization objective and additional constraints appended.
            - The symbol (str) of the added scalarization.

    Raises:
    - ScalarizationError: if
            - the group_classification mapping does not provide a classification for every
                objective in the problem, or
            - neither an explicit ideal nor a computable corrected ideal is available, or
            - neither an explicit nadir nor a computable corrected nadir is available.
    - KeyError: if group_classification does not contain entries for objective symbols
        referenced in the problem (note: this will typically surface as KeyError during
        processing).
    Notes and implementation details
    - The function internally flips maximization objectives into a minimization form
        using flip_maximized_objective_values(...) so all scalarization math assumes
        minimization semantics.
    - Weight computation uses (nadir - (ideal - delta)); ensure delta is chosen so
        denominator is positive.
    - The "max" term in the scalarization is constructed from selected improving
        objectives only; the augmentation term is the (weighted) sum over all objectives.
    - Constraint objects are created with ConstraintTypeEnum.LTE and names/symbols
        formatted like "improvement constraint for {symbol}" or "Worsen until constraint
        for {symbol}".
    - The function currently prints group_classification (left for debugging) — this
        side effect may be removed in production code.
    """
    # check that classifications have been provided for all objective functions
    if not objective_dict_has_all_symbols(problem, group_classification):
        msg = (
            f"The given classifications {group_classification} do not define "
            "a classification for all the objective functions."
        )
        raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    # max term and constraints
    constraints = []

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # Derive the group classifications

    for obj in problem.objectives:
        _symbol = obj.symbol
        match group_classification[_symbol][0]:
            case "improve":
                # Take the most ambitious target among the group (could be changed to something else as well)
                target = (
                    -max(group_classification[_symbol][1]) if obj.maximize else min(group_classification[_symbol][1])
                )
                if target < corrected_current_point[_symbol]:
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {target}) - _alpha"
                    constraints.append(
                        Constraint(
                            name=f"Max term linearization for {_symbol}",
                            symbol=f"max_con_{_symbol}",
                            func=max_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )

                con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                constraints.append(
                    Constraint(
                        name=f"improvement constraint for {_symbol}",
                        symbol=f"{_symbol}_lt",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )

            case "worsen":
                # Take the strictest constraint given by the group (could be changed to something else as well)
                target = (
                    -max(group_classification[_symbol][1]) if obj.maximize else min(group_classification[_symbol][1])
                )
                con_expr = f"{_symbol}_min - {target} "
                constraints.append(
                    Constraint(
                        name=f"Worsen until constraint for {_symbol}",
                        symbol=f"{_symbol}_gte",
                        func=con_expr,
                        cons_type=ConstraintTypeEnum.LTE,
                        is_linear=problem.is_linear,
                        is_convex=problem.is_convex,
                        is_twice_differentiable=problem.is_twice_differentiable,
                    )
                )
            case "conflict":
                if find_compromise:
                    # Take the median target from the group (could be changed to something else as well)
                    target = np.median(group_classification[_symbol][1])
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {target}) - _alpha"
                    constraints.append(
                        Constraint(
                            name=f"Max term linearization for {_symbol}",
                            symbol=f"max_con_{_symbol}",
                            func=max_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]} - {
                        (nadir_point[_symbol] - ideal_point[_symbol]) / 20
                    }"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                else:
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
            case _:
                msg = (
                    f"Warning! The classification {group_classification[_symbol]} was "
                    "supplied, but it is not supported."
                    "Must be one of ['improve', 'worsen', 'conflict']"
                )

    # form the augmentation term
    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])

    func = f"_alpha + {rho} * ({aug_expr})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_nimbus_diff

add_group_nimbus_diff(
    problem: Problem,
    symbol: str,
    classifications_list: list[
        dict[str, tuple[str, float | None]]
    ],
    current_objective_vector: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Implements the differentiable variant of the multiple decision maker of the group NIMBUS scalarization function.

The scalarization function is defined as follows:

\[\begin{align} \mbox{minimize} \quad &\alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x})\\ \mbox{subject to} \quad & w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}) - \alpha \leq 0 \quad & \forall i \in I^<,\\ & w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd}) - \alpha \leq 0 \quad & \forall j \in I^\leq ,\\ & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0 \quad & \forall i \in I^< \cup I^\leq \cup I^= ,\\ & f_i(\mathbf{x}) - \epsilon_i \leq 0 \quad & \forall i \in I^\geq ,\\ & \mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\), and \(w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}\).

The \(I\)-sets are related to the classifications given to each objective function value in respect to the current objective vector (e.g., by a decision maker). They are as follows:

  • \(I^{<}\): values that should improve,
  • \(I^{\leq}\): values that should improve until a given aspiration level \(\hat{z}_i\),
  • \(I^{=}\): values that are fine as they are,
  • \(I^{\geq}\): values that can be impaired until some reservation level \(\varepsilon_i\), and
  • \(I^{\diamond}\): values that are allowed to change freely (not present explicitly in this scalarization function).

The aspiration levels and the reservation levels are supplied for each classification, when relevant, in the argument classifications as follows:

classifications = {
    "f_1": ("<", None),
    "f_2": ("<=", 42.1),
    "f_3": (">=", 22.2),
    "f_4": ("0", None)
    }

Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple consists of a pair where the left element is the classification (self explanatory, '0' is for objective values that may change freely), the right element is either None or an aspiration or a reservation level depending on the classification.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the scalarization function, i.e., target of the optimization.

required
classifications_list list[dict[str, tuple[str, float | None]]]

a list of dicts, where the key is a symbol of an objective function, and the value is a tuple with a classification and an aspiration or a reservation level, or None, depending on the classification. See above for an explanation.

required
current_objective_vector dict[str, float]

the current objective vector that corresponds to a Pareto optimal solution. The classifications are assumed to been given in respect to this vector.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 0.000001.

1e-06
rho float

a small scalar used in the augmentation term. Defaults to 0.000001.

1e-06

Raises:

Type Description
ScalarizationError

any of the given classifications do not define a classification for all the objective functions or any of the given classifications do not allow at least one objective function value to improve and one to worsen.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_nimbus_diff(  # noqa: C901
    problem: Problem,
    symbol: str,
    classifications_list: list[dict[str, tuple[str, float | None]]],
    current_objective_vector: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 0.000001,
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 0.000001,
) -> tuple[Problem, str]:
    r"""Implements the differentiable variant of the multiple decision maker of the group NIMBUS scalarization function.

    The scalarization function is defined as follows:

    \begin{align}
        \mbox{minimize} \quad
         &\alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x})\\
        \mbox{subject to} \quad & w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}) - \alpha \leq 0 \quad & \forall i \in I^<,\\
        & w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd}) - \alpha \leq 0 \quad & \forall j \in I^\leq ,\\
        & f_i(\mathbf{x}) - f_i(\mathbf{x_c}) \leq 0 \quad & \forall i \in I^< \cup I^\leq \cup I^= ,\\
        & f_i(\mathbf{x}) - \epsilon_i \leq 0 \quad & \forall i \in I^\geq ,\\
        & \mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$, and $w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}$.

    The $I$-sets are related to the classifications given to each objective function value
    in respect to  the current objective vector (e.g., by a decision maker). They
    are as follows:

    - $I^{<}$: values that should improve,
    - $I^{\leq}$: values that should improve until a given aspiration level $\hat{z}_i$,
    - $I^{=}$: values that are fine as they are,
    - $I^{\geq}$: values that can be impaired until some reservation level $\varepsilon_i$, and
    - $I^{\diamond}$: values that are allowed to change freely (not present explicitly in this scalarization function).

    The aspiration levels and the reservation levels are supplied for each classification, when relevant, in
    the argument `classifications` as follows:

    ```python
    classifications = {
        "f_1": ("<", None),
        "f_2": ("<=", 42.1),
        "f_3": (">=", 22.2),
        "f_4": ("0", None)
        }
    ```

    Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple
    consists of a pair where the left element is the classification (self explanatory, '0' is for objective values
    that may change freely), the right element is either `None` or an aspiration or a reservation level
    depending on the classification.

    Args:
        problem (Problem): the problem to be scalarized.
        symbol (str): the symbol given to the scalarization function, i.e., target of the optimization.
        classifications_list (list[dict[str, tuple[str, float  |  None]]]): a list of dicts, where the key is a symbol
            of an objective function, and the value is a tuple with a classification and an aspiration
            or a reservation level, or `None`, depending on the classification. See above for an
            explanation.
        current_objective_vector (dict[str, float]): the current objective vector that corresponds to
            a Pareto optimal solution. The classifications are assumed to been given in respect to
            this vector.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 0.000001.
        rho (float, optional): a small scalar used in the augmentation term. Defaults to 0.000001.

    Raises:
        ScalarizationError: any of the given classifications do not define a classification
            for all the objective functions or any of the given classifications do not allow at
            least one objective function value to improve and one to worsen.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check that classifications have been provided for all objective functions
    for classifications in classifications_list:
        if not objective_dict_has_all_symbols(problem, classifications):
            msg = (
                f"The given classifications {classifications} do not define "
                "a classification for all the objective functions."
            )
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)
    bounds = flip_maximized_objective_values(problem, agg_bounds)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol]))
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
        }

    constraints = []

    for i in range(len(classifications_list)):
        classifications = classifications_list[i]
        for obj in problem.objectives:
            _symbol = obj.symbol
            match classifications[_symbol]:
                case ("<", _):
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {ideal_point[_symbol]}) - _alpha"
                    constraints.append(
                        Constraint(
                            name=f"Max term linearization for {_symbol}",
                            symbol=f"max_con_{_symbol}_{i + 1}",
                            func=max_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]} - _alpha"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("<=", aspiration):
                    # if obj is to be maximized, then the current aspiration value needs to be multiplied by -1
                    max_expr = (
                        f"{weights[_symbol]} * ({_symbol}_min - {aspiration * -1 if obj.maximize else aspiration}) "
                        "- _alpha"
                    )
                    constraints.append(
                        Constraint(
                            name=f"Max term linearization for {_symbol}",
                            symbol=f"max_con_{_symbol}_{i + 1}",
                            func=max_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]} - _alpha"
                    constraints.append(
                        Constraint(
                            name=f"improvement until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("=", _):
                    # not relevant for this group scalarization
                    pass
                case ">=":
                    con_expr = f"{_symbol}_min - {bounds[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"Worsen until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_gte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("0", _):
                    # not relevant for this scalarization
                    pass
                case (c, _):
                    msg = (
                        f"Warning! The classification {c} was supplied, but it is not supported."
                        "Must be one of ['<', '<=', '0', '=', '>=']"
                    )

    # form the augmentation term
    aug_exprs = []
    for _ in range(len(classifications_list)):
        aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"_alpha + {rho} * ({aug_exprs})"
    scalarization_function = ScalarizationFunction(
        name="Differentiable NIMBUS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization_function)
    return _problem.add_constraints(constraints), symbol

add_group_nimbus_sf

add_group_nimbus_sf(
    problem: Problem,
    symbol: str,
    classifications_list: list[
        dict[str, tuple[str, float | None]]
    ],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 1e-06,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Implements the multiple decision maker variant of the NIMBUS scalarization function.

Variant without aggregated bounds.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i\in I^<,j\in I^\leq,d} [w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}), w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}\), and \(w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}\).

The \(I\)-sets are related to the classifications given to each objective function value in respect to the current objective vector (e.g., by a decision maker). They are as follows:

  • \(I^{<}\): values that should improve,
  • \(I^{\leq}\): values that should improve until a given aspiration level \(\hat{z}_i\),
  • \(I^{=}\): values that are fine as they are,
  • \(I^{\geq}\): values that can be impaired until some reservation level \(\varepsilon_i\), and
  • \(I^{\diamond}\): values that are allowed to change freely (not present explicitly in this scalarization function).

The aspiration levels and the reservation levels are supplied for each classification, when relevant, in the argument classifications as follows:

classifications = {
    "f_1": ("<", None),
    "f_2": ("<=", 42.1),
    "f_3": (">=", 22.2),
    "f_4": ("0", None)
    }

Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple consists of a pair where the left element is the classification (self explanatory, '0' is for objective values that may change freely), the right element is either None or an aspiration or a reservation level depending on the classification.

Parameters:

Name Type Description Default
problem Problem

the problem to be scalarized.

required
symbol str

the symbol given to the scalarization function, i.e., target of the optimization.

required
classifications_list list[dict[str, tuple[str, float | None]]]

a list of dicts, where the key is a symbol of an objective function, and the value is a tuple with a classification and an aspiration or a reservation level, or None, depending on the classification. See above for an explanation.

required
current_objective_vector dict[str, float]

the current objective vector that corresponds to a Pareto optimal solution. The classifications are assumed to been given in respect to this vector.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
nadir dict[str, float]

nadir point values. If not given, attempt will be made to calculate nadir point from problem.

None
delta float

a small scalar used to define the utopian point. Defaults to 0.000001.

1e-06
rho float

a small scalar used in the augmentation term. Defaults to 0.000001.

1e-06

Raises:

Type Description
ScalarizationError

any of the given classifications do not define a classification for all the objective functions or any of the given classifications do not allow at least one objective function value to improve and one to worsen.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_nimbus_sf(  # noqa: C901
    problem: Problem,
    symbol: str,
    classifications_list: list[dict[str, tuple[str, float | None]]],
    current_objective_vector: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    delta: float = 0.000001,
    rho: float = 0.000001,
) -> tuple[Problem, str]:
    r"""Implements the multiple decision maker variant of the NIMBUS scalarization function.

    Variant without aggregated bounds.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i\in I^<,j\in I^\leq,d} [w_{id}(f_{id}(\mathbf{x})-z^{ideal}_{id}),
        w_{jd}(f_{jd}(\mathbf{x})-\hat{z}_{jd})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{z^{nad}_{id} - z^{uto}_{id}}$, and $w_{jd} = \frac{1}{z^{nad}_{jd} - z^{uto}_{jd}}$.

    The $I$-sets are related to the classifications given to each objective function value
    in respect to  the current objective vector (e.g., by a decision maker). They
    are as follows:

    - $I^{<}$: values that should improve,
    - $I^{\leq}$: values that should improve until a given aspiration level $\hat{z}_i$,
    - $I^{=}$: values that are fine as they are,
    - $I^{\geq}$: values that can be impaired until some reservation level $\varepsilon_i$, and
    - $I^{\diamond}$: values that are allowed to change freely (not present explicitly in this scalarization function).

    The aspiration levels and the reservation levels are supplied for each classification, when relevant, in
    the argument `classifications` as follows:

    ```python
    classifications = {
        "f_1": ("<", None),
        "f_2": ("<=", 42.1),
        "f_3": (">=", 22.2),
        "f_4": ("0", None)
        }
    ```

    Here, we have assumed four objective functions. The key of the dict is a function's symbol, and the tuple
    consists of a pair where the left element is the classification (self explanatory, '0' is for objective values
    that may change freely), the right element is either `None` or an aspiration or a reservation level
    depending on the classification.

    Args:
        problem (Problem): the problem to be scalarized.
        symbol (str): the symbol given to the scalarization function, i.e., target of the optimization.
        classifications_list (list[dict[str, tuple[str, float  |  None]]]): a list of dicts, where the key is a symbol
            of an objective function, and the value is a tuple with a classification and an aspiration
            or a reservation level, or `None`, depending on the classification. See above for an
            explanation.
        current_objective_vector (dict[str, float]): the current objective vector that corresponds to
            a Pareto optimal solution. The classifications are assumed to been given in respect to
            this vector.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        nadir (dict[str, float], optional): nadir point values. If not given, attempt will be made
            to calculate nadir point from problem.
        delta (float, optional): a small scalar used to define the utopian point. Defaults to 0.000001.
        rho (float, optional): a small scalar used in the augmentation term. Defaults to 0.000001.

    Raises:
        ScalarizationError: any of the given classifications do not define a classification
            for all the objective functions or any of the given classifications do not allow at
            least one objective function value to improve and one to worsen.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check that classifications have been provided for all objective functions
    for classifications in classifications_list:
        if not objective_dict_has_all_symbols(problem, classifications):
            msg = (
                f"The given classifications {classifications} do not define "
                "a classification for all the objective functions."
            )
            raise ScalarizationError(msg)

        # check that at least one objective function is allowed to be improved and one is
        # allowed to worsen
        if not any(classifications[obj.symbol][0] in ["<", "<="] for obj in problem.objectives) or not any(
            classifications[obj.symbol][0] in [">=", "0"] for obj in problem.objectives
        ):
            msg = (
                f"The given classifications {classifications} should allow at least one objective function value "
                "to improve and one to worsen."
            )
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # check if nadir point is specified
    # if not specified, try to calculate corrected nadir point
    if nadir is not None:
        nadir_point = nadir
    elif problem.get_nadir_point() is not None:
        nadir_point = get_corrected_nadir(problem)
    else:
        msg = "Nadir point not defined!"
        raise ScalarizationError(msg)

    corrected_current_point = flip_maximized_objective_values(problem, current_objective_vector)

    # calculate the weights
    weights = {
        obj.symbol: 1 / (nadir_point[obj.symbol] - (ideal_point[obj.symbol] - delta)) for obj in problem.objectives
    }

    # max term and constraints
    max_args = []
    constraints = []

    for i in range(len(classifications_list)):
        classifications = classifications_list[i]
        for obj in problem.objectives:
            _symbol = obj.symbol
            match classifications[_symbol]:
                case ("<", _):
                    max_expr = f"{weights[_symbol]} * ({_symbol}_min - {ideal_point[_symbol]})"
                    max_args.append(max_expr)

                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lt",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("<=", aspiration):
                    # if obj is to be maximized, then the current aspiration value needs to be multiplied by -1
                    max_expr = (
                        f"{weights[_symbol]} * ({_symbol}_min - {aspiration * -1 if obj.maximize else aspiration})"
                    )
                    max_args.append(max_expr)

                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"improvement until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_lte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("=", _):
                    con_expr = f"{_symbol}_min - {corrected_current_point[_symbol]}"
                    constraints.append(
                        Constraint(
                            name=f"Stay at least as good constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_eq",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case (">=", reservation):
                    # if obj is to be maximized, then the current reservation value needs to be multiplied by -1
                    con_expr = f"{_symbol}_min - {-1 * reservation if obj.maximize else reservation}"
                    constraints.append(
                        Constraint(
                            name=f"Worsen until constraint for {_symbol}",
                            symbol=f"{_symbol}_{i + 1}_gte",
                            func=con_expr,
                            cons_type=ConstraintTypeEnum.LTE,
                            is_linear=problem.is_linear,
                            is_convex=problem.is_convex,
                            is_twice_differentiable=problem.is_twice_differentiable,
                        )
                    )
                case ("0", _):
                    # not relevant for this scalarization
                    pass
                case (c, _):
                    msg = (
                        f"Warning! The classification {c} was supplied, but it is not supported."
                        "Must be one of ['<', '<=', '0', '=', '>=']"
                    )
    max_expr = f"Max({','.join(max_args)})"

    # form the augmentation term
    aug_exprs = []
    for _ in range(len(classifications_list)):
        aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{max_expr} + {rho} * ({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="NIMBUS scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=False,
    )

    _problem = problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_scenario_sf_diff

add_group_scenario_sf_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    weights: list[dict[str, float]],
    epsilon: float = 1e-06,
) -> tuple[Problem, str]

Add the differentiable scenario-based scalarization.

Adds the following scalarization function:

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of reference points as objective dicts. function symbols and values to reference point components, i.e., aspiration levels.

required
weights list[dict[str, float]]

the list of weights to be used in the scalarization function. Must be positive.

required
epsilon float

small augmentation multiplier ε

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_scenario_sf_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    weights: list[dict[str, float]],
    epsilon: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the differentiable scenario-based scalarization.

    Adds the following scalarization function:
    \begin{align}
      \min_{x,\alpha}\quad
        & \alpha \;+\; \varepsilon \sum_{i,p} w_{ip}\bigl(f_{ip}(x) - \bar z_{ip}\bigr) \\
      \text{s.t.}\quad
        & w_{ip}\bigl(f_{ip}(x) - \bar z_{ip}\bigr)\;-\;\alpha \;\le\;0
          \quad\forall\,i,p,\\
        & x \in \mathcal{X}\,,
    \end{align}

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of reference points as objective dicts.
            function symbols and values to reference point components, i.e., aspiration levels.
        weights (list[dict[str, float]]): the list of weights to be used in the scalarization function.
            Must be positive.
        epsilon: small augmentation multiplier ε

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    if len(reference_points) != len(weights):
        raise ScalarizationError("reference_points and weights must have same length")

    for idx, (ref_point, weight) in enumerate(zip(reference_points, weights, strict=True)):
        if not objective_dict_has_all_symbols(problem, ref_point):
            raise ScalarizationError(f"reference_points[{idx}] missing some objectives")
        if not objective_dict_has_all_symbols(problem, weight):
            raise ScalarizationError(f"weights[{idx}] missing some objectives")

    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=0.0,
    )

    sum_list = []
    constraints = []

    for idx, (ref_point, weight) in enumerate(zip(reference_points, weights, strict=True)):
        corrected_rp = flip_maximized_objective_values(problem, ref_point)
        for obj in problem.objectives:
            expr = f"{weight[obj.symbol]}*({obj.symbol}_min - {corrected_rp[obj.symbol]})"
            sum_list.append(expr)

            constraints.append(
                Constraint(
                    name=f"ssf_con_{obj.symbol}",
                    symbol=f"{obj.symbol}_con_{idx}",
                    func=f"{expr} - {alpha.symbol}",
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )

    sum_part = " + ".join(sum_list)

    func = f"_alpha + {epsilon}*({sum_part})"
    scalar = ScalarizationFunction(
        name="Scenario-based differentiable ASF",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )

    problem_ = problem.add_variables([alpha])
    problem_ = problem_.add_constraints(constraints)
    problem_ = problem_.add_scalarization(scalar)

    return problem_, symbol

add_group_scenario_sf_nondiff

add_group_scenario_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    weights: list[dict[str, float]],
    epsilon: float = 1e-06,
) -> tuple[Problem, str]

Add the non-differentiable scenario based scalarization function.

Add the following scalarization function:

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of reference points as objective dicts. function symbols and values to reference point components, i.e., aspiration levels.

required
weights list[dict[str, float]]

the list of weights to be used in the scalarization function. Must be positive.

required
epsilon float

small augmentation multiplier ε

1e-06

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added, and the symbol of the added scalarization function.

Source code in desdeo/tools/group_scalarization.py
def add_group_scenario_sf_nondiff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    weights: list[dict[str, float]],
    epsilon: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Add the non-differentiable scenario based scalarization function.

    Add the following scalarization function:
    \begin{align}
      \min_{\mathbf{x}}\quad
        &\max_{i,p}\bigl[w_{ip}\bigl(f_{ip}(\mathbf{x}) - \bar z_{ip}\bigr)\bigr]
        \;+\;\varepsilon \sum_{i,p} w_{ip}\bigl(f_{ip}(\mathbf{x}) - \bar z_{ip}\bigr) \\[6pt]
      \text{s.t.}\quad
        &\mathbf{x} \in \mathcal{X}\,,
    \end{align}

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of reference points as objective dicts.
            function symbols and values to reference point components, i.e., aspiration levels.
        weights (list[dict[str, float]]): the list of weights to be used in the scalarization function.
            Must be positive.
        epsilon: small augmentation multiplier ε

    Returns:
        tuple[Problem, str]: A tuple containing a copy of the problem with the scalarization function added,
            and the symbol of the added scalarization function.
    """
    if len(reference_points) != len(weights):
        raise ScalarizationError("reference_points and weights must have same length")

    for reference_point, weight in zip(reference_points, weights, strict=True):
        if not objective_dict_has_all_symbols(problem, reference_point):
            raise ScalarizationError(
                f"The give reference point {reference_point} is missing value for one or more objectives."
            )
        if not objective_dict_has_all_symbols(problem, weight):
            raise ScalarizationError(f"The given weight vector {weight} is missing a value for one or more objectives.")

    max_list: list[str] = []
    sum_list: list[str] = []
    for reference_point, weight in zip(reference_points, weights, strict=True):
        corrected_ref_point = flip_maximized_objective_values(problem, reference_point)

        for obj in problem.objectives:
            expr = f"{weight[obj.symbol]}*({obj.symbol}_min - {corrected_ref_point[obj.symbol]})"
            max_list.append(expr)
            sum_list.append(expr)

    max_part = f"{Op.MAX}({', '.join(max_list)})"
    sum_part = " + ".join(sum_list)
    func = f"{max_part} + {epsilon}*({sum_part})"

    scalar = ScalarizationFunction(
        name="Group non differentiable scalarization function for scenario based problems.",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    return problem.add_scalarization(scalar), symbol

add_group_stom

add_group_stom(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the multiple decision maker variant of the STOM scalarizing function.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of dicts with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_stom(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the multiple decision maker variant of the STOM scalarizing function.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of dicts with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing value for one or more objectives."
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # calculate the weights
    weights = []
    for reference_point in reference_points:
        corrected_rp = flip_maximized_objective_values(problem, reference_point)
        if type(delta) is dict:
            weights.append(
                {
                    obj.symbol: 1 / ((corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta[obj.symbol])
                    for obj in problem.objectives
                }
            )
        else:
            weights.append(
                {
                    obj.symbol: 1 / ((corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta)
                    for obj in problem.objectives
                }
            )

    # form the max term
    max_terms = []
    for i in range(len(reference_points)):
        for obj in problem.objectives:
            if type(delta) is dict:
                max_terms.append(
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta[obj.symbol]})"
                )
            else:
                max_terms.append(f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta})")
    max_terms = ", ".join(max_terms)

    # form the augmentation term
    aug_exprs = []
    for i in range(len(reference_points)):
        aug_expr = " + ".join([f"({weights[i][obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho}*({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="STOM scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=False,
    )
    problem = problem.add_scalarization(scalarization_function)
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        constraints = []
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
        problem = problem.add_constraints(constraints)

    return problem, symbol

add_group_stom_agg

add_group_stom_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the multiple decision maker variant of the STOM scalarizing function.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} &&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels, i.e., min aspirations.

required
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_stom_agg(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float],
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the multiple decision maker variant of the STOM scalarizing function.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} &&\max_{i,d} [w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id})] +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} &&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels, i.e., min aspirations.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)
    agg_bounds = flip_maximized_objective_values(problem, agg_bounds)

    # calculate the weights
    weights = None
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / ((agg_aspirations[obj.symbol] - ideal_point[obj.symbol]) + delta[obj.symbol])
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / ((agg_aspirations[obj.symbol] - ideal_point[obj.symbol]) + delta)
            for obj in problem.objectives
        }

    # form the max and augmentation terms
    max_terms = []
    aug_exprs = []
    for obj in problem.objectives:
        if type(delta) is dict:
            max_terms.append(
                f"({weights[obj.symbol]}) * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta[obj.symbol]} )"
            )
        else:
            max_terms.append(f"{weights[obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta})")

    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    max_terms = ", ".join(max_terms)
    aug_exprs = " + ".join(aug_exprs)

    func = f"{Op.MAX}({max_terms}) + {rho} * ({aug_exprs})"

    scalarization_function = ScalarizationFunction(
        name="STOM scalarizing function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_convex=problem.is_convex,
        is_linear=problem.is_linear,
        is_twice_differentiable=False,
    )

    constraints = []

    for obj in problem.objectives:
        expr = f"({obj.symbol}_min - {agg_bounds[obj.symbol]})"
        constraints.append(
            Constraint(
                name=f"Constraint bound for {obj.symbol}",
                symbol=f"{obj.symbol}_con",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )

    problem = problem.add_constraints(constraints)
    problem = problem.add_scalarization(scalarization_function)

    return problem, symbol

add_group_stom_agg_diff

add_group_stom_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the multiple decision maker variant of the STOM scalarizing function.

Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required. The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} && \alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
agg_aspirations dict[str, float]

a dictionary of aggregated aspiration levels, i.e., min aspirations.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_stom_agg_diff(
    problem: Problem,
    symbol: str,
    agg_aspirations: dict[str, float],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the multiple decision maker variant of the STOM scalarizing function.

    Both aggregated aspiration levels (min aspirations) and agg bounds (max bounds) are required.
    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} && \alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        agg_aspirations (dict[str, float]): a dictionary of aggregated aspiration levels, i.e., min aspirations.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )
    # Correct the aspirations and hard_constraints
    agg_aspirations = flip_maximized_objective_values(problem, agg_aspirations)

    # calculate the weights
    weights = {}
    if type(delta) is dict:
        weights = {
            obj.symbol: 1 / ((agg_aspirations[obj.symbol] - ideal_point[obj.symbol]) + delta[obj.symbol])
            for obj in problem.objectives
        }
    else:
        weights = {
            obj.symbol: 1 / ((agg_aspirations[obj.symbol] - ideal_point[obj.symbol]) + delta)
            for obj in problem.objectives
        }

    # form the max term
    con_terms = []
    for obj in problem.objectives:
        if type(delta) is dict:
            con_terms.append(
                f"{weights[obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta[obj.symbol]}) - _alpha"
            )
        else:
            con_terms.append(f"{weights[obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta}) - _alpha")

    # form the augmentation term
    aug_exprs = []
    aug_expr = " + ".join([f"({weights[obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
    aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for obj in problem.objectives:
        if type(delta) is dict:
            expr = (
                f"({obj.symbol}_min - {ideal_point[obj.symbol] - delta[obj.symbol]}) / "
                f"({agg_aspirations[obj.symbol] - (ideal_point[obj.symbol] - delta[obj.symbol])}) - _alpha"
            )
        else:
            expr = (
                f"({obj.symbol}_min - {ideal_point[obj.symbol] - delta}) / "
                f"({agg_aspirations[obj.symbol] - (ideal_point[obj.symbol] - delta)}) - _alpha"
            )
        constraints.append(
            Constraint(
                name=f"Constraint for {obj.symbol}",
                symbol=f"{obj.symbol}_maxcon",
                func=expr,
                cons_type=ConstraintTypeEnum.LTE,
                is_linear=obj.is_linear,
                is_convex=obj.is_convex,
                is_twice_differentiable=obj.is_twice_differentiable,
            )
        )
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    func = f"_alpha + {rho}*({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="Differentiable STOM scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

add_group_stom_diff

add_group_stom_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-06,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-06,
) -> tuple[Problem, str]

Adds the differentiable variant of the multiple decision maker variant of the STOM scalarizing function.

The scalarization function is defined as follows:

\[\begin{align} &\mbox{minimize} && \alpha + \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\ &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id}) - \alpha \leq 0,\\ &&&\mathbf{x} \in \mathbf{X}, \end{align}\]

where \(w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}\).

Parameters:

Name Type Description Default
problem Problem

the problem the scalarization is added to.

required
symbol str

the symbol given to the added scalarization.

required
reference_points list[dict[str, float]]

a list of dicts with keys corresponding to objective function symbols and values to reference point components, i.e., aspiration levels.

required
ideal dict[str, float]

ideal point values. If not given, attempt will be made to calculate ideal point from problem.

None
agg_bounds dict[str, float]

a dictionary of bounds not to violate.

None
rho float

a small scalar value to scale the sum in the objective function of the scalarization. Defaults to 1e-6.

1e-06
delta float

a small scalar value to define the utopian point. Defaults to 1e-6.

1e-06

Raises:

Type Description
ScalarizationError

there are missing elements in any reference point.

Returns:

Type Description
tuple[Problem, str]

tuple[Problem, str]: a tuple with the copy of the problem with the added scalarization and the symbol of the added scalarization.

Source code in desdeo/tools/group_scalarization.py
def add_group_stom_diff(
    problem: Problem,
    symbol: str,
    reference_points: list[dict[str, float]],
    agg_bounds: dict[str, float] | None = None,
    delta: dict[str, float] | float = 1e-6,
    ideal: dict[str, float] | None = None,
    rho: float = 1e-6,
) -> tuple[Problem, str]:
    r"""Adds the differentiable variant of the multiple decision maker variant of the STOM scalarizing function.

    The scalarization function is defined as follows:

    \begin{align}
        &\mbox{minimize} && \alpha +
        \rho \sum^k_{i=1} \sum^{n_d}_{d=1} w_{id}f_{id}(\mathbf{x}) \\
        &\mbox{subject to} && w_{id}(f_{id}(\mathbf{x})-z^{uto}_{id}) - \alpha \leq 0,\\
        &&&\mathbf{x} \in \mathbf{X},
    \end{align}

    where $w_{id} = \frac{1}{\overline{z}_{id} - z^{uto}_{id}}$.

    Args:
        problem (Problem): the problem the scalarization is added to.
        symbol (str): the symbol given to the added scalarization.
        reference_points (list[dict[str, float]]): a list of dicts with keys corresponding to objective
            function symbols and values to reference point components, i.e.,
            aspiration levels.
        ideal (dict[str, float], optional): ideal point values. If not given, attempt will be made
            to calculate ideal point from problem.
        agg_bounds (dict[str, float]): a dictionary of bounds not to violate.
        rho (float, optional): a small scalar value to scale the sum in the objective
            function of the scalarization. Defaults to 1e-6.
        delta (float, optional): a small scalar value to define the utopian point. Defaults to 1e-6.

    Raises:
        ScalarizationError: there are missing elements in any reference point.

    Returns:
        tuple[Problem, str]: a tuple with the copy of the problem with the added
            scalarization and the symbol of the added scalarization.
    """
    # check reference points
    for reference_point in reference_points:
        if not objective_dict_has_all_symbols(problem, reference_point):
            msg = f"The give reference point {reference_point} is missing value for one or more objectives."
            raise ScalarizationError(msg)

    # check if ideal point is specified
    # if not specified, try to calculate corrected ideal point
    if ideal is not None:
        ideal_point = ideal
    elif problem.get_ideal_point() is not None:
        ideal_point = get_corrected_ideal(problem)
    else:
        msg = "Ideal point not defined!"
        raise ScalarizationError(msg)

    # define the auxiliary variable
    alpha = Variable(
        name="alpha",
        symbol="_alpha",
        variable_type=VariableTypeEnum.real,
        lowerbound=None,
        upperbound=None,
        initial_value=1.0,
    )

    # calculate the weights
    weights = []
    for reference_point in reference_points:
        corrected_rp = flip_maximized_objective_values(problem, reference_point)
        if type(delta) is dict:
            weights.append(
                {
                    obj.symbol: 1 / ((corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta[obj.symbol])
                    for obj in problem.objectives
                }
            )
        else:
            weights.append(
                {
                    obj.symbol: 1 / ((corrected_rp[obj.symbol] - ideal_point[obj.symbol]) + delta)
                    for obj in problem.objectives
                }
            )

    # form the max term
    con_terms = []
    for i in range(len(reference_points)):
        rp = {}
        for obj in problem.objectives:
            if type(delta) is dict:
                rp[obj.symbol] = (
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta[obj.symbol]})"
                    " - _alpha"
                )
            else:
                rp[obj.symbol] = (
                    f"{weights[i][obj.symbol]} * ({obj.symbol}_min - {ideal_point[obj.symbol] - delta}) - _alpha"
                )
        con_terms.append(rp)

    # form the augmentation term
    aug_exprs = []
    for i in range(len(reference_points)):
        aug_expr = " + ".join([f"({weights[i][obj.symbol]} * {obj.symbol}_min)" for obj in problem.objectives])
        aug_exprs.append(aug_expr)
    aug_exprs = " + ".join(aug_exprs)

    constraints = []
    # loop to create a constraint for every objective of every reference point given
    for i in range(len(reference_points)):
        for obj in problem.objectives:
            # since we are subtracting a constant value, the linearity, convexity,
            # and differentiability of the objective function, and hence the
            # constraint, should not change.
            constraints.append(
                Constraint(
                    name=f"Constraint for {obj.symbol}",
                    symbol=f"{obj.symbol}_con_{i + 1}",
                    func=con_terms[i][obj.symbol],
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    #  get corrected bounds if exist
    if agg_bounds is not None:
        bounds = flip_maximized_objective_values(problem, agg_bounds)
        for obj in problem.objectives:
            expr = f"({obj.symbol}_min - {bounds[obj.symbol]})"
            constraints.append(
                Constraint(
                    name=f"Constraint bound for {obj.symbol}",
                    symbol=f"{obj.symbol}_con",
                    func=expr,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=obj.is_linear,
                    is_convex=obj.is_convex,
                    is_twice_differentiable=obj.is_twice_differentiable,
                )
            )
    func = f"_alpha + {rho}*({aug_exprs})"
    scalarization = ScalarizationFunction(
        name="Differentiable STOM scalarization objective function for multiple decision makers",
        symbol=symbol,
        func=func,
        is_linear=problem.is_linear,
        is_convex=problem.is_convex,
        is_twice_differentiable=problem.is_twice_differentiable,
    )
    _problem = problem.add_variables([alpha])
    _problem = _problem.add_scalarization(scalarization)
    return _problem.add_constraints(constraints), symbol

Gurobipy solver interfaces

Defines solver interfaces for gurobipy.

GurobipySolver

Bases: BaseSolver

Creates a gurobipy solver that utilizes gurobi's own Python implementation.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
class GurobipySolver(BaseSolver):
    """Creates a gurobipy solver that utilizes gurobi's own Python implementation."""

    def __init__(self, problem: Problem, options: dict[str, any] | None = None):
        """The solver is initialized by supplying a problem and options.

        Unlike with Pyomo you do not need to have gurobi installed on your system
        for this to work. Suitable for solving mixed-integer linear and quadratic optimization
        problems.

        Args:
            problem (Problem): the problem to be solved.
            options (dict[str,any]): Dictionary of Gurobi parameters to set.
                You probably don't need to set any of these and can just use the defaults.
                For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html
        """
        self.evaluator = GurobipyEvaluator(problem)
        self.problem = problem

        if options is not None:
            for key, value in options.items():
                self.evaluator.model.setParam(key, value)
        else:
            # Set some default parameters that are good for most problems.
            self.evaluator.model.setParam("OutputFlag", 0)  # Suppress Gurobi output
            self.evaluator.model.setParam("LogToConsole", 0)  # Suppress Gurobi logging to console

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for the given target.

        Args:
            target (str): the symbol of the function to be optimized, and which is
                defined in the problem given when initializing the solver.

        Returns:
            SolverResults: the results of the optimization.
        """
        self.evaluator.set_optimization_target(target)
        self.evaluator.model.optimize()
        return parse_gurobipy_optimizer_results(self.problem, self.evaluator)

__init__

__init__(
    problem: Problem, options: dict[str, any] | None = None
)

The solver is initialized by supplying a problem and options.

Unlike with Pyomo you do not need to have gurobi installed on your system for this to work. Suitable for solving mixed-integer linear and quadratic optimization problems.

Parameters:

Name Type Description Default
problem Problem

the problem to be solved.

required
options dict[str, any]

Dictionary of Gurobi parameters to set. You probably don't need to set any of these and can just use the defaults. For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html

None
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def __init__(self, problem: Problem, options: dict[str, any] | None = None):
    """The solver is initialized by supplying a problem and options.

    Unlike with Pyomo you do not need to have gurobi installed on your system
    for this to work. Suitable for solving mixed-integer linear and quadratic optimization
    problems.

    Args:
        problem (Problem): the problem to be solved.
        options (dict[str,any]): Dictionary of Gurobi parameters to set.
            You probably don't need to set any of these and can just use the defaults.
            For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html
    """
    self.evaluator = GurobipyEvaluator(problem)
    self.problem = problem

    if options is not None:
        for key, value in options.items():
            self.evaluator.model.setParam(key, value)
    else:
        # Set some default parameters that are good for most problems.
        self.evaluator.model.setParam("OutputFlag", 0)  # Suppress Gurobi output
        self.evaluator.model.setParam("LogToConsole", 0)  # Suppress Gurobi logging to console

solve

solve(target: str) -> SolverResults

Solve the problem for the given target.

Parameters:

Name Type Description Default
target str

the symbol of the function to be optimized, and which is defined in the problem given when initializing the solver.

required

Returns:

Name Type Description
SolverResults SolverResults

the results of the optimization.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for the given target.

    Args:
        target (str): the symbol of the function to be optimized, and which is
            defined in the problem given when initializing the solver.

    Returns:
        SolverResults: the results of the optimization.
    """
    self.evaluator.set_optimization_target(target)
    self.evaluator.model.optimize()
    return parse_gurobipy_optimizer_results(self.problem, self.evaluator)

PersistentGurobipySolver

Bases: PersistentSolver

A persistent solver class utlizing gurobipy.

Use this instead of create_gurobipy_solver when re-initializing the solver every time the problem is changed is not practical.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
class PersistentGurobipySolver(PersistentSolver):
    """A persistent solver class utlizing gurobipy.

    Use this instead of create_gurobipy_solver when re-initializing the
    solver every time the problem is changed is not practical.
    """

    evaluator: GurobipyEvaluator

    def __init__(self, problem: Problem, options: dict[str, any] | None = None):
        """Initializer for the persistent solver.

        Args:
            problem (Problem): the problem to be transformed in a GurobipyModel.
            options (dict[str,any]): Dictionary of Gurobi parameters to set.
                You probably don't need to set any of these and can just use the defaults.
                For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html
        """
        self.problem = problem
        self.evaluator = GurobipyEvaluator(problem)
        if options is not None:
            for key, value in options.items():
                self.evaluator.model.setParam(key, value)

    def add_constraint(self, constraint: Constraint | list[Constraint]) -> gp.Constr | list[gp.Constr]:
        """Add one or more constraint expressions to the solver.

        If adding a lot of constraints or dealing with a large model, this function
        may end up being very slow compared to adding the constraints to the model
        stored in the evaluator directly.

        Args:
            constraint (Constraint): the constraint function expression or a list of
                constraint function expressions.

        Raises:
            GurobipyEvaluatorError: when an unsupported constraint type is encountered.

        Returns:
            gurobipy.Constr: The gurobipy constraint that was added or a list of gurobipy
                constraints if the constraint argument was a list.
        """
        if isinstance(constraint, list):
            cons_list = list[gp.Constr]
            for cons in constraint:
                cons_list.append(self.evaluator.add_constraint(cons))
            return cons_list

        return self.evaluator.add_constraint(constraint)

    def add_objective(self, objective: Objective | list[Objective]):
        """Adds an objective function expression to the solver.

        Does not yet add any actual gurobipy optimization objectives, only adds them to the dict
        containing the expressions of the objectives. The objective expressions are stored in the
        evaluator and the evaluator must add the appropiate gurobipy objective before solving.

        Args:
            objective (Objective): an objective function expression or a list of objective function
                expressions to be added.
        """
        if not isinstance(objective, list):
            objective = [objective]

        for obj in objective:
            self.evaluator.add_objective(obj)

    def add_scalarization_function(self, scalarization: ScalarizationFunction | list[ScalarizationFunction]):
        """Adds a scalrization expression to the solver.

        Scalarizations work identically to objectives, except they are stored in a different
        dict in the evaluator. If you want to solve the problem using a scalarization, the
        evaluator needs to set it as an optimization target first.

        Args:
            scalarization (ScalarizationFunction): A scalarization function or a list of
                scalarization functions to be added.
        """
        if not isinstance(scalarization, list):
            scalarization = [scalarization]

        for scal in scalarization:
            self.evaluator.add_scalarization_function(scal)

    def add_variable(
        self, variable: Variable | TensorVariable | list[Variable] | list[TensorVariable]
    ) -> gp.Var | gp.MVar | list[gp.Var] | list[gp.MVar]:
        """Add one or more variables to the solver.

        If adding a lot of variables or dealing with a large model, this function
        may end up being very slow compared to adding the variables to the model
        stored in the evaluator directly.

        Args:
            variable (Variable): The definition of the variable or a list of variables to be added.

        Raises:
            GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
                I.e., the variables are of a non supported type.

        Returns:
            gp.Var: the variable that was added to the model or a list of variables if
                variable argument was a list.
        """
        if isinstance(variable, list):
            var_list = list[gp.Var | gp.MVar]
            for var in variable:
                var_list.append(self.evaluator.add_variable(var))
            return var_list

        return self.evaluator.add_variable(variable)

    def remove_constraint(self, symbol: str | list[str]):
        """Removes a constraint from the solver.

        If removing a lot of constraints or dealing with a very large model this function
        may be slow because of the model.update() calls. Accessing the model stored in the
        evaluator directly may be faster.

        Args:
            symbol (str): a str representing the symbol of the constraint to be removed.
                Can also be a list of multiple symbols.
        """
        if not isinstance(symbol, list):
            symbol = [symbol]
        for s in symbol:
            self.evaluator.remove_constraint(s)

    def remove_variable(self, symbol: str | list[str]):
        """Removes a variable from the model.

        If removing a lot of variables or dealing with a very large model this function
        may be slow because of the model.update() calls. Accessing the model stored in
        the evaluator directly may be faster.

        Args:
            symbol (str): a str representing the symbol of the variable to be removed.
                Can also be a list of multiple symbols.
        """
        self.evaluator.remove_variable(symbol)

    def solve(self, target: str) -> SolverResults:
        """Solves the current problem with the specified target.

        Args:
            target (str): a str representing the symbol of the target function.

        Returns:
            SolverResults: The results of the solver
        """
        self.evaluator.set_optimization_target(target)
        self.evaluator.model.optimize()
        return parse_gurobipy_optimizer_results(self.problem, self.evaluator)

__init__

__init__(
    problem: Problem, options: dict[str, any] | None = None
)

Initializer for the persistent solver.

Parameters:

Name Type Description Default
problem Problem

the problem to be transformed in a GurobipyModel.

required
options dict[str, any]

Dictionary of Gurobi parameters to set. You probably don't need to set any of these and can just use the defaults. For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html

None
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def __init__(self, problem: Problem, options: dict[str, any] | None = None):
    """Initializer for the persistent solver.

    Args:
        problem (Problem): the problem to be transformed in a GurobipyModel.
        options (dict[str,any]): Dictionary of Gurobi parameters to set.
            You probably don't need to set any of these and can just use the defaults.
            For available parameters see https://www.gurobi.com/documentation/current/refman/parameters.html
    """
    self.problem = problem
    self.evaluator = GurobipyEvaluator(problem)
    if options is not None:
        for key, value in options.items():
            self.evaluator.model.setParam(key, value)

add_constraint

add_constraint(
    constraint: Constraint | list[Constraint],
) -> gp.Constr | list[gp.Constr]

Add one or more constraint expressions to the solver.

If adding a lot of constraints or dealing with a large model, this function may end up being very slow compared to adding the constraints to the model stored in the evaluator directly.

Parameters:

Name Type Description Default
constraint Constraint

the constraint function expression or a list of constraint function expressions.

required

Raises:

Type Description
GurobipyEvaluatorError

when an unsupported constraint type is encountered.

Returns:

Type Description
Constr | list[Constr]

gurobipy.Constr: The gurobipy constraint that was added or a list of gurobipy constraints if the constraint argument was a list.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def add_constraint(self, constraint: Constraint | list[Constraint]) -> gp.Constr | list[gp.Constr]:
    """Add one or more constraint expressions to the solver.

    If adding a lot of constraints or dealing with a large model, this function
    may end up being very slow compared to adding the constraints to the model
    stored in the evaluator directly.

    Args:
        constraint (Constraint): the constraint function expression or a list of
            constraint function expressions.

    Raises:
        GurobipyEvaluatorError: when an unsupported constraint type is encountered.

    Returns:
        gurobipy.Constr: The gurobipy constraint that was added or a list of gurobipy
            constraints if the constraint argument was a list.
    """
    if isinstance(constraint, list):
        cons_list = list[gp.Constr]
        for cons in constraint:
            cons_list.append(self.evaluator.add_constraint(cons))
        return cons_list

    return self.evaluator.add_constraint(constraint)

add_objective

add_objective(objective: Objective | list[Objective])

Adds an objective function expression to the solver.

Does not yet add any actual gurobipy optimization objectives, only adds them to the dict containing the expressions of the objectives. The objective expressions are stored in the evaluator and the evaluator must add the appropiate gurobipy objective before solving.

Parameters:

Name Type Description Default
objective Objective

an objective function expression or a list of objective function expressions to be added.

required
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def add_objective(self, objective: Objective | list[Objective]):
    """Adds an objective function expression to the solver.

    Does not yet add any actual gurobipy optimization objectives, only adds them to the dict
    containing the expressions of the objectives. The objective expressions are stored in the
    evaluator and the evaluator must add the appropiate gurobipy objective before solving.

    Args:
        objective (Objective): an objective function expression or a list of objective function
            expressions to be added.
    """
    if not isinstance(objective, list):
        objective = [objective]

    for obj in objective:
        self.evaluator.add_objective(obj)

add_scalarization_function

add_scalarization_function(
    scalarization: ScalarizationFunction
    | list[ScalarizationFunction],
)

Adds a scalrization expression to the solver.

Scalarizations work identically to objectives, except they are stored in a different dict in the evaluator. If you want to solve the problem using a scalarization, the evaluator needs to set it as an optimization target first.

Parameters:

Name Type Description Default
scalarization ScalarizationFunction

A scalarization function or a list of scalarization functions to be added.

required
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def add_scalarization_function(self, scalarization: ScalarizationFunction | list[ScalarizationFunction]):
    """Adds a scalrization expression to the solver.

    Scalarizations work identically to objectives, except they are stored in a different
    dict in the evaluator. If you want to solve the problem using a scalarization, the
    evaluator needs to set it as an optimization target first.

    Args:
        scalarization (ScalarizationFunction): A scalarization function or a list of
            scalarization functions to be added.
    """
    if not isinstance(scalarization, list):
        scalarization = [scalarization]

    for scal in scalarization:
        self.evaluator.add_scalarization_function(scal)

add_variable

add_variable(
    variable: Variable
    | TensorVariable
    | list[Variable]
    | list[TensorVariable],
) -> gp.Var | gp.MVar | list[gp.Var] | list[gp.MVar]

Add one or more variables to the solver.

If adding a lot of variables or dealing with a large model, this function may end up being very slow compared to adding the variables to the model stored in the evaluator directly.

Parameters:

Name Type Description Default
variable Variable

The definition of the variable or a list of variables to be added.

required

Raises:

Type Description
GurobipyEvaluatorError

when a problem in extracting the variables is encountered. I.e., the variables are of a non supported type.

Returns:

Type Description
Var | MVar | list[Var] | list[MVar]

gp.Var: the variable that was added to the model or a list of variables if variable argument was a list.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def add_variable(
    self, variable: Variable | TensorVariable | list[Variable] | list[TensorVariable]
) -> gp.Var | gp.MVar | list[gp.Var] | list[gp.MVar]:
    """Add one or more variables to the solver.

    If adding a lot of variables or dealing with a large model, this function
    may end up being very slow compared to adding the variables to the model
    stored in the evaluator directly.

    Args:
        variable (Variable): The definition of the variable or a list of variables to be added.

    Raises:
        GurobipyEvaluatorError: when a problem in extracting the variables is encountered.
            I.e., the variables are of a non supported type.

    Returns:
        gp.Var: the variable that was added to the model or a list of variables if
            variable argument was a list.
    """
    if isinstance(variable, list):
        var_list = list[gp.Var | gp.MVar]
        for var in variable:
            var_list.append(self.evaluator.add_variable(var))
        return var_list

    return self.evaluator.add_variable(variable)

remove_constraint

remove_constraint(symbol: str | list[str])

Removes a constraint from the solver.

If removing a lot of constraints or dealing with a very large model this function may be slow because of the model.update() calls. Accessing the model stored in the evaluator directly may be faster.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the constraint to be removed. Can also be a list of multiple symbols.

required
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def remove_constraint(self, symbol: str | list[str]):
    """Removes a constraint from the solver.

    If removing a lot of constraints or dealing with a very large model this function
    may be slow because of the model.update() calls. Accessing the model stored in the
    evaluator directly may be faster.

    Args:
        symbol (str): a str representing the symbol of the constraint to be removed.
            Can also be a list of multiple symbols.
    """
    if not isinstance(symbol, list):
        symbol = [symbol]
    for s in symbol:
        self.evaluator.remove_constraint(s)

remove_variable

remove_variable(symbol: str | list[str])

Removes a variable from the model.

If removing a lot of variables or dealing with a very large model this function may be slow because of the model.update() calls. Accessing the model stored in the evaluator directly may be faster.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the variable to be removed. Can also be a list of multiple symbols.

required
Source code in desdeo/tools/gurobipy_solver_interfaces.py
def remove_variable(self, symbol: str | list[str]):
    """Removes a variable from the model.

    If removing a lot of variables or dealing with a very large model this function
    may be slow because of the model.update() calls. Accessing the model stored in
    the evaluator directly may be faster.

    Args:
        symbol (str): a str representing the symbol of the variable to be removed.
            Can also be a list of multiple symbols.
    """
    self.evaluator.remove_variable(symbol)

solve

solve(target: str) -> SolverResults

Solves the current problem with the specified target.

Parameters:

Name Type Description Default
target str

a str representing the symbol of the target function.

required

Returns:

Name Type Description
SolverResults SolverResults

The results of the solver

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solves the current problem with the specified target.

    Args:
        target (str): a str representing the symbol of the target function.

    Returns:
        SolverResults: The results of the solver
    """
    self.evaluator.set_optimization_target(target)
    self.evaluator.model.optimize()
    return parse_gurobipy_optimizer_results(self.problem, self.evaluator)

_constraint_contains_objective

_constraint_contains_objective(
    constraint: Constraint, objective_symbol: str
) -> bool

Check if a constraint's function contains a specific objective symbol.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def _constraint_contains_objective(constraint: Constraint, objective_symbol: str) -> bool:
    """Check if a constraint's function contains a specific objective symbol."""
    func_str = str(constraint.func)
    return objective_symbol in func_str

check_gurobi_license

check_gurobi_license()

Check if Gurobi is using a full license (not trial).

Returns:

Type Description
str | bool

True if using full academic/commercial license False if using trial license or no license found

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def check_gurobi_license():
    """Check if Gurobi is using a full license (not trial).

    Returns:
        (str | bool): True if using full academic/commercial license
            False if using trial license or no license found
    """
    captured_output = io.StringIO()
    original_stdout = sys.stdout

    try:
        sys.stdout = captured_output
        with gp.Env(empty=True) as env:
            env.setParam("OutputFlag", 1)
            env.start()
        sys.stdout = original_stdout

        output = captured_output.getvalue()
        return "Restricted license - for non-production use only" not in output  # noqa: TRY300

    except Exception:
        sys.stdout = original_stdout
        return False

parse_gurobipy_optimizer_results

parse_gurobipy_optimizer_results(
    problem: Problem, evaluator: GurobipyEvaluator
) -> SolverResults

Parses results from GurobipyEvaluator's model into DESDEO SolverResults.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
evaluator GurobipyEvaluator

the evaluator utilized to solve the problem.

required

Returns:

Name Type Description
SolverResults SolverResults

DESDEO solver results.

Source code in desdeo/tools/gurobipy_solver_interfaces.py
def parse_gurobipy_optimizer_results(problem: Problem, evaluator: GurobipyEvaluator) -> SolverResults:
    """Parses results from GurobipyEvaluator's model into DESDEO SolverResults.

    Args:
        problem (Problem): the problem being solved.
        evaluator (GurobipyEvaluator): the evaluator utilized to solve the problem.

    Returns:
        SolverResults: DESDEO solver results.
    """
    model_status = evaluator.model.status
    success = model_status == gp.GRB.OPTIMAL
    if model_status == gp.GRB.OPTIMAL:
        status = "Optimal solution found."
    elif model_status == gp.GRB.INFEASIBLE:
        status = "Model is infeasible."
    elif model_status == gp.GRB.UNBOUNDED:
        status = "Model is unbounded."
    elif model_status == gp.GRB.INF_OR_UNBD:
        status = "Model is either infeasible or unbounded."
    else:
        status = f"Optimization ended with status: {model_status}"
    msg = f"Gurobipy solver status is: '{status}'"

    if not success:
        return SolverResults(
            optimal_variables={var.symbol: float("nan") for var in problem.variables},
            optimal_objectives={obj.symbol: float("nan") for obj in problem.objectives},
            constraint_values=None,
            extra_func_values=None,
            scalarization_values=None,
            lagrange_multipliers=None,
            success=False,
            message=msg,
        )

    results = evaluator.get_values()

    variable_values = {var.symbol: results[var.symbol] for var in problem.variables}
    objective_values = {obj.symbol: results[obj.symbol] for obj in problem.objectives}
    constraint_values = (
        {con.symbol: results[con.symbol] for con in problem.constraints} if problem.constraints is not None else None
    )
    extra_func_values = (
        {extra.symbol: results[extra.symbol] for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: results[scal.symbol] for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )
    lagrange_multipliers = None
    if problem.constraints is not None:
        objective_symbols = set(objective_values.keys())
        lagrange_multipliers = {
            con.symbol: results[con.symbol]
            for con in problem.constraints
            if any(obj_sym in con.symbol for obj_sym in objective_symbols)
        }

    return SolverResults(
        optimal_variables=variable_values,
        optimal_objectives=objective_values,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        lagrange_multipliers=lagrange_multipliers,
        success=True,
        message=msg,
    )

CVXPY solver interfaces

Defines solver interfaces for cvxpy.

CVXPYSolver

Bases: BaseSolver

Creates a CVXPY solver that utilizes CVXPY's optimization capabilities.

Source code in desdeo/tools/cvxpy_solver_interfaces.py
class CVXPYSolver(BaseSolver):
    """Creates a CVXPY solver that utilizes CVXPY's optimization capabilities."""

    def __init__(self, problem: Problem, options: CVXPYSolverOptions = _default_cvxpy_options):
        """The solver is initialized by supplying a problem and options.

        CVXPY is a Python-embedded modeling language for convex optimization problems,
        supporting a broad range of problem types.

        Args:
            problem (Problem): the problem to be solved.
            options (CVXPYSolverOptions): Pydantic model containing solver options for CVXPY.
                For available options see https://www.cvxpy.org/api_reference/cvxpy.problems.html#solve
        """
        self.evaluator = CVXPYEvaluator(problem)
        self.problem = problem

        options_dict = {k: v for k, v in options.model_dump().items() if v is not None}
        extra_options = options_dict.pop("extra_options", None)
        if extra_options is not None:
            options_dict.update(extra_options)
        self.solve_options = options_dict

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for the given target.

        Args:
            target (str): the symbol of the function to be optimized, and which is
                defined in the problem given when initializing the solver.

        Returns:
            SolverResults: the results of the optimization.
        """
        self.evaluator.set_optimization_target(target)
        self.evaluator.solve(**self.solve_options)
        return parse_cvxpy_optimizer_results(self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: CVXPYSolverOptions = _default_cvxpy_options,
)

The solver is initialized by supplying a problem and options.

CVXPY is a Python-embedded modeling language for convex optimization problems, supporting a broad range of problem types.

Parameters:

Name Type Description Default
problem Problem

the problem to be solved.

required
options CVXPYSolverOptions

Pydantic model containing solver options for CVXPY. For available options see https://www.cvxpy.org/api_reference/cvxpy.problems.html#solve

_default_cvxpy_options
Source code in desdeo/tools/cvxpy_solver_interfaces.py
def __init__(self, problem: Problem, options: CVXPYSolverOptions = _default_cvxpy_options):
    """The solver is initialized by supplying a problem and options.

    CVXPY is a Python-embedded modeling language for convex optimization problems,
    supporting a broad range of problem types.

    Args:
        problem (Problem): the problem to be solved.
        options (CVXPYSolverOptions): Pydantic model containing solver options for CVXPY.
            For available options see https://www.cvxpy.org/api_reference/cvxpy.problems.html#solve
    """
    self.evaluator = CVXPYEvaluator(problem)
    self.problem = problem

    options_dict = {k: v for k, v in options.model_dump().items() if v is not None}
    extra_options = options_dict.pop("extra_options", None)
    if extra_options is not None:
        options_dict.update(extra_options)
    self.solve_options = options_dict

solve

solve(target: str) -> SolverResults

Solve the problem for the given target.

Parameters:

Name Type Description Default
target str

the symbol of the function to be optimized, and which is defined in the problem given when initializing the solver.

required

Returns:

Name Type Description
SolverResults SolverResults

the results of the optimization.

Source code in desdeo/tools/cvxpy_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for the given target.

    Args:
        target (str): the symbol of the function to be optimized, and which is
            defined in the problem given when initializing the solver.

    Returns:
        SolverResults: the results of the optimization.
    """
    self.evaluator.set_optimization_target(target)
    self.evaluator.solve(**self.solve_options)
    return parse_cvxpy_optimizer_results(self.problem, self.evaluator)

CVXPYSolverOptions

Bases: BaseModel

Defines a pydantic model to store and pass options to the CVXPY solver.

Source code in desdeo/tools/cvxpy_solver_interfaces.py
class CVXPYSolverOptions(BaseModel):
    """Defines a pydantic model to store and pass options to the CVXPY solver."""

    solver: str | None = Field(
        description="The solver to use.",
        default=None,
    )
    solver_path: list[str | tuple[str, dict[str, object]]] | None = Field(
        description="The solvers to try with optional arguments, in order of preference.",
        default=None,
    )
    verbose: bool = Field(description="Overrides the default of hiding solver output.", default=False)
    gp: bool = Field(description="Parse the problem as a disciplined geometric program.", default=False)
    qcp: bool = Field(description="Parse the problem as a disciplined quasiconvex program.", default=False)
    requires_grad: bool = Field(
        description=(
            "Allow gradient computation with respect to Parameters by calling problem.backward() "
            "or problem.derivative() after solving."
        ),
        default=False,
    )
    enforce_dpp: bool = Field(
        description=(
            "Raise a DPPError for non-DPP problems when solving instead of only warning. "
            "Only relevant for problems involving Parameters."
        ),
        default=False,
    )
    ignore_dpp: bool = Field(
        description=("Treat DPP problems as non-DPP, which may speed up compilation."),
        default=False,
    )
    extra_options: dict[str, object] | None = Field(
        description="Additional keyword arguments forwarded directly to cvxpy Problem.solve().",
        default=None,
    )

check_cvxpy_suitability

check_cvxpy_suitability(problem: Problem) -> bool

Checks whether a problem is suitable for being solved with CVXPY.

Source code in desdeo/tools/cvxpy_solver_interfaces.py
def check_cvxpy_suitability(problem: Problem) -> bool:
    """Checks whether a problem is suitable for being solved with CVXPY."""
    try:
        evaluator = CVXPYEvaluator(problem)
        for obj in problem.objectives:
            evaluator.set_optimization_target(obj.symbol)
            if not (evaluator.problem_model.is_dcp() or evaluator.problem_model.is_dgp()):
                return False
                break
        else:
            return True
    except Exception:
        return False

parse_cvxpy_optimizer_results

parse_cvxpy_optimizer_results(
    problem: Problem, evaluator: CVXPYEvaluator
) -> SolverResults

Parses results from CVXPYEvaluator's problem into DESDEO SolverResults.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
evaluator CVXPYEvaluator

the evaluator utilized to solve the problem.

required

Returns:

Name Type Description
SolverResults SolverResults

DESDEO solver results.

Source code in desdeo/tools/cvxpy_solver_interfaces.py
def parse_cvxpy_optimizer_results(problem: Problem, evaluator: CVXPYEvaluator) -> SolverResults:
    """Parses results from CVXPYEvaluator's problem into DESDEO SolverResults.

    Args:
        problem (Problem): the problem being solved.
        evaluator (CVXPYEvaluator): the evaluator utilized to solve the problem.

    Returns:
        SolverResults: DESDEO solver results.
    """
    results = evaluator.get_values()

    variable_values = {var.symbol: results[var.symbol] for var in problem.variables}
    objective_values = {obj.symbol: results[obj.symbol] for obj in problem.objectives}
    constraint_values = (
        {con.symbol: results[con.symbol] for con in problem.constraints} if problem.constraints is not None else None
    )
    extra_func_values = (
        {extra.symbol: results[extra.symbol] for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: results[scal.symbol] for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )
    lagrange_multipliers = None

    success = evaluator.problem_model.status in {cp.OPTIMAL, cp.OPTIMAL_INACCURATE}
    if evaluator.problem_model.status == cp.OPTIMAL:
        status = "Optimal solution found."
    elif evaluator.problem_model.status == cp.OPTIMAL_INACCURATE:
        status = "Optimal solution found (inaccurate)."
    elif evaluator.problem_model.status == cp.INFEASIBLE:
        status = "Problem is infeasible."
    elif evaluator.problem_model.status == cp.UNBOUNDED:
        status = "Problem is unbounded."
    elif evaluator.problem_model.status == cp.INFEASIBLE_INACCURATE:
        status = "Problem is infeasible (inaccurate)."
    elif evaluator.problem_model.status == cp.UNBOUNDED_INACCURATE:
        status = "Problem is unbounded (inaccurate)."
    else:
        status = f"Optimization ended with status: {evaluator.problem_model.status}"
    msg = f"CVXPY solver status is: '{status}'"

    return SolverResults(
        optimal_variables=variable_values,
        optimal_objectives=objective_values,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        lagrange_multipliers=lagrange_multipliers,
        success=success,
        message=msg,
    )

Nevergrad solver interfaces

Solver interfaces to the optimization routines found in nevergrad.

For more info, see https://facebookresearch.github.io/nevergrad/index.html

_default_nevergrad_generic_options module-attribute

_default_nevergrad_generic_options = (
    NevergradGenericOptions()
)

The set of default options for nevergrad's NgOpt optimizer.

NevergradGenericOptions

Bases: BaseModel

Defines options to be passed to nevergrad's optimization routines.

Source code in desdeo/tools/ng_solver_interfaces.py
class NevergradGenericOptions(BaseModel):
    """Defines options to be passed to nevergrad's optimization routines."""

    budget: int = Field(description="The maximum number of allowed function evaluations.", default=100)
    """The maximum number of allowed function evaluations. Defaults to 100."""

    num_workers: int = Field(description="The maximum number of allowed parallel evaluations.", default=1)
    """The maximum number of allowed parallel evaluations. This is currently
    used to define the batch size when evaluating problems. Defaults to 1."""

    optimizer: Literal[*available_nevergrad_optimizers] = Field(
        description=(
            "The optimizer to be used. Must be one of `NGOpt`, `TwoPointDE`, `PortfolioDiscreteOnePlusOne`, "
            "`OnePlusOne`, `CMA`, `TBPSA`, `PSO`, `ScrHammersleySearchPlusMiddlePoint`, or `RandomSearch`. "
            "Defaults to `NGOpt`."
        ),
        default="NGOpt",
    )
    """The optimizer to be used. Must be one of `NGOpt`, `TwoPointsDE`, `PortfolioDiscreteOnePlusOne`,
    `OnePlusOne`, `CMA`, `TBPSA`, `PSO`, `ScrHammersleySearchPlusMiddlePoint`, or `RandomSearch`.
    Defaults to `NGOpt`."""

    seed: int | None = Field(
        description="An optional random seed for reproducible optimization. Defaults to None.", default=None
    )
    """An optional random seed for reproducible optimization. If `None`, the optimizer's
    random state is left unseeded. Defaults to None."""

budget class-attribute instance-attribute

budget: int = Field(
    description="The maximum number of allowed function evaluations.",
    default=100,
)

The maximum number of allowed function evaluations. Defaults to 100.

num_workers class-attribute instance-attribute

num_workers: int = Field(
    description="The maximum number of allowed parallel evaluations.",
    default=1,
)

The maximum number of allowed parallel evaluations. This is currently used to define the batch size when evaluating problems. Defaults to 1.

optimizer class-attribute instance-attribute

optimizer: Literal[*available_nevergrad_optimizers,] = (
    Field(
        description="The optimizer to be used. Must be one of `NGOpt`, `TwoPointDE`, `PortfolioDiscreteOnePlusOne`, `OnePlusOne`, `CMA`, `TBPSA`, `PSO`, `ScrHammersleySearchPlusMiddlePoint`, or `RandomSearch`. Defaults to `NGOpt`.",
        default="NGOpt",
    )
)

The optimizer to be used. Must be one of NGOpt, TwoPointsDE, PortfolioDiscreteOnePlusOne, OnePlusOne, CMA, TBPSA, PSO, ScrHammersleySearchPlusMiddlePoint, or RandomSearch. Defaults to NGOpt.

seed class-attribute instance-attribute

seed: int | None = Field(
    description="An optional random seed for reproducible optimization. Defaults to None.",
    default=None,
)

An optional random seed for reproducible optimization. If None, the optimizer's random state is left unseeded. Defaults to None.

NevergradGenericSolver

Bases: BaseSolver

Creates a solver that utilizes optimizations routines found in the nevergrad library.

Source code in desdeo/tools/ng_solver_interfaces.py
class NevergradGenericSolver(BaseSolver):
    """Creates a solver that utilizes optimizations routines found in the nevergrad library."""

    def __init__(self, problem: Problem, options: NevergradGenericOptions | None = _default_nevergrad_generic_options):
        """Creates a solver that utilizes optimizations routines found in the nevergrad library.

        These solvers are best utilized for black-box, gradient free optimization with
        computationally expensive function calls. Utilizing multiple workers is recommended
        (see `NevergradGenericOptions`) when function calls are heavily I/O bound.

        See https://facebookresearch.github.io/nevergrad/getting_started.html for further information
        on nevergrad and its solvers.

        References:
            Rapin, J., & Teytaud, O. (2018). Nevergrad - A gradient-free
                optimization platform. GitHub.
                https://GitHub.com/FacebookResearch/Nevergrad

        Args:
            problem (Problem): the problem to be solved.
            options (NgOptOptions | None): options to be passes to the solver.
                If none, `_default_ng_ngopt_options` are used. Defaults to None.

        """
        self.problem = problem
        self.options = options if options is not None else _default_nevergrad_generic_options
        self.evaluator = SympyEvaluator(problem)

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for the given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: the results of the optimization.
        """

        def _make_scalar(var):
            if var.initial_value is not None:
                init = var.initial_value
            elif var.lowerbound is not None and var.upperbound is not None:
                init = (var.lowerbound + var.upperbound) / 2
            elif var.lowerbound is not None:
                init = var.lowerbound
            elif var.upperbound is not None:
                init = var.upperbound
            else:
                init = 0.0
            scalar = ng.p.Scalar(init=init)
            if var.lowerbound is not None or var.upperbound is not None:
                scalar.set_bounds(var.lowerbound, var.upperbound)
            return scalar

        parametrization = ng.p.Dict(**{var.symbol: _make_scalar(var) for var in self.problem.variables})

        # When a seed is given, make the run reproducible. nevergrad (and NGOpt's optimizer selection)
        # draws from numpy's global RNG as well as the parametrization's own random state, so the global
        # RNG must be seeded before anything is constructed. It is restored in the `finally` below so the
        # caller's global numpy state is not mutated.
        rng_state = None
        if self.options.seed is not None:
            rng_state = np.random.get_state()  # noqa: NPY002
            np.random.seed(self.options.seed)  # noqa: NPY002

        optimizer = ng.optimizers.registry[self.options.optimizer](
            parametrization=parametrization, **self.options.model_dump(exclude={"optimizer", "seed"})
        )

        if self.options.seed is not None:
            optimizer.parametrization.random_state.seed(self.options.seed)

        constraint_symbols = (
            None if self.problem.constraints is None else [con.symbol for con in self.problem.constraints]
        )

        try:
            if optimizer.num_workers == 1:
                # single thread
                recommendation = optimizer.minimize(
                    lambda xs, t=target: self.evaluator.evaluate_target(xs, t),
                    constraint_violation=[
                        lambda xs, t=con_t: self.evaluator.evaluate_target(xs, t) for con_t in constraint_symbols
                    ]
                    if constraint_symbols is not None
                    else None,
                )

            elif optimizer.num_workers > 1:
                # multiple processors
                with ThreadPoolExecutor(max_workers=optimizer.num_workers) as executor:
                    recommendation = optimizer.minimize(
                        lambda xs, t=target: self.evaluator.evaluate_target(xs, t),
                        constraint_violation=[
                            lambda xs, t=con_t: self.evaluator.evaluate_target(xs, t) for con_t in constraint_symbols
                        ]
                        if constraint_symbols is not None
                        else None,
                        executor=executor,
                        batch_mode=False,
                    )

            msg = f"Recommendation found by {self.options.optimizer}."
            success = True

        except Exception as e:
            msg = f"{self.options.optimizer} failed. Possible reason: {e}"
            success = False
        finally:
            if rng_state is not None:
                np.random.set_state(rng_state)  # noqa: NPY002

        result = {"recommendation": recommendation, "message": msg, "success": success}

        return parse_ng_results(result, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: NevergradGenericOptions
    | None = _default_nevergrad_generic_options,
)

Creates a solver that utilizes optimizations routines found in the nevergrad library.

These solvers are best utilized for black-box, gradient free optimization with computationally expensive function calls. Utilizing multiple workers is recommended (see NevergradGenericOptions) when function calls are heavily I/O bound.

See https://facebookresearch.github.io/nevergrad/getting_started.html for further information on nevergrad and its solvers.

References

Rapin, J., & Teytaud, O. (2018). Nevergrad - A gradient-free optimization platform. GitHub. https://GitHub.com/FacebookResearch/Nevergrad

Parameters:

Name Type Description Default
problem Problem

the problem to be solved.

required
options NgOptOptions | None

options to be passes to the solver. If none, _default_ng_ngopt_options are used. Defaults to None.

_default_nevergrad_generic_options
Source code in desdeo/tools/ng_solver_interfaces.py
def __init__(self, problem: Problem, options: NevergradGenericOptions | None = _default_nevergrad_generic_options):
    """Creates a solver that utilizes optimizations routines found in the nevergrad library.

    These solvers are best utilized for black-box, gradient free optimization with
    computationally expensive function calls. Utilizing multiple workers is recommended
    (see `NevergradGenericOptions`) when function calls are heavily I/O bound.

    See https://facebookresearch.github.io/nevergrad/getting_started.html for further information
    on nevergrad and its solvers.

    References:
        Rapin, J., & Teytaud, O. (2018). Nevergrad - A gradient-free
            optimization platform. GitHub.
            https://GitHub.com/FacebookResearch/Nevergrad

    Args:
        problem (Problem): the problem to be solved.
        options (NgOptOptions | None): options to be passes to the solver.
            If none, `_default_ng_ngopt_options` are used. Defaults to None.

    """
    self.problem = problem
    self.options = options if options is not None else _default_nevergrad_generic_options
    self.evaluator = SympyEvaluator(problem)

solve

solve(target: str) -> SolverResults

Solve the problem for the given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

the results of the optimization.

Source code in desdeo/tools/ng_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for the given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: the results of the optimization.
    """

    def _make_scalar(var):
        if var.initial_value is not None:
            init = var.initial_value
        elif var.lowerbound is not None and var.upperbound is not None:
            init = (var.lowerbound + var.upperbound) / 2
        elif var.lowerbound is not None:
            init = var.lowerbound
        elif var.upperbound is not None:
            init = var.upperbound
        else:
            init = 0.0
        scalar = ng.p.Scalar(init=init)
        if var.lowerbound is not None or var.upperbound is not None:
            scalar.set_bounds(var.lowerbound, var.upperbound)
        return scalar

    parametrization = ng.p.Dict(**{var.symbol: _make_scalar(var) for var in self.problem.variables})

    # When a seed is given, make the run reproducible. nevergrad (and NGOpt's optimizer selection)
    # draws from numpy's global RNG as well as the parametrization's own random state, so the global
    # RNG must be seeded before anything is constructed. It is restored in the `finally` below so the
    # caller's global numpy state is not mutated.
    rng_state = None
    if self.options.seed is not None:
        rng_state = np.random.get_state()  # noqa: NPY002
        np.random.seed(self.options.seed)  # noqa: NPY002

    optimizer = ng.optimizers.registry[self.options.optimizer](
        parametrization=parametrization, **self.options.model_dump(exclude={"optimizer", "seed"})
    )

    if self.options.seed is not None:
        optimizer.parametrization.random_state.seed(self.options.seed)

    constraint_symbols = (
        None if self.problem.constraints is None else [con.symbol for con in self.problem.constraints]
    )

    try:
        if optimizer.num_workers == 1:
            # single thread
            recommendation = optimizer.minimize(
                lambda xs, t=target: self.evaluator.evaluate_target(xs, t),
                constraint_violation=[
                    lambda xs, t=con_t: self.evaluator.evaluate_target(xs, t) for con_t in constraint_symbols
                ]
                if constraint_symbols is not None
                else None,
            )

        elif optimizer.num_workers > 1:
            # multiple processors
            with ThreadPoolExecutor(max_workers=optimizer.num_workers) as executor:
                recommendation = optimizer.minimize(
                    lambda xs, t=target: self.evaluator.evaluate_target(xs, t),
                    constraint_violation=[
                        lambda xs, t=con_t: self.evaluator.evaluate_target(xs, t) for con_t in constraint_symbols
                    ]
                    if constraint_symbols is not None
                    else None,
                    executor=executor,
                    batch_mode=False,
                )

        msg = f"Recommendation found by {self.options.optimizer}."
        success = True

    except Exception as e:
        msg = f"{self.options.optimizer} failed. Possible reason: {e}"
        success = False
    finally:
        if rng_state is not None:
            np.random.set_state(rng_state)  # noqa: NPY002

    result = {"recommendation": recommendation, "message": msg, "success": success}

    return parse_ng_results(result, self.problem, self.evaluator)

parse_ng_results

parse_ng_results(
    results: dict,
    problem: Problem,
    evaluator: SympyEvaluator,
) -> SolverResults

Parses the optimization results returned by nevergrad solvers.

Parameters:

Name Type Description Default
results dict

the results. A dict with at least the keys recommendation, which points to a parametrization returned by nevergrad solvers, message with information about the optimization, and success indicating whther a recommendation was found successfully or not.

required
problem Problem

the problem the results belong to.

required
evaluator GenericEvaluator

the evaluator used to evaluate the problem.

required

Returns:

Name Type Description
SolverResults SolverResults

a pydantic dataclass withthe relevant optimization results.

Source code in desdeo/tools/ng_solver_interfaces.py
def parse_ng_results(results: dict, problem: Problem, evaluator: SympyEvaluator) -> SolverResults:
    """Parses the optimization results returned by nevergrad solvers.

    Args:
        results (dict): the results. A dict with at least the keys
            `recommendation`, which points to a parametrization returned by
            nevergrad solvers, `message` with information about the optimization,
            and `success` indicating whther a recommendation was found successfully
            or not.
        problem (Problem): the problem the results belong to.
        evaluator (GenericEvaluator): the evaluator used to evaluate the problem.

    Returns:
        SolverResults: a pydantic dataclass withthe relevant optimization results.
    """
    optimal_variables = results["recommendation"].value
    success = results["success"]
    msg = results["message"]

    results = evaluator.evaluate(optimal_variables)

    optimal_objectives = {obj.symbol: results[obj.symbol] for obj in problem.objectives}

    constraint_values = (
        {con.symbol: results[con.symbol] for con in problem.constraints} if problem.constraints is not None else None
    )
    extra_func_values = (
        {extra.symbol: results[extra.symbol] for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: results[scal.symbol] for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )

    return SolverResults(
        optimal_variables=optimal_variables,
        optimal_objectives=optimal_objectives,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        success=success,
        message=msg,
    )

Scipy solver interfaces

Solver interfaces to the optimization routines found in scipy.

These solvers can solve various scalarized problems of multiobjective optimization problems.

EvalTargetEnum

Bases: StrEnum

An enum that describe whether the evaluator target is an objective or a constraint.

Source code in desdeo/tools/scipy_solver_interfaces.py
class EvalTargetEnum(StrEnum):
    """An enum that describe whether the evaluator target is an objective or a constraint."""

    objective = "objective"
    constraint = "constraint"

ScipyDeOptions

Bases: BaseModel

Defines a pydantic model to store and pass options to the Scipy differential evolution solver.

Source code in desdeo/tools/scipy_solver_interfaces.py
class ScipyDeOptions(BaseModel):
    """Defines a pydantic model to store and pass options to the Scipy differential evolution solver."""

    initial_guess: dict[str, float | None] | None = Field(
        description="The initial guess to be utilized in the solver. For variables with a None as their initial "
        "guess, the mid-point of the variable's lower and upper bound is utilized as the initial"
        "guess. If None, it is assumed that there are no initial guesses for any of the variables.",
        default=None,
    )
    de_kwargs: dict | None = Field(
        description="Custom keyword arguments to be forwarded to `scipy.optimize.differential_evolution`.", default=None
    )

ScipyDeSolver

Bases: BaseSolver

Creates a scipy solver that utilizes differential evolution.

Source code in desdeo/tools/scipy_solver_interfaces.py
class ScipyDeSolver(BaseSolver):
    """Creates a scipy solver that utilizes differential evolution."""

    def __init__(
        self,
        problem: Problem,
        options: ScipyDeOptions = _default_scipy_de_options,
    ):
        """Creates a solver that utilizes the `scipy.optimize.differential_evolution` routine.

        The `scipy.optimize.differential_evolution` routine is fully accessible through this function.
        For additional details and explanation of some of the argumetns, see
        https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.differential_evolution.html

        Args:
            problem (Problem): the multiobjective optimization problem to be solved.
            options (ScipyDeOptions): Pydantic model containing arguments used by scipy DE solver.
        """
        initial_guess = options.initial_guess
        de_kwargs = options.de_kwargs

        if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
            msg = "ScipyDeSolver only supports scalar variables."
            raise SolverError(msg)

        self.problem = problem
        if de_kwargs is None:
            de_kwargs = {
                "strategy": "best1bin",
                "maxiter": 1000,
                "popsize": 15,
                "tol": 0.01,
                "mutation": (0.5, 1),
                "recombination": 0.7,
                "seed": None,
                "callback": None,
                "disp": False,
                "polish": True,
                "init": "latinhypercube",
                "atol": 0,
                "updating": "deferred",
                "workers": 1,
                "integrality": None,
                "vectorized": True,  # the constraints for scipy_de need to be fixed first for this to work
            }
        self.de_kwargs = de_kwargs

        # variable bounds
        self.bounds = get_variable_bounds_pairs(problem)

        # initial guess. If no guess is present for a variable, said variable's mid point of its
        # lower abd upper bound is used instead
        if initial_guess is None:
            self.initial_guess = set_initial_guess(problem)
        else:
            self.initial_guess = initial_guess

        self.evaluator = PolarsEvaluator(problem)
        self.constraints = (
            create_scipy_object_constraints(self.problem, self.evaluator)
            if self.problem.constraints is not None
            else ()
        )

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for a given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: results of the optimization.
        """
        # If the target is an objective symbol, use its _min form
        objective_symbols = {obj.symbol for obj in self.problem.objectives}
        eval_target = f"{target}_min" if target in objective_symbols else target

        optimization_result: _ScipyOptimizeResult = _scipy_de(
            get_scipy_eval(self.problem, self.evaluator, eval_target, EvalTargetEnum.objective),
            bounds=self.bounds,
            x0=self.initial_guess,
            constraints=self.constraints,
            **self.de_kwargs,
        )

        # parse the results
        return parse_scipy_optimization_result(optimization_result, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: ScipyDeOptions = _default_scipy_de_options,
)

Creates a solver that utilizes the scipy.optimize.differential_evolution routine.

The scipy.optimize.differential_evolution routine is fully accessible through this function. For additional details and explanation of some of the argumetns, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.differential_evolution.html

Parameters:

Name Type Description Default
problem Problem

the multiobjective optimization problem to be solved.

required
options ScipyDeOptions

Pydantic model containing arguments used by scipy DE solver.

_default_scipy_de_options
Source code in desdeo/tools/scipy_solver_interfaces.py
def __init__(
    self,
    problem: Problem,
    options: ScipyDeOptions = _default_scipy_de_options,
):
    """Creates a solver that utilizes the `scipy.optimize.differential_evolution` routine.

    The `scipy.optimize.differential_evolution` routine is fully accessible through this function.
    For additional details and explanation of some of the argumetns, see
    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.differential_evolution.html

    Args:
        problem (Problem): the multiobjective optimization problem to be solved.
        options (ScipyDeOptions): Pydantic model containing arguments used by scipy DE solver.
    """
    initial_guess = options.initial_guess
    de_kwargs = options.de_kwargs

    if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
        msg = "ScipyDeSolver only supports scalar variables."
        raise SolverError(msg)

    self.problem = problem
    if de_kwargs is None:
        de_kwargs = {
            "strategy": "best1bin",
            "maxiter": 1000,
            "popsize": 15,
            "tol": 0.01,
            "mutation": (0.5, 1),
            "recombination": 0.7,
            "seed": None,
            "callback": None,
            "disp": False,
            "polish": True,
            "init": "latinhypercube",
            "atol": 0,
            "updating": "deferred",
            "workers": 1,
            "integrality": None,
            "vectorized": True,  # the constraints for scipy_de need to be fixed first for this to work
        }
    self.de_kwargs = de_kwargs

    # variable bounds
    self.bounds = get_variable_bounds_pairs(problem)

    # initial guess. If no guess is present for a variable, said variable's mid point of its
    # lower abd upper bound is used instead
    if initial_guess is None:
        self.initial_guess = set_initial_guess(problem)
    else:
        self.initial_guess = initial_guess

    self.evaluator = PolarsEvaluator(problem)
    self.constraints = (
        create_scipy_object_constraints(self.problem, self.evaluator)
        if self.problem.constraints is not None
        else ()
    )

solve

solve(target: str) -> SolverResults

Solve the problem for a given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

results of the optimization.

Source code in desdeo/tools/scipy_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for a given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: results of the optimization.
    """
    # If the target is an objective symbol, use its _min form
    objective_symbols = {obj.symbol for obj in self.problem.objectives}
    eval_target = f"{target}_min" if target in objective_symbols else target

    optimization_result: _ScipyOptimizeResult = _scipy_de(
        get_scipy_eval(self.problem, self.evaluator, eval_target, EvalTargetEnum.objective),
        bounds=self.bounds,
        x0=self.initial_guess,
        constraints=self.constraints,
        **self.de_kwargs,
    )

    # parse the results
    return parse_scipy_optimization_result(optimization_result, self.problem, self.evaluator)

ScipyMinimizeOptions

Bases: BaseModel

Defines a pydantic model to store and pass options to the Scipy Minimize solver.

Source code in desdeo/tools/scipy_solver_interfaces.py
class ScipyMinimizeOptions(BaseModel):
    """Defines a pydantic model to store and pass options to the Scipy Minimize solver."""

    initial_guess: dict[str, float | None] | None = Field(
        description="The initial guess to be utilized in the solver. For variables with a None as their"
        "initial guess, the mid-point of the variable's lower and upper bound is utilized as the "
        "initial guess. If None, it is assumed that there are no initial guesses for any of the variables.",
        default=None,
    )
    method: str | None = Field(
        description="The scipy.optimize.minimize method to beused. If None, a method is selected "
        "automatically based on the properties of the objective (does it have constraints?).",
        default=None,
    )
    method_kwargs: dict | None = Field(
        description="The keyword arguments passed to the scipy.optimize.minimize method.", default=None
    )
    tol: float | None = Field(description="Tolerance for termination.", default=None)
    additional_options: dict | None = Field(description="Additional solver options.", default=None)

ScipyMinimizeSolver

Bases: BaseSolver

Creates a scipy solver that utilizes the minimization routine.

Source code in desdeo/tools/scipy_solver_interfaces.py
class ScipyMinimizeSolver(BaseSolver):
    """Creates a scipy solver that utilizes the `minimization` routine."""

    def __init__(self, problem: Problem, options: ScipyMinimizeOptions = _default_scipy_minimize_options):
        """Initializes a solver that utilizes the `scipy.optimize.minimize` routine.

        The `scipy.optimize.minimze` routine is fully accessible through this function.
        For additional details and explanation of some of the argumetns, see
        https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize

        Args:
            problem (Problem): the multiobjective optimization problem to be solved.
            options: (ScipyMinimizeOptions): Pydantic model containing args for scipy minimize solver.

        """
        if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
            msg = "ScipyMinimizeSolver only supports scalar variables."
            raise SolverError(msg)

        initial_guess = options.initial_guess
        self.problem = problem
        self.method = options.method
        self.method_kwargs = options.method_kwargs
        self.tol = options.tol
        self.additional_options = options.additional_options

        # variables bounds as (min, max pairs)
        self.bounds = get_variable_bounds_pairs(problem)

        # the initial guess as a simple sequence. If no initial value is set for some variable,
        # then the initial value defaults to middle of the upper and lower bounds.
        if initial_guess is not None:
            self.initial_guess = [initial_guess[var.symbol] for var in self.problem.variables]
        else:
            self.initial_guess = set_initial_guess(problem)

        self.evaluator = PolarsEvaluator(problem)

        self.constraints = (
            create_scipy_dict_constraints(self.problem, self.evaluator)
            if self.problem.constraints is not None
            else None
        )

    def solve(self, target: str) -> SolverResults:
        """Solves the problem for a given target.

        Args:
            target (str): the sumbol of the objective function to be optimized.

        Returns:
            SolverResults: results of the optimization.
        """
        # If the target is an objective symbol, use its _min form
        objective_symbols = {obj.symbol for obj in self.problem.objectives}
        eval_target = f"{target}_min" if target in objective_symbols else target

        optimization_result: _ScipyOptimizeResult = _scipy_minimize(
            get_scipy_eval(self.problem, self.evaluator, eval_target, EvalTargetEnum.objective),
            self.initial_guess,
            method=self.method,
            bounds=self.bounds,
            constraints=self.constraints,
            options=self.additional_options,
            tol=self.tol,
        )

        # pare and return the results
        return parse_scipy_optimization_result(optimization_result, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: ScipyMinimizeOptions = _default_scipy_minimize_options,
)

Initializes a solver that utilizes the scipy.optimize.minimize routine.

The scipy.optimize.minimze routine is fully accessible through this function. For additional details and explanation of some of the argumetns, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize

Parameters:

Name Type Description Default
problem Problem

the multiobjective optimization problem to be solved.

required
options ScipyMinimizeOptions

(ScipyMinimizeOptions): Pydantic model containing args for scipy minimize solver.

_default_scipy_minimize_options
Source code in desdeo/tools/scipy_solver_interfaces.py
def __init__(self, problem: Problem, options: ScipyMinimizeOptions = _default_scipy_minimize_options):
    """Initializes a solver that utilizes the `scipy.optimize.minimize` routine.

    The `scipy.optimize.minimze` routine is fully accessible through this function.
    For additional details and explanation of some of the argumetns, see
    https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize

    Args:
        problem (Problem): the multiobjective optimization problem to be solved.
        options: (ScipyMinimizeOptions): Pydantic model containing args for scipy minimize solver.

    """
    if variable_dimension_enumerate(problem) not in SUPPORTED_VAR_DIMENSIONS:
        msg = "ScipyMinimizeSolver only supports scalar variables."
        raise SolverError(msg)

    initial_guess = options.initial_guess
    self.problem = problem
    self.method = options.method
    self.method_kwargs = options.method_kwargs
    self.tol = options.tol
    self.additional_options = options.additional_options

    # variables bounds as (min, max pairs)
    self.bounds = get_variable_bounds_pairs(problem)

    # the initial guess as a simple sequence. If no initial value is set for some variable,
    # then the initial value defaults to middle of the upper and lower bounds.
    if initial_guess is not None:
        self.initial_guess = [initial_guess[var.symbol] for var in self.problem.variables]
    else:
        self.initial_guess = set_initial_guess(problem)

    self.evaluator = PolarsEvaluator(problem)

    self.constraints = (
        create_scipy_dict_constraints(self.problem, self.evaluator)
        if self.problem.constraints is not None
        else None
    )

solve

solve(target: str) -> SolverResults

Solves the problem for a given target.

Parameters:

Name Type Description Default
target str

the sumbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

results of the optimization.

Source code in desdeo/tools/scipy_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solves the problem for a given target.

    Args:
        target (str): the sumbol of the objective function to be optimized.

    Returns:
        SolverResults: results of the optimization.
    """
    # If the target is an objective symbol, use its _min form
    objective_symbols = {obj.symbol for obj in self.problem.objectives}
    eval_target = f"{target}_min" if target in objective_symbols else target

    optimization_result: _ScipyOptimizeResult = _scipy_minimize(
        get_scipy_eval(self.problem, self.evaluator, eval_target, EvalTargetEnum.objective),
        self.initial_guess,
        method=self.method,
        bounds=self.bounds,
        constraints=self.constraints,
        options=self.additional_options,
        tol=self.tol,
    )

    # pare and return the results
    return parse_scipy_optimization_result(optimization_result, self.problem, self.evaluator)

create_scipy_dict_constraints

create_scipy_dict_constraints(
    problem: Problem, evaluator: PolarsEvaluator
) -> dict

Creates a dict with scipy compatible constraints.

It is assumed that there are constraints defined in problem.

Parameters:

Name Type Description Default
problem Problem

the Problem with the constraints.

required
evaluator GenericEvaluator

the evaluator utilized to evaluate problem.

required

Returns:

Name Type Description
dict dict

a dict with scipy compatible constraints.

Source code in desdeo/tools/scipy_solver_interfaces.py
def create_scipy_dict_constraints(problem: Problem, evaluator: PolarsEvaluator) -> dict:
    """Creates a dict with scipy compatible constraints.

    It is assumed that there are constraints defined in problem.

    Args:
        problem (Problem): the Problem with the constraints.
        evaluator (GenericEvaluator): the evaluator utilized to evaluate problem.

    Returns:
        dict: a dict with scipy compatible constraints.
    """
    return [
        {
            "type": "ineq" if constraint.cons_type == ConstraintTypeEnum.LTE else "eq",
            "fun": get_scipy_eval(problem, evaluator, constraint.symbol, eval_target=EvalTargetEnum.constraint),
        }
        for constraint in problem.constraints
    ]

create_scipy_object_constraints

create_scipy_object_constraints(
    problem: Problem, evaluator: PolarsEvaluator
) -> list[NonlinearConstraint]

Creates a list with scipy constraint object NonLinearConstraints used by some scipy routines.

For more infor, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.NonlinearConstraint.html#scipy-optimize-nonlinearconstraint

Parameters:

Name Type Description Default
problem Problem

the problem with the original constraint to be utilized in creating the list of constraints.

required
evaluator GenericEvaluator

the evaluator corresponding to problem that can be used to evaluate the constraints.

required

Returns:

Type Description
list[NonlinearConstraint]

list[NonlinearConstraint]: a list of scipy's NonLinearConstraint objects.

Source code in desdeo/tools/scipy_solver_interfaces.py
def create_scipy_object_constraints(problem: Problem, evaluator: PolarsEvaluator) -> list[NonlinearConstraint]:
    """Creates a list with scipy constraint object `NonLinearConstraints` used by some scipy routines.

    For more infor, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.NonlinearConstraint.html#scipy-optimize-nonlinearconstraint

    Args:
        problem (Problem): the problem with the original constraint to be utilized in creating the list of constraints.
        evaluator (GenericEvaluator): the evaluator corresponding to problem that can be used to evaluate
            the constraints.

    Returns:
        list[NonlinearConstraint]: a list of scipy's NonLinearConstraint objects.
    """
    return NonlinearConstraint(
        fun=get_scipy_eval(problem, evaluator, "", eval_target=EvalTargetEnum.constraint),
        lb=0,  # constraint value must be between 0 and inf, e.g., positive.
        ub=float("inf"),  # since in scipy, a constraint is respected when its value is positive. See scipy_eval.
    )

get_scipy_eval

get_scipy_eval(
    problem: Problem,
    evaluator: PolarsEvaluator,
    target: str,
    eval_target: EvalTargetEnum,
) -> Callable[[list[float | int]], list[float | int]]

Wraps the problem and evaluator into a callable function that can be used by scipy routines.

The returned function expects an array-like argument, such as a numpy array or list.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
evaluator GenericEvaluator

the evaluator to evaluate the problem being solved.

required
target str

the symbol of the objective to of the optimization, defined in problem.

required
eval_target EvalTargetEnum

either objective or constraints. If objective, it is assumed that the evalution is about evaluating the objective function of the single-objective optimization problem being solved, e.g., a scalarization function defined in problem. If constraint, then the evalution is assumed to be about evaluating the constraints defined in problem.

required

Returns:

Type Description
Callable[[list[float | int]], list[float | int]]

Callable[[list[float | int]], list[float | int]]: a function that takes as its argument an array like object.

Note

Constraints in scipy are defined such that a positive number means the constraint is respected. In DESDEO, this is the opposite, e.g., a positive number means a constraint is breached. We take this into account when returning the constraint values, but this does not affect the constraint values computed for the true constraints.

Source code in desdeo/tools/scipy_solver_interfaces.py
def get_scipy_eval(
    problem: Problem,
    evaluator: PolarsEvaluator,
    target: str,
    eval_target: EvalTargetEnum,
) -> Callable[[list[float | int]], list[float | int]]:
    """Wraps the problem and evaluator into a callable function that can be used by scipy routines.

    The returned function expects an array-like argument, such as a numpy array or list.

    Args:
        problem (Problem): the problem being solved.
        evaluator (GenericEvaluator): the evaluator to evaluate the problem being solved.
        target (str): the symbol of the objective to of the optimization, defined in problem.
        eval_target (EvalTargetEnum): either objective or constraints. If objective,
            it is assumed that the evalution is about evaluating the objective function
            of the single-objective optimization problem being solved, e.g., a scalarization function
            defined in problem. If constraint, then the evalution is assumed to be about evaluating
            the constraints defined in problem.

    Returns:
      Callable[[list[float | int]], list[float | int]]: a function that takes as its argument
        an array like object.

    Note:
        Constraints in scipy are defined such that a positive number means the constraint
            is respected. In DESDEO, this is the opposite, e.g., a positive number means
            a constraint is breached. We take this into account when returning the
            constraint values, but this does not affect the constraint values computed
            for the true constraints.
    """

    def scipy_eval(x: list[float | int]) -> list[float | int]:
        """An evaluator to be used in scipy routines.

        Args:
            x (list[float  |  int]): an array like, such as a numpy array or list.append

        Raises:
            SolverError: when an invalid evaluator target is specified.

        Returns:
            list[float | int]: an array like.
        """
        # TODO: Consider caching the results of evaluator.evaluate
        evalutor_args = {
            problem.variables[i].symbol: [x[i]] if isinstance(x[i], float | int) else x[i]
            for i in range(len(problem.variables))
        }

        if eval_target == EvalTargetEnum.objective:
            evaluator_res = evaluator.evaluate(evalutor_args)

            # evaluata objective (scalarized)
            return evaluator_res.to_dict(as_series=False)[target]

        if eval_target == EvalTargetEnum.constraint:
            evaluator_df = evaluator.evaluate(evalutor_args)
            # evaluate constraint
            # put the minus here because scipy expect positive constraints values when the constraint
            # is respected. But in DESDEO, we define constraints s.t., a negative value means the constraint
            # is recpected, therefore, it needs to be flipped here.
            con_symbols = [constraint.symbol for constraint in problem.constraints]
            res_dict = evaluator_df[con_symbols].to_dict(as_series=False)

            res = np.array([np.array(res_dict[symbol]) for symbol in con_symbols])

            # squeeze important for minimization routines
            return -np.squeeze(res, axis=-1) if res.shape[-1] == 1 else -res

        # non-existing eval_target
        msg = f"'eval_target' = '{eval_target} not supported. Must be one of {list(EvalTargetEnum)}."
        raise SolverError(msg)

    return scipy_eval

get_variable_bounds_pairs

get_variable_bounds_pairs(
    problem: Problem,
) -> list[tuple[float | int, float | int]]

Returns the variable bounds defined in a Problem as a list of tuples.

Parameters:

Name Type Description Default
problem Problem

the problem with the variables of interest.

required

Returns:

Type Description
list[tuple[float | int, float | int]]

list[tuple[float | int, float | int]]: a list of tuples, the first element of each tuple is the lower bound of a variable and the second its upper bound. Each tuple corresponds to a variable.

Source code in desdeo/tools/scipy_solver_interfaces.py
def get_variable_bounds_pairs(problem: Problem) -> list[tuple[float | int, float | int]]:
    """Returns the variable bounds defined in a Problem as a list of tuples.

    Args:
        problem (Problem): the problem with the variables of interest.

    Returns:
        list[tuple[float | int, float | int]]: a list of tuples, the first
            element of each tuple is the lower bound of a variable and the second
            its upper bound. Each tuple corresponds to a variable.
    """
    return [(variable.lowerbound, variable.upperbound) for variable in problem.variables]

parse_scipy_optimization_result

parse_scipy_optimization_result(
    optimization_result: OptimizeResult,
    problem: Problem,
    evaluator: PolarsEvaluator,
) -> SolverResults

Parses the optimization results returned by various scipy methods.

For documentation, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html#scipy.optimize.OptimizeResult

Parameters:

Name Type Description Default
optimization_result OptimizeResult

the optimization results.

required
problem Problem

the problem to which the optimization results correspond to.

required
evaluator GenericEvaluator

the evaluator that has been used in computing the optimization results.

required

Returns:

Name Type Description
SolverResults SolverResults

a pydantic dataclass with the relevant optimization results.

Source code in desdeo/tools/scipy_solver_interfaces.py
def parse_scipy_optimization_result(
    optimization_result: _ScipyOptimizeResult, problem: Problem, evaluator: PolarsEvaluator
) -> SolverResults:
    """Parses the optimization results returned by various scipy methods.

    For documentation, see https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.OptimizeResult.html#scipy.optimize.OptimizeResult

    Args:
        optimization_result (_ScipyOptimizeResult): the optimization results.
        problem (Problem): the problem to which the optimization results correspond to.
        evaluator (GenericEvaluator): the evaluator that has been used in computing the optimization results.

    Returns:
        SolverResults: a pydantic dataclass with the relevant optimization results.
    """
    x_opt = optimization_result.x
    success_opt = optimization_result.success
    msg_opt = optimization_result.message

    results = evaluator.evaluate({problem.variables[i].symbol: [x_opt[i]] for i in range(len(problem.variables))})

    optimal_objectives = {obj.symbol: results[obj.symbol][0] for obj in problem.objectives}
    constraint_values = (
        {con.symbol: results[con.symbol][0] for con in problem.constraints} if problem.constraints is not None else None
    )

    """
    objective_symbols = [obj.symbol for obj in problem.objectives]
    f_res = results[objective_symbols]
    optimal_objectives = {symbol: f_res[symbol][0] for symbol in objective_symbols}

    if problem.constraints is not None:
        const_symbols = [const.symbol for const in problem.constraints]
        const_res = results[const_symbols]
        constraint_values = {symbol: const_res[symbol][0] for symbol in const_symbols}
    else:
        constraint_values = None
    """

    extra_func_values = (
        {extra.symbol: results[extra.symbol][0] for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: results[scal.symbol][0] for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )

    return SolverResults(
        optimal_variables={problem.variables[i].symbol: x_opt[i] for i in range(len(problem.variables))},
        optimal_objectives=optimal_objectives,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        success=success_opt,
        message=msg_opt,
    )

set_initial_guess

set_initial_guess(problem: Problem) -> list[float | int]

Sets or gets the initial guess for each variable defined in a Problem.

For variables without an initial guess, the initial guess is set to the middle point of the variable's lower and upper bound. If only one of the bounds is defined, that bound is used instead. If neither bound is defined, the initial guess defaults to 0.

Parameters:

Name Type Description Default
problem Problem

the problem with the variables of which the initial values are of interest.

required

Returns:

Type Description
list[float | int]

list[float | int]: a list of numbers, each number represents the initial guess of each variable in the problem.

Source code in desdeo/tools/scipy_solver_interfaces.py
def set_initial_guess(problem: Problem) -> list[float | int]:
    """Sets or gets the initial guess for each variable defined in a Problem.

    For variables without an initial guess, the initial guess is set to the middle point of
    the variable's lower and upper bound. If only one of the bounds is defined, that bound is
    used instead. If neither bound is defined, the initial guess defaults to 0.

    Args:
        problem (Problem): the problem with the variables of which the initial values are of interest.

    Returns:
        list[float | int]: a list of numbers, each number represents the initial guess of each variable in the problem.
    """
    guesses = []
    for variable in problem.variables:
        if variable.initial_value is not None:
            guesses.append(variable.initial_value)
        elif variable.lowerbound is not None and variable.upperbound is not None:
            guesses.append((variable.upperbound - variable.lowerbound) / 2 + variable.lowerbound)
        elif variable.lowerbound is not None:
            guesses.append(variable.lowerbound)
        elif variable.upperbound is not None:
            guesses.append(variable.upperbound)
        else:
            guesses.append(0)
    return guesses

Pyomo solver interfaces

Defines solver interfaces for pyomo.

_default_bonmin_options module-attribute

_default_bonmin_options = BonminOptions()

Defines Bonmin options with default values.

_default_cbc_options module-attribute

_default_cbc_options = CbcOptions()

Defines CBC options with default values.

_default_ipopt_options module-attribute

_default_ipopt_options = IpoptOptions()

Defines Ipopt optins with default values.

BonminOptions

Bases: BaseModel

Defines a pydantic model to store and pass options to the Bonmin solver.

Because Bonmin utilizes many sub-solver, the options specific to Bonmin must be prefixed in their name with 'bonmin.{option_name}', e.g., bonmin.integer_tolerance. For a list of options, see https://www.coin-or.org/Bonmin/options_list.html

Note

Not all options are available through this model. Please add options as they are needed and make a pull request.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class BonminOptions(BaseModel):
    """Defines a pydantic model to store and pass options to the Bonmin solver.

    Because Bonmin utilizes many sub-solver, the options specific to Bonmin
    must be prefixed in their name with 'bonmin.{option_name}',
    e.g., `bonmin.integer_tolerance`. For a list of options, see
    https://www.coin-or.org/Bonmin/options_list.html

    Note:
        Not all options are available through this model.
        Please add options as they are needed and make a pull request.
    """

    tol: float = Field(description="Sets the convergence tolerance of ipopt. Defaults to 1e-8.", default=1e-8)
    """Sets the convergence tolerance of ipopt. Defaults to 1e-8."""

    bonmin_integer_tolerance: float = Field(
        description="Numbers within this value of an integer are considered integers. Defaults to 1e-6.", default=1e-6
    )
    """Numbers within this value of an integer are considered integers. Defaults to 1e-6."""

    bonmin_algorithm: str = Field(
        description=(
            "Presets some of the options in Bonmin based on the algorithm choice. Defaults to 'B-BB'. "
            "A good first option to try is 'B-Hyb'."
        ),
        default="B-BB",
    )
    """Presets some of the options in Bonmin based on the algorithm choice. Defaults to 'B-BB'.
    A good first option to try is 'B-Hyb'.
    """

    def asdict(self) -> dict[str, float]:
        """Converts the Pydantic model into a dict so that Bonmin specific options are in the correct format.

        This means that the attributes starting with `bonmin_optionname` will be
        converted to keys in the format `bonmin.optionname` in the returned dict.
        """
        output = {}
        for field_name, _ in BonminOptions.model_fields.items():
            if (rest := field_name.split(sep="_"))[0] == "bonmin":
                # Convert to Bonmin specific format
                output[f"bonmin.{'_'.join(rest[1:])}"] = getattr(self, field_name)
            else:
                # Keep the field as is
                output[field_name] = getattr(self, field_name)

        return output

bonmin_algorithm class-attribute instance-attribute

bonmin_algorithm: str = Field(
    description="Presets some of the options in Bonmin based on the algorithm choice. Defaults to 'B-BB'. A good first option to try is 'B-Hyb'.",
    default="B-BB",
)

Presets some of the options in Bonmin based on the algorithm choice. Defaults to 'B-BB'. A good first option to try is 'B-Hyb'.

bonmin_integer_tolerance class-attribute instance-attribute

bonmin_integer_tolerance: float = Field(
    description="Numbers within this value of an integer are considered integers. Defaults to 1e-6.",
    default=1e-06,
)

Numbers within this value of an integer are considered integers. Defaults to 1e-6.

tol class-attribute instance-attribute

tol: float = Field(
    description="Sets the convergence tolerance of ipopt. Defaults to 1e-8.",
    default=1e-08,
)

Sets the convergence tolerance of ipopt. Defaults to 1e-8.

asdict

asdict() -> dict[str, float]

Converts the Pydantic model into a dict so that Bonmin specific options are in the correct format.

This means that the attributes starting with bonmin_optionname will be converted to keys in the format bonmin.optionname in the returned dict.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def asdict(self) -> dict[str, float]:
    """Converts the Pydantic model into a dict so that Bonmin specific options are in the correct format.

    This means that the attributes starting with `bonmin_optionname` will be
    converted to keys in the format `bonmin.optionname` in the returned dict.
    """
    output = {}
    for field_name, _ in BonminOptions.model_fields.items():
        if (rest := field_name.split(sep="_"))[0] == "bonmin":
            # Convert to Bonmin specific format
            output[f"bonmin.{'_'.join(rest[1:])}"] = getattr(self, field_name)
        else:
            # Keep the field as is
            output[field_name] = getattr(self, field_name)

    return output

CbcOptions

Bases: BaseModel

Defines a pydantic dataclass to pass options to the CBC solver.

For more information and documentation on the options, see https://github.com/coin-or/Cbc

Note

Not all options are available through this model. Please add options as they are needed and make a pull request.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class CbcOptions(BaseModel):
    """Defines a pydantic dataclass to pass options to the CBC solver.

    For more information and documentation on the options,
    see https://github.com/coin-or/Cbc

    Note:
        Not all options are available through this model.
        Please add options as they are needed and make a pull request.
    """

    model_config = ConfigDict(frozen=True, populate_by_name=True)

    seconds: int = Field(
        description="The maximum amount of time (in seconds) the solver should run. Defaults to 600.", default=600
    )
    """The maximum amount of time (in seconds) the solver should run. Defaults to 600."""

    threads: int = Field(
        description="Number of threads (cores) to use for solving the problem. Defaults to 4.", default=4
    )
    """Number of threads (cores) to use for solving the problem. Defaults to 4."""

    log_level: int = Field(
        alias="logLevel",
        description=(
            "Controls the level of logging output. Values range from 0 (no output) to 5 (very detailed output)."
            " Defaults to 2."
        ),
        default=2,
    )
    """Controls the level of logging output. Values range from 0 (no output) to 5 (very detailed output).
    Defaults to 2.
    """

    max_solutions: int = Field(
        alias="maxSolutions",
        description="Limits the number of feasible solutions found by the solver. Defaults to 10.",
        default=10,
    )
    """Limits the number of feasible solutions found by the solver. Defaults to 10."""

    max_nodes: int = Field(
        alias="maxNodes",
        description="Sets the maximum number of branch-and-bound nodes to explore. Defaults to 1000.",
        default=1000,
    )
    """Sets the maximum number of branch-and-bound nodes to explore. Defaults to 1000."""

    ratio_gap: float = Field(
        alias="ratioGap",
        description=(
            "Sets the relative MIP gap (as a fraction of the optimal solution value) at which the solver will"
            " terminate. Defaults to 0.01."
        ),
        default=0.01,
    )
    """Sets the relative MIP gap (as a fraction of the optimal solution value) at which the solver will terminate.
    Defaults to 0.01.
    """

    absolute_gap: float = Field(
        alias="absoluteGap",
        description=(
            "Sets the absolute MIP gap (an absolute value) at which the solver will terminate.  Defaults to 1.0."
        ),
        default=1.0,
    )
    """Sets the absolute MIP gap (an absolute value) at which the solver will terminate. Defaults to 1.0."""

    solve: str = Field(
        description=(
            "Determines the strategy to use for solving the problem (e.g., 'branchAndCut', 'tree', 'trunk')."
            " Defaults to 'branchAndCut'."
        ),
        default="branchAndCut",
    )
    """Determines the strategy to use for solving the problem (e.g., 'branchAndCut', 'tree', 'trunk').
    Defaults to 'branchAndCut'.
    """

    presolve: int = Field(
        description="Controls the presolve level (0: no presolve, 1: default, 2: aggressive). Defaults to 2.", default=2
    )
    """Controls the presolve level (0: no presolve, 1: default, 2: aggressive). Defaults to 2."""

    feasibility_tolerance: float = Field(
        alias="feasibilityTolerance",
        description="Sets the feasibility tolerance for constraints. Defaults to 1e-6.",
        default=1e-6,
    )
    """Sets the feasibility tolerance for constraints. Defaults to 1e-6."""

    integer_tolerance: float = Field(
        alias="integerTolerance",
        description="Sets the tolerance for integrality of integer variables. Defaults to 1e-5.",
        default=1e-5,
    )
    """Sets the tolerance for integrality of integer variables. Defaults to 1e-5."""

absolute_gap class-attribute instance-attribute

absolute_gap: float = Field(
    alias="absoluteGap",
    description="Sets the absolute MIP gap (an absolute value) at which the solver will terminate.  Defaults to 1.0.",
    default=1.0,
)

Sets the absolute MIP gap (an absolute value) at which the solver will terminate. Defaults to 1.0.

feasibility_tolerance class-attribute instance-attribute

feasibility_tolerance: float = Field(
    alias="feasibilityTolerance",
    description="Sets the feasibility tolerance for constraints. Defaults to 1e-6.",
    default=1e-06,
)

Sets the feasibility tolerance for constraints. Defaults to 1e-6.

integer_tolerance class-attribute instance-attribute

integer_tolerance: float = Field(
    alias="integerTolerance",
    description="Sets the tolerance for integrality of integer variables. Defaults to 1e-5.",
    default=1e-05,
)

Sets the tolerance for integrality of integer variables. Defaults to 1e-5.

log_level class-attribute instance-attribute

log_level: int = Field(
    alias="logLevel",
    description="Controls the level of logging output. Values range from 0 (no output) to 5 (very detailed output). Defaults to 2.",
    default=2,
)

Controls the level of logging output. Values range from 0 (no output) to 5 (very detailed output). Defaults to 2.

max_nodes class-attribute instance-attribute

max_nodes: int = Field(
    alias="maxNodes",
    description="Sets the maximum number of branch-and-bound nodes to explore. Defaults to 1000.",
    default=1000,
)

Sets the maximum number of branch-and-bound nodes to explore. Defaults to 1000.

max_solutions class-attribute instance-attribute

max_solutions: int = Field(
    alias="maxSolutions",
    description="Limits the number of feasible solutions found by the solver. Defaults to 10.",
    default=10,
)

Limits the number of feasible solutions found by the solver. Defaults to 10.

presolve class-attribute instance-attribute

presolve: int = Field(
    description="Controls the presolve level (0: no presolve, 1: default, 2: aggressive). Defaults to 2.",
    default=2,
)

Controls the presolve level (0: no presolve, 1: default, 2: aggressive). Defaults to 2.

ratio_gap class-attribute instance-attribute

ratio_gap: float = Field(
    alias="ratioGap",
    description="Sets the relative MIP gap (as a fraction of the optimal solution value) at which the solver will terminate. Defaults to 0.01.",
    default=0.01,
)

Sets the relative MIP gap (as a fraction of the optimal solution value) at which the solver will terminate. Defaults to 0.01.

seconds class-attribute instance-attribute

seconds: int = Field(
    description="The maximum amount of time (in seconds) the solver should run. Defaults to 600.",
    default=600,
)

The maximum amount of time (in seconds) the solver should run. Defaults to 600.

solve class-attribute instance-attribute

solve: str = Field(
    description="Determines the strategy to use for solving the problem (e.g., 'branchAndCut', 'tree', 'trunk'). Defaults to 'branchAndCut'.",
    default="branchAndCut",
)

Determines the strategy to use for solving the problem (e.g., 'branchAndCut', 'tree', 'trunk'). Defaults to 'branchAndCut'.

threads class-attribute instance-attribute

threads: int = Field(
    description="Number of threads (cores) to use for solving the problem. Defaults to 4.",
    default=4,
)

Number of threads (cores) to use for solving the problem. Defaults to 4.

IpoptOptions

Bases: BaseModel

Defines a pydantic dataclass to pass options to the Ipopt solver.

For more information and documentation on the options, see https://coin-or.github.io/Ipopt/

Note

Not all options are available through this model. Please add options as they are needed and make a pull request.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class IpoptOptions(BaseModel):
    """Defines a pydantic dataclass to pass options to the Ipopt solver.

    For more information and documentation on the options,
    see https://coin-or.github.io/Ipopt/

    Note:
        Not all options are available through this model.
        Please add options as they are needed and make a pull request.
    """

    tol: float = Field(description="The desired relative convergence tolerance. Defaults to 1e-8.", default=1e-8)
    """The desired relative convergence tolerance. Defaults to 1e-8."""

    max_iter: int = Field(description="Maximum number of iterations. Must be >1. Defaults to 3000.", default=3000)
    """Maximum number of iterations. Must be >1. Defaults to 3000."""

    print_level: int = Field(
        description="The verbosity level of the solver's output. Ranges between 0 and 12. Defaults to 5.", default=5
    )
    """The verbosity level of the solver's output. Ranges between 0 and 12."""

max_iter class-attribute instance-attribute

max_iter: int = Field(
    description="Maximum number of iterations. Must be >1. Defaults to 3000.",
    default=3000,
)

Maximum number of iterations. Must be >1. Defaults to 3000.

print_level class-attribute instance-attribute

print_level: int = Field(
    description="The verbosity level of the solver's output. Ranges between 0 and 12. Defaults to 5.",
    default=5,
)

The verbosity level of the solver's output. Ranges between 0 and 12.

tol class-attribute instance-attribute

tol: float = Field(
    description="The desired relative convergence tolerance. Defaults to 1e-8.",
    default=1e-08,
)

The desired relative convergence tolerance. Defaults to 1e-8.

PyomoBonminSolver

Bases: BaseSolver

Creates pyomo solvers that utilize bonmin.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class PyomoBonminSolver(BaseSolver):
    """Creates pyomo solvers that utilize bonmin."""

    def __init__(self, problem: Problem, options: BonminOptions | None = _default_bonmin_options):
        """The solver is initialized with a problem and solver options.

        Suitable for mixed-integer problems. The objective function being minimized
        (target) and the constraint functions must be twice continuously
        differentiable. When the objective functions and constraints are convex, the
        solution is exact. When the objective or any of the constraints, or both,
        are non-convex, then the solution is based on heuristics.

        For more info about bonmin, see: https://www.coin-or.org/Bonmin/

        Note:
            Bonmin must be installed on the system running DESDEO, and its executable
                must be defined in the PATH.

        Args:
            problem (Problem): the problem to be solved.
            options (BonminOptions, optional): options to be passed to the Bonmin solver.
                If `None` is passed, defaults to `_default_bonmin_options` defined in
                this source file. Defaults to `None`.
        """
        if not problem.is_twice_differentiable:
            raise SolverError("Problem must be twice differentiable.")
        self.problem = problem
        self.evaluator = PyomoEvaluator(problem)

        if options is None:
            self.options = _default_bonmin_options
        else:
            self.options = options

        # Add suffix to request dual values from Bonmin
        self.evaluator.model.dual = pyomo.Suffix(direction=pyomo.Suffix.IMPORT)

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for a given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: the results of the optimization.
        """
        self.evaluator.set_optimization_target(target)

        opt = pyomo.SolverFactory("bonmin", tee=True)

        # set solver options
        for key, value in self.options.asdict().items():
            opt.options[key] = value
        opt_res = opt.solve(self.evaluator.model)

        return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: BonminOptions | None = _default_bonmin_options,
)

The solver is initialized with a problem and solver options.

Suitable for mixed-integer problems. The objective function being minimized (target) and the constraint functions must be twice continuously differentiable. When the objective functions and constraints are convex, the solution is exact. When the objective or any of the constraints, or both, are non-convex, then the solution is based on heuristics.

For more info about bonmin, see: https://www.coin-or.org/Bonmin/

Note

Bonmin must be installed on the system running DESDEO, and its executable must be defined in the PATH.

Parameters:

Name Type Description Default
problem Problem

the problem to be solved.

required
options BonminOptions

options to be passed to the Bonmin solver. If None is passed, defaults to _default_bonmin_options defined in this source file. Defaults to None.

_default_bonmin_options
Source code in desdeo/tools/pyomo_solver_interfaces.py
def __init__(self, problem: Problem, options: BonminOptions | None = _default_bonmin_options):
    """The solver is initialized with a problem and solver options.

    Suitable for mixed-integer problems. The objective function being minimized
    (target) and the constraint functions must be twice continuously
    differentiable. When the objective functions and constraints are convex, the
    solution is exact. When the objective or any of the constraints, or both,
    are non-convex, then the solution is based on heuristics.

    For more info about bonmin, see: https://www.coin-or.org/Bonmin/

    Note:
        Bonmin must be installed on the system running DESDEO, and its executable
            must be defined in the PATH.

    Args:
        problem (Problem): the problem to be solved.
        options (BonminOptions, optional): options to be passed to the Bonmin solver.
            If `None` is passed, defaults to `_default_bonmin_options` defined in
            this source file. Defaults to `None`.
    """
    if not problem.is_twice_differentiable:
        raise SolverError("Problem must be twice differentiable.")
    self.problem = problem
    self.evaluator = PyomoEvaluator(problem)

    if options is None:
        self.options = _default_bonmin_options
    else:
        self.options = options

    # Add suffix to request dual values from Bonmin
    self.evaluator.model.dual = pyomo.Suffix(direction=pyomo.Suffix.IMPORT)

solve

solve(target: str) -> SolverResults

Solve the problem for a given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

the results of the optimization.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for a given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: the results of the optimization.
    """
    self.evaluator.set_optimization_target(target)

    opt = pyomo.SolverFactory("bonmin", tee=True)

    # set solver options
    for key, value in self.options.asdict().items():
        opt.options[key] = value
    opt_res = opt.solve(self.evaluator.model)

    return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

PyomoCBCSolver

Bases: BaseSolver

Create a pyomo solver that utilizes CBC.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class PyomoCBCSolver(BaseSolver):
    """Create a pyomo solver that utilizes CBC."""

    def __init__(self, problem: Problem, options: CbcOptions | None = _default_cbc_options):
        """The solver is initialized with a problem and solver options.

        Suitable for combinatorial and large-scale mixed-integer linear problems.

        For more information, see https://coin-or.github.io/Ipopt/

        Note:
            CBC must be installed on the system running DESDEO, and its executable
                must be defined in the PATH.

        Args:
            problem (Problem): the problem being solved.
            options (CbcOptions, optional): options to be passed to the CBC solver.
                If `None` is passed, defaults to `_default_cbc_options` defined in
                this source file. Defaults to `None`.
        """
        if not problem.is_linear:
            raise SolverError("Nonlinear problems not supported.")
        self.problem = problem
        self.evaluator = PyomoEvaluator(problem)

        if options is None:
            self.options = _default_cbc_options
        else:
            self.options = options

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for a given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: results of the Optimization.
        """
        self.evaluator.set_optimization_target(target)

        opt = pyomo.SolverFactory("cbc", tee=True, options=self.options.model_dump())
        opt_res = opt.solve(self.evaluator.model)
        return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: CbcOptions | None = _default_cbc_options,
)

The solver is initialized with a problem and solver options.

Suitable for combinatorial and large-scale mixed-integer linear problems.

For more information, see https://coin-or.github.io/Ipopt/

Note

CBC must be installed on the system running DESDEO, and its executable must be defined in the PATH.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
options CbcOptions

options to be passed to the CBC solver. If None is passed, defaults to _default_cbc_options defined in this source file. Defaults to None.

_default_cbc_options
Source code in desdeo/tools/pyomo_solver_interfaces.py
def __init__(self, problem: Problem, options: CbcOptions | None = _default_cbc_options):
    """The solver is initialized with a problem and solver options.

    Suitable for combinatorial and large-scale mixed-integer linear problems.

    For more information, see https://coin-or.github.io/Ipopt/

    Note:
        CBC must be installed on the system running DESDEO, and its executable
            must be defined in the PATH.

    Args:
        problem (Problem): the problem being solved.
        options (CbcOptions, optional): options to be passed to the CBC solver.
            If `None` is passed, defaults to `_default_cbc_options` defined in
            this source file. Defaults to `None`.
    """
    if not problem.is_linear:
        raise SolverError("Nonlinear problems not supported.")
    self.problem = problem
    self.evaluator = PyomoEvaluator(problem)

    if options is None:
        self.options = _default_cbc_options
    else:
        self.options = options

solve

solve(target: str) -> SolverResults

Solve the problem for a given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

results of the Optimization.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for a given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: results of the Optimization.
    """
    self.evaluator.set_optimization_target(target)

    opt = pyomo.SolverFactory("cbc", tee=True, options=self.options.model_dump())
    opt_res = opt.solve(self.evaluator.model)
    return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

PyomoGurobiSolver

Bases: BaseSolver

Creates a pyomo solver that utilized Gurobi.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class PyomoGurobiSolver(BaseSolver):
    """Creates a pyomo solver that utilized Gurobi."""

    def __init__(self, problem: Problem, options: dict[str, any] | None = None):
        """Creates a pyomo solver that utilizes gurobi.

        You need to have gurobi installed on your system for this to work.

        Suitable for solving mixed-integer linear and quadratic optimization
        problems.

        Args:
            problem (Problem): the problem to be solved.
            options (GurobiOptions): Dictionary of Gurobi parameters to set.
                This is passed to pyomo as is, so it works the same as options
                would for calling pyomo SolverFactory directly.
                See https://www.gurobi.com/documentation/current/refman/parameters.html
                for information on the available options
        """
        self.problem = problem
        self.evaluator = PyomoEvaluator(problem)

        if options is None:
            self.options = {}
        else:
            self.options = options

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for a given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: the results of the optimization.
        """
        self.evaluator.set_optimization_target(target)

        with pyomo.SolverFactory("gurobi", solver_io="python") as opt:
            opt_res = opt.solve(self.evaluator.model)
            return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem, options: dict[str, any] | None = None
)

Creates a pyomo solver that utilizes gurobi.

You need to have gurobi installed on your system for this to work.

Suitable for solving mixed-integer linear and quadratic optimization problems.

Parameters:

Name Type Description Default
problem Problem

the problem to be solved.

required
options GurobiOptions

Dictionary of Gurobi parameters to set. This is passed to pyomo as is, so it works the same as options would for calling pyomo SolverFactory directly. See https://www.gurobi.com/documentation/current/refman/parameters.html for information on the available options

None
Source code in desdeo/tools/pyomo_solver_interfaces.py
def __init__(self, problem: Problem, options: dict[str, any] | None = None):
    """Creates a pyomo solver that utilizes gurobi.

    You need to have gurobi installed on your system for this to work.

    Suitable for solving mixed-integer linear and quadratic optimization
    problems.

    Args:
        problem (Problem): the problem to be solved.
        options (GurobiOptions): Dictionary of Gurobi parameters to set.
            This is passed to pyomo as is, so it works the same as options
            would for calling pyomo SolverFactory directly.
            See https://www.gurobi.com/documentation/current/refman/parameters.html
            for information on the available options
    """
    self.problem = problem
    self.evaluator = PyomoEvaluator(problem)

    if options is None:
        self.options = {}
    else:
        self.options = options

solve

solve(target: str) -> SolverResults

Solve the problem for a given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

the results of the optimization.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for a given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: the results of the optimization.
    """
    self.evaluator.set_optimization_target(target)

    with pyomo.SolverFactory("gurobi", solver_io="python") as opt:
        opt_res = opt.solve(self.evaluator.model)
        return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

PyomoIpoptSolver

Bases: BaseSolver

Create a pyomo solver that utilizes Ipopt.

Source code in desdeo/tools/pyomo_solver_interfaces.py
class PyomoIpoptSolver(BaseSolver):
    """Create a pyomo solver that utilizes Ipopt."""

    def __init__(self, problem: Problem, options: IpoptOptions | None = _default_ipopt_options):
        """The solver is initialized with a problem and solver options.

        Suitable for non-linear, twice differentiable constrained problems.
        The problem may be convex or non-convex.

        For more information, see https://coin-or.github.io/Ipopt/

        Note:
            Ipopt must be installed on the system running DESDEO, and its executable
                must be defined in the PATH.

        Args:
            problem (Problem): the problem being solved.
            options (IpoptOptions, optional): options to be passed to the Ipopt solver.
                If `None` is passed, defaults to `_default_ipopt_options` defined in
                this source file. Defaults to `None`.
        """
        if not problem.is_twice_differentiable:
            raise SolverError("Problem must be twice differentiable.")
        self.problem = problem
        self.evaluator = PyomoEvaluator(problem)

        if options is None:
            self.options = _default_ipopt_options
        else:
            self.options = options

        # Add suffix to request dual values from Ipopt
        self.evaluator.model.dual = pyomo.Suffix(direction=pyomo.Suffix.IMPORT)

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for a given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: results of the Optimization.
        """
        self.evaluator.set_optimization_target(target)

        opt = pyomo.SolverFactory("ipopt", tee=True, options=self.options.model_dump())
        opt_res = opt.solve(self.evaluator.model)
        return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

__init__

__init__(
    problem: Problem,
    options: IpoptOptions | None = _default_ipopt_options,
)

The solver is initialized with a problem and solver options.

Suitable for non-linear, twice differentiable constrained problems. The problem may be convex or non-convex.

For more information, see https://coin-or.github.io/Ipopt/

Note

Ipopt must be installed on the system running DESDEO, and its executable must be defined in the PATH.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
options IpoptOptions

options to be passed to the Ipopt solver. If None is passed, defaults to _default_ipopt_options defined in this source file. Defaults to None.

_default_ipopt_options
Source code in desdeo/tools/pyomo_solver_interfaces.py
def __init__(self, problem: Problem, options: IpoptOptions | None = _default_ipopt_options):
    """The solver is initialized with a problem and solver options.

    Suitable for non-linear, twice differentiable constrained problems.
    The problem may be convex or non-convex.

    For more information, see https://coin-or.github.io/Ipopt/

    Note:
        Ipopt must be installed on the system running DESDEO, and its executable
            must be defined in the PATH.

    Args:
        problem (Problem): the problem being solved.
        options (IpoptOptions, optional): options to be passed to the Ipopt solver.
            If `None` is passed, defaults to `_default_ipopt_options` defined in
            this source file. Defaults to `None`.
    """
    if not problem.is_twice_differentiable:
        raise SolverError("Problem must be twice differentiable.")
    self.problem = problem
    self.evaluator = PyomoEvaluator(problem)

    if options is None:
        self.options = _default_ipopt_options
    else:
        self.options = options

    # Add suffix to request dual values from Ipopt
    self.evaluator.model.dual = pyomo.Suffix(direction=pyomo.Suffix.IMPORT)

solve

solve(target: str) -> SolverResults

Solve the problem for a given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

results of the Optimization.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for a given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: results of the Optimization.
    """
    self.evaluator.set_optimization_target(target)

    opt = pyomo.SolverFactory("ipopt", tee=True, options=self.options.model_dump())
    opt_res = opt.solve(self.evaluator.model)
    return parse_pyomo_optimizer_results(opt_res, self.problem, self.evaluator)

parse_pyomo_optimizer_results

parse_pyomo_optimizer_results(
    opt_res: SolverResults,
    problem: Problem,
    evaluator: PyomoEvaluator,
) -> SolverResults

Parses pyomo SolverResults into DESDEO SolverResults.

Parameters:

Name Type Description Default
opt_res SolverResults

the pyomo solver results.

required
problem Problem

the problem being solved.

required
evaluator PyomoEvaluator

the evaluator utilized to get the pyomo solver results.

required

Returns:

Name Type Description
SolverResults SolverResults

DESDEO solver results.

Source code in desdeo/tools/pyomo_solver_interfaces.py
def parse_pyomo_optimizer_results(  # noqa: C901
    opt_res: _pyomo_SolverResults, problem: Problem, evaluator: PyomoEvaluator
) -> SolverResults:
    """Parses pyomo SolverResults into DESDEO SolverResults.

    Args:
        opt_res (SolverResults): the pyomo solver results.
        problem (Problem): the problem being solved.
        evaluator (PyomoEvaluator): the evaluator utilized to get the pyomo solver results.

    Returns:
        SolverResults: DESDEO solver results.
    """
    results = evaluator.get_values()

    variable_values = {}
    for var in problem.variables:
        if isinstance(var, TensorVariable):
            # handle tensor variables
            # 1-indexing in Pyomo...
            values_list = np.zeros(var.shape)
            for indices in itertools.product(*(range(1, dim + 1) for dim in var.shape)):
                values_list[*[idx - 1 for idx in indices]] = results[var.symbol][
                    indices if len(indices) > 1 else indices[0]
                ]
            variable_values[var.symbol] = values_list.tolist()
        else:
            # variable_values = {var.symbol: results[var.symbol] for var in problem.variables}
            variable_values[var.symbol] = results[var.symbol]

    objective_values = {obj.symbol: results[obj.symbol] for obj in problem.objectives}
    constraint_values = (
        {con.symbol: results[con.symbol] for con in problem.constraints} if problem.constraints else None
    )

    # handle constraint, which might be multi-valued
    if problem.constraints is not None:
        constraint_values = {}

        for con in problem.constraints:
            result = results[con.symbol]

            if isinstance(result, dict):
                # multi-valued
                indices = list(getattr(evaluator.model, con.symbol).keys())
                if indices and isinstance(indices[0], int):
                    # 1-D constraint indexed by RangeSet — keys are plain integers
                    values_list = np.zeros(len(indices))
                    for idx in indices:
                        values_list[idx - 1] = result[idx]
                else:
                    # multi-D constraint — keys are tuples
                    shape = tuple(len({idx[k] for idx in indices}) for k in range(len(indices[0])))
                    values_list = np.zeros(shape)
                    for idx in indices:
                        values_list[*[i - 1 for i in idx]] = result[idx]

                constraint_values[con.symbol] = values_list.tolist()

            else:
                # scalar-valued
                constraint_values[con.symbol] = result
    else:
        constraint_values = None

    extra_func_values = (
        {extra.symbol: results[extra.symbol] for extra in problem.extra_funcs}
        if problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: results[scal.symbol] for scal in problem.scalarization_funcs}
        if problem.scalarization_funcs is not None
        else None
    )
    # Extract Lagrange multipliers with error handling
    lagrange_multipliers = None
    if problem.constraints:
        try:
            if hasattr(evaluator.model, "dual") and evaluator.model.dual:
                lagrange_multipliers = {}
                for con in problem.constraints:
                    try:
                        constraint_obj = getattr(evaluator.model, con.symbol)
                        if constraint_obj in evaluator.model.dual:
                            lagrange_multipliers["mu_" + con.symbol] = evaluator.model.dual[constraint_obj]
                    except (AttributeError, KeyError):
                        continue
                if not lagrange_multipliers:
                    lagrange_multipliers = None
        except (AttributeError, KeyError):
            lagrange_multipliers = None

    success = (
        opt_res.solver.status == _pyomo_SolverStatus.ok
        and opt_res.solver.termination_condition == _pyomo_TerminationCondition.optimal
    )
    msg = (
        f"Pyomo solver status is: '{opt_res.solver.status}', with termination condition: "
        f"'{opt_res.solver.termination_condition}'."
    )

    return SolverResults(
        optimal_variables=variable_values,
        optimal_objectives=objective_values,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        lagrange_multipliers=lagrange_multipliers,
        success=success,
        message=msg,
    )

Proximal solver

Defines solvers meant to be utilized with Problems with discrete representations.

ProximalSolver

Bases: BaseSolver

Creates a solver that finds the closest solution given a fully discrete problem.

Note

This solver is extremely naive. It will optimize the problem and the result will be a point defined for a discrete problem that is closest (Euclidean distance) to the optimum. The result may be wildly inaccurate depending on how representative the discrete points are of the original problem.

Source code in desdeo/tools/proximal_solver.py
class ProximalSolver(BaseSolver):
    """Creates a solver that finds the closest solution given a fully discrete problem.

    Note:
        This solver is extremely naive. It will optimize the problem and the result will
            be a point defined for a discrete problem that is closest (Euclidean
            distance) to the optimum. The result may be wildly inaccurate depending on how
            representative the discrete points are of the original problem.
    """

    def __init__(self, problem: Problem, kwargs: dict | None = None):
        """Creates a solver that assumes the problem being a fully discrete one.

        Assumes that problem has only data-based objectives and a discrete definition
        that fully defines all the objectives.

        Args:
            problem (Problem): the problem being solved.
            kwargs (Optional[dict]): optional keyword arguments. Not used right now, but kept
                here for compatibility reasons. Defaults to None.

        """
        for obj in problem.objectives:
            if obj.objective_type is not ObjectiveTypeEnum.data_based:
                raise SolverError(f"All objectives must be data-based {obj.symbol}.")
        if problem.discrete_representation is None:
            raise SolverError("Problem must have a discrete representation defined.")
        self.problem = problem
        self.evaluator = PolarsEvaluator(problem, evaluator_mode=PolarsEvaluatorModesEnum.discrete)

    def solve(self, target: str) -> SolverResults:
        """Solve the problem for the given target.

        Args:
            target (str): the symbol of the objective function to be optimized.

        Returns:
            SolverResults: the results fo the optimization.
        """
        results_df = self.evaluator.evaluate()

        # check constraint values if problem has constraints
        if self.problem.constraints is not None:
            cons_condition = pl.lit(True)  # noqa: FBT003
            for constraint in self.problem.constraints:
                cons_condition = cons_condition & (results_df[constraint.symbol] <= 0)

            results_df = results_df.filter(cons_condition)

        # find the row with the minimum value in the 'target' column
        closest = results_df.sort(target).head(1)

        # extract relevant results, extract them as dict for easier jsonification
        variable_values = {variable.symbol: closest[variable.symbol][0] for variable in self.problem.variables}
        objective_values = {objective.symbol: closest[objective.symbol][0] for objective in self.problem.objectives}
        constraint_values = (
            {constraint.symbol: closest[constraint.symbol][0] for constraint in self.problem.constraints}
            if self.problem.constraints is not None
            else None
        )
        extra_func_values = (
            {extra.symbol: closest[extra.symbol][0] for extra in self.problem.extra_funcs}
            if self.problem.extra_funcs is not None
            else None
        )
        scalarization_values = (
            {scal.symbol: closest[scal.symbol][0] for scal in self.problem.scalarization_funcs}
            if self.problem.scalarization_funcs is not None
            else None
        )
        message = f"Optimal value found from tabular data minimizing the column '{target}'."
        success = True

        # wrap results and return them
        return SolverResults(
            optimal_variables=variable_values,
            optimal_objectives=objective_values,
            constraint_values=constraint_values,
            extra_func_values=extra_func_values,
            scalarization_values=scalarization_values,
            success=success,
            message=message,
        )

__init__

__init__(problem: Problem, kwargs: dict | None = None)

Creates a solver that assumes the problem being a fully discrete one.

Assumes that problem has only data-based objectives and a discrete definition that fully defines all the objectives.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
kwargs Optional[dict]

optional keyword arguments. Not used right now, but kept here for compatibility reasons. Defaults to None.

None
Source code in desdeo/tools/proximal_solver.py
def __init__(self, problem: Problem, kwargs: dict | None = None):
    """Creates a solver that assumes the problem being a fully discrete one.

    Assumes that problem has only data-based objectives and a discrete definition
    that fully defines all the objectives.

    Args:
        problem (Problem): the problem being solved.
        kwargs (Optional[dict]): optional keyword arguments. Not used right now, but kept
            here for compatibility reasons. Defaults to None.

    """
    for obj in problem.objectives:
        if obj.objective_type is not ObjectiveTypeEnum.data_based:
            raise SolverError(f"All objectives must be data-based {obj.symbol}.")
    if problem.discrete_representation is None:
        raise SolverError("Problem must have a discrete representation defined.")
    self.problem = problem
    self.evaluator = PolarsEvaluator(problem, evaluator_mode=PolarsEvaluatorModesEnum.discrete)

solve

solve(target: str) -> SolverResults

Solve the problem for the given target.

Parameters:

Name Type Description Default
target str

the symbol of the objective function to be optimized.

required

Returns:

Name Type Description
SolverResults SolverResults

the results fo the optimization.

Source code in desdeo/tools/proximal_solver.py
def solve(self, target: str) -> SolverResults:
    """Solve the problem for the given target.

    Args:
        target (str): the symbol of the objective function to be optimized.

    Returns:
        SolverResults: the results fo the optimization.
    """
    results_df = self.evaluator.evaluate()

    # check constraint values if problem has constraints
    if self.problem.constraints is not None:
        cons_condition = pl.lit(True)  # noqa: FBT003
        for constraint in self.problem.constraints:
            cons_condition = cons_condition & (results_df[constraint.symbol] <= 0)

        results_df = results_df.filter(cons_condition)

    # find the row with the minimum value in the 'target' column
    closest = results_df.sort(target).head(1)

    # extract relevant results, extract them as dict for easier jsonification
    variable_values = {variable.symbol: closest[variable.symbol][0] for variable in self.problem.variables}
    objective_values = {objective.symbol: closest[objective.symbol][0] for objective in self.problem.objectives}
    constraint_values = (
        {constraint.symbol: closest[constraint.symbol][0] for constraint in self.problem.constraints}
        if self.problem.constraints is not None
        else None
    )
    extra_func_values = (
        {extra.symbol: closest[extra.symbol][0] for extra in self.problem.extra_funcs}
        if self.problem.extra_funcs is not None
        else None
    )
    scalarization_values = (
        {scal.symbol: closest[scal.symbol][0] for scal in self.problem.scalarization_funcs}
        if self.problem.scalarization_funcs is not None
        else None
    )
    message = f"Optimal value found from tabular data minimizing the column '{target}'."
    success = True

    # wrap results and return them
    return SolverResults(
        optimal_variables=variable_values,
        optimal_objectives=objective_values,
        constraint_values=constraint_values,
        extra_func_values=extra_func_values,
        scalarization_values=scalarization_values,
        success=success,
        message=message,
    )

Scenario tools

Tools for constructing and solving scenario-based optimization problems.

_build_constant_maps

_build_constant_maps(
    leaf_scenarios: list[str],
    scenario_problems: dict[str, Problem],
) -> tuple[
    dict[str, dict[str, str]],
    dict[str, Constant | TensorConstant],
]

Build per-leaf constant rename maps and the combined constant dict.

Constants whose value is the same in every leaf keep their original symbol. Constants that differ across leaves are renamed to symbol_leaf.

Returns:

Name Type Description
const_maps dict[str, dict[str, str]]

{leaf -> {original_sym -> new_sym}}

combined_constants dict[str, Constant | TensorConstant]

{new_sym -> Constant | TensorConstant}

Source code in desdeo/tools/scenarios.py
def _build_constant_maps(
    leaf_scenarios: list[str],
    scenario_problems: dict[str, Problem],
) -> tuple[dict[str, dict[str, str]], dict[str, Constant | TensorConstant]]:
    """Build per-leaf constant rename maps and the combined constant dict.

    Constants whose value is the same in every leaf keep their original symbol.
    Constants that differ across leaves are renamed to ``symbol_leaf``.

    Returns:
        const_maps: {leaf -> {original_sym -> new_sym}}
        combined_constants: {new_sym -> Constant | TensorConstant}
    """
    const_per_leaf: dict[str, dict[str, Constant | TensorConstant]] = {
        leaf: {c.symbol: c for c in (scenario_problems[leaf].constants or [])} for leaf in leaf_scenarios
    }
    all_const_syms: set[str] = {sym for lc in const_per_leaf.values() for sym in lc}

    const_maps: dict[str, dict[str, str]] = {leaf: {} for leaf in leaf_scenarios}
    combined_constants: dict[str, Constant | TensorConstant] = {}

    for sym in all_const_syms:
        values = {
            leaf: const_per_leaf[leaf][sym].value
            for leaf in leaf_scenarios
            if sym in const_per_leaf[leaf] and hasattr(const_per_leaf[leaf][sym], "value")
        }
        if len(set(values.values())) <= 1:
            for leaf in leaf_scenarios:
                const_maps[leaf][sym] = sym
            first = next(
                (const_per_leaf[leaf][sym] for leaf in leaf_scenarios if sym in const_per_leaf[leaf]),
                None,
            )
            if first is not None:
                combined_constants[sym] = first
        else:
            for leaf in leaf_scenarios:
                if sym in const_per_leaf[leaf]:
                    new_sym = f"{leaf}_{sym}"
                    const_maps[leaf][sym] = new_sym
                    combined_constants[new_sym] = const_per_leaf[leaf][sym].model_copy(update={"symbol": new_sym})

    return const_maps, combined_constants

_build_parent_map

_build_parent_map(
    scenario_tree: dict[str, list[str]],
) -> dict[str, str]

Return a mapping from each node to its parent node.

Source code in desdeo/tools/scenarios.py
def _build_parent_map(scenario_tree: dict[str, list[str]]) -> dict[str, str]:
    """Return a mapping from each node to its parent node."""
    parent: dict[str, str] = {}
    for node, children in scenario_tree.items():
        for child in children:
            parent[child] = node
    return parent

_build_variable_maps

_build_variable_maps(
    scenario_model: ScenarioModel,
    leaf_scenarios: list[str],
    parent_map: dict[str, str],
    scenario_problems: dict[str, Problem],
) -> tuple[
    dict[str, dict[str, str]],
    dict[str, Variable | TensorVariable],
]

Build per-leaf variable rename maps and the combined variable dict.

Returns:

Name Type Description
var_maps dict[str, dict[str, str]]

{leaf -> {original_sym -> new_sym}}

combined_variables dict[str, Variable | TensorVariable]

{new_sym -> Variable | TensorVariable}

Source code in desdeo/tools/scenarios.py
def _build_variable_maps(
    scenario_model: "ScenarioModel",
    leaf_scenarios: list[str],
    parent_map: dict[str, str],
    scenario_problems: dict[str, Problem],
) -> tuple[dict[str, dict[str, str]], dict[str, Variable | TensorVariable]]:
    """Build per-leaf variable rename maps and the combined variable dict.

    Returns:
        var_maps: {leaf -> {original_sym -> new_sym}}
        combined_variables: {new_sym -> Variable | TensorVariable}
    """
    var_syms = {v.symbol for v in scenario_model.base_problem.variables}

    var_maps: dict[str, dict[str, str]] = {
        leaf: {sym: _new_variable_symbol(sym, leaf, scenario_model.anticipation_stop, parent_map) for sym in var_syms}
        for leaf in leaf_scenarios
    }

    combined_variables: dict[str, Variable | TensorVariable] = {}
    for leaf in leaf_scenarios:
        for var in scenario_problems[leaf].variables:
            new_sym = var_maps[leaf].get(var.symbol, var.symbol)
            if new_sym not in combined_variables:
                combined_variables[new_sym] = var.model_copy(
                    update={"symbol": new_sym, "name": f"{var.name} ({new_sym})"}
                )

    return var_maps, combined_variables

_combine_elements

_combine_elements(
    leaf_scenarios: list[str],
    scenario_problems: dict[str, Problem],
    var_maps: dict[str, dict[str, str]],
    const_maps: dict[str, dict[str, str]],
    get_list: callable,
    make_update: callable,
    extra_leaf_maps: dict[str, dict[str, str]]
    | None = None,
) -> tuple[list | None, dict[str, dict[str, str]]]

Build a combined list for one element type across all leaf scenarios.

Elements whose renamed func string is identical across every leaf that carries them are kept as a single shared element (original symbol). All others get a per-leaf prefix leaf_symbol.

Parameters:

Name Type Description Default
leaf_scenarios list[str]

ordered list of leaf scenario names.

required
scenario_problems dict[str, Problem]

pre-computed {leaf -> Problem} mapping.

required
var_maps dict[str, dict[str, str]]

per-leaf variable rename maps {leaf -> {orig_sym -> new_sym}}.

required
const_maps dict[str, dict[str, str]]

per-leaf constant rename maps {leaf -> {orig_sym -> new_sym}}.

required
get_list callable

callable(Problem) -> list | None of elements.

required
make_update callable

callable(elem, new_func, leaf) -> dict for model_copy. leaf is the scenario name for per-leaf elements, or None for shared ones. new_sym is derived inside as f"{leaf}_{elem.symbol}" or elem.symbol.

required
extra_leaf_maps dict[str, dict[str, str]] | None

optional additional per-leaf rename maps merged into the leaf_map before renaming expressions. Useful for passing objective-symbol renames (including _min versions) when processing scalarization functions and constraints.

None

Returns:

Type Description
tuple[list | None, dict[str, dict[str, str]]]

A tuple of (combined list or None, symbol map). The symbol map has the original symbol as key and a {leaf -> new_symbol} dict as value. Leaves that do not carry an element keep the original symbol as their value.

Source code in desdeo/tools/scenarios.py
def _combine_elements(
    leaf_scenarios: list[str],
    scenario_problems: dict[str, Problem],
    var_maps: dict[str, dict[str, str]],
    const_maps: dict[str, dict[str, str]],
    get_list: callable,
    make_update: callable,
    extra_leaf_maps: "dict[str, dict[str, str]] | None" = None,
) -> tuple[list | None, dict[str, dict[str, str]]]:
    """Build a combined list for one element type across all leaf scenarios.

    Elements whose renamed func string is identical across every leaf that
    carries them are kept as a single shared element (original symbol).
    All others get a per-leaf prefix ``leaf_symbol``.

    Args:
        leaf_scenarios: ordered list of leaf scenario names.
        scenario_problems: pre-computed {leaf -> Problem} mapping.
        var_maps: per-leaf variable rename maps {leaf -> {orig_sym -> new_sym}}.
        const_maps: per-leaf constant rename maps {leaf -> {orig_sym -> new_sym}}.
        get_list: callable(Problem) -> list | None of elements.
        make_update: callable(elem, new_func, leaf) -> dict for model_copy.
            ``leaf`` is the scenario name for per-leaf elements, or ``None`` for shared ones.
            ``new_sym`` is derived inside as ``f"{leaf}_{elem.symbol}"`` or ``elem.symbol``.
        extra_leaf_maps: optional additional per-leaf rename maps merged into
            the leaf_map before renaming expressions.  Useful for passing
            objective-symbol renames (including ``_min`` versions) when
            processing scalarization functions and constraints.

    Returns:
        A tuple of (combined list or None, symbol map).  The symbol map has the
            original symbol as key and a {leaf -> new_symbol} dict as value. Leaves
            that do not carry an element keep the original symbol as their value.
    """
    combined: list = []
    seen: set[str] = set()
    symbol_map: dict[str, dict[str, str]] = {}

    all_syms: set[str] = {elem.symbol for leaf in leaf_scenarios for elem in (get_list(scenario_problems[leaf]) or [])}

    for sym in all_syms:
        renamed: dict[str, str] = {}
        for leaf in leaf_scenarios:
            match = next(
                (e for e in (get_list(scenario_problems[leaf]) or []) if e.symbol == sym),
                None,
            )
            if match is None:
                continue
            leaf_map = {
                **var_maps[leaf],
                **const_maps[leaf],
                **(extra_leaf_maps[leaf] if extra_leaf_maps else {}),
            }
            renamed[leaf] = _rename_symbols(match.func, leaf_map)

        is_shared = len({str(v) for v in renamed.values()}) == 1 and len(renamed) == len(leaf_scenarios)

        if is_shared:
            first_leaf = next(iter(renamed))
            symbol_map[sym] = dict.fromkeys(leaf_scenarios, sym)
            entries = [(sym, first_leaf, renamed[first_leaf], None)]
        else:
            symbol_map[sym] = {leaf: f"{leaf}_{sym}" if leaf in renamed else sym for leaf in leaf_scenarios}
            entries = [(f"{leaf}_{sym}", leaf, new_func, leaf) for leaf, new_func in renamed.items()]

        for new_sym, src_leaf, new_func, name_leaf in entries:
            if new_sym not in seen:
                seen.add(new_sym)
                elem = next(e for e in (get_list(scenario_problems[src_leaf]) or []) if e.symbol == sym)
                combined.append(elem.model_copy(update=make_update(elem, new_func, name_leaf)))

    return combined or None, symbol_map

_longest_common_name

_longest_common_name(
    names: list[str], fallback: str
) -> str

Return the longest common substring across all names, stripped of edge separators.

Falls back to fallback when the list is empty or no non-empty common substring exists.

Source code in desdeo/tools/scenarios.py
def _longest_common_name(names: "list[str]", fallback: str) -> str:
    """Return the longest common substring across all names, stripped of edge separators.

    Falls back to *fallback* when the list is empty or no non-empty common substring exists.
    """
    if not names:
        return fallback
    shortest = min(names, key=len)
    for length in range(len(shortest), 0, -1):
        for start in range(len(shortest) - length + 1):
            candidate = shortest[start : start + length]
            if all(candidate in name for name in names):
                stripped = candidate.strip("_- .")
                if stripped:
                    return stripped
    return fallback

_new_variable_symbol

_new_variable_symbol(
    var_sym: str,
    leaf: str,
    anticipation_stop: dict[str, list[str]],
    parent_map: dict[str, str],
) -> str

Return the combined-problem symbol for a variable in a given leaf scenario.

Walks from ROOT toward the leaf. The first (highest) ancestor where the variable appears in anticipation_stop determines sharing:

  • ROOT -> all scenarios share one copy; original symbol is kept.
  • Other -> all leaves under that node share one copy; symbol gets that node name as prefix.
  • None -> fully independent per leaf; symbol gets the leaf name as prefix.
Source code in desdeo/tools/scenarios.py
def _new_variable_symbol(
    var_sym: str,
    leaf: str,
    anticipation_stop: dict[str, list[str]],
    parent_map: dict[str, str],
) -> str:
    """Return the combined-problem symbol for a variable in a given leaf scenario.

    Walks from ROOT toward the leaf.  The first (highest) ancestor where the
    variable appears in anticipation_stop determines sharing:

    - ROOT  -> all scenarios share one copy; original symbol is kept.
    - Other -> all leaves under that node share one copy; symbol gets that
               node name as prefix.
    - None  -> fully independent per leaf; symbol gets the leaf name as prefix.
    """
    for node in _path_from_root(leaf, parent_map):
        if var_sym in anticipation_stop.get(node, []):
            return var_sym if node == "ROOT" else f"{node}_{var_sym}"
    return f"{leaf}_{var_sym}"

_path_from_root

_path_from_root(
    node: str, parent_map: dict[str, str]
) -> list[str]

Return the path [ROOT, ..., node] inclusive of both ends.

Source code in desdeo/tools/scenarios.py
def _path_from_root(node: str, parent_map: dict[str, str]) -> list[str]:
    """Return the path [ROOT, ..., node] inclusive of both ends."""
    path: list[str] = []
    current: str | None = node
    while current is not None:
        path.append(current)
        current = parent_map.get(current)
    path.reverse()
    return path

_pool_names_for

_pool_names_for(
    scenario_model: ScenarioModel,
    found_type: str,
    sym: str,
    per_leaf: dict[str, str],
) -> list[str]

Collect distinct pool-element names for sym across the leaves in per_leaf.

Source code in desdeo/tools/scenarios.py
def _pool_names_for(
    scenario_model: "ScenarioModel",
    found_type: str,
    sym: str,
    per_leaf: "dict[str, str]",
) -> "list[str]":
    """Collect distinct pool-element names for *sym* across the leaves in *per_leaf*."""
    pool: tuple = getattr(scenario_model, found_type, ())
    seen: set[int] = set()
    names: list[str] = []
    for leaf_name in per_leaf:
        scenario = scenario_model.scenarios.get(leaf_name)
        if scenario is None:
            continue
        elem_map: dict[str, int] = getattr(scenario, found_type, {})
        if sym in elem_map:
            idx = elem_map[sym]
            if idx not in seen and idx < len(pool):
                seen.add(idx)
                name = getattr(pool[idx], "name", None)
                if name:
                    names.append(name)
    return names

_rename_in_mathjson

_rename_in_mathjson(node, symbol_map: dict[str, str])

Recursively rename symbol strings in a MathJSON node.

Source code in desdeo/tools/scenarios.py
def _rename_in_mathjson(node, symbol_map: dict[str, str]):
    """Recursively rename symbol strings in a MathJSON node."""
    if isinstance(node, str):
        return node if node in _RESERVED else symbol_map.get(node, node)
    if isinstance(node, list):
        return [_rename_in_mathjson(child, symbol_map) for child in node]
    return node  # int / float

_rename_symbols

_rename_symbols(
    expr: str | list, symbol_map: dict[str, str]
) -> list

Rename symbols in a MathJSON expression (list) or infix string.

Walks the MathJSON tree and substitutes every string leaf that is not a known operator keyword. If expr is a plain infix string it is first parsed to MathJSON, then the renaming is applied.

The returned value is always a MathJSON list, which Pydantic's parse_infix_to_func validator accepts directly without re-parsing.

Source code in desdeo/tools/scenarios.py
def _rename_symbols(expr: str | list, symbol_map: dict[str, str]) -> list:
    """Rename symbols in a MathJSON expression (list) or infix string.

    Walks the MathJSON tree and substitutes every string leaf that is not a
    known operator keyword.  If ``expr`` is a plain infix string it is first
    parsed to MathJSON, then the renaming is applied.

    The returned value is always a MathJSON list, which Pydantic's
    ``parse_infix_to_func`` validator accepts directly without re-parsing.
    """
    if isinstance(expr, str):
        expr = _parser.parse(expr)
    return _rename_in_mathjson(expr, symbol_map)

append_aggregated_elem

append_aggregated_elem(
    found_type: str,
    new_objectives: list,
    new_scal_funcs: list,
    new_extra_funcs: list,
    *,
    name: str,
    symbol: str,
    func: Any,
    description: str | None = None,
    maximize: bool = False,
    is_linear: bool = False,
    is_convex: bool = False,
    is_twice_differentiable: bool = False,
) -> None

Append a new aggregated element to the appropriate list based on found_type.

Appends an Objective if found_type is 'objectives', a ScalarizationFunction if 'scalarization_funcs', and an ExtraFunction for everything else. description and maximize are only used for objectives.

Source code in desdeo/tools/scenarios.py
def append_aggregated_elem(
    found_type: str,
    new_objectives: list,
    new_scal_funcs: list,
    new_extra_funcs: list,
    *,
    name: str,
    symbol: str,
    func: Any,
    description: "str | None" = None,
    maximize: bool = False,
    is_linear: bool = False,
    is_convex: bool = False,
    is_twice_differentiable: bool = False,
) -> None:
    """Append a new aggregated element to the appropriate list based on found_type.

    Appends an Objective if found_type is 'objectives', a ScalarizationFunction if
    'scalarization_funcs', and an ExtraFunction for everything else.
    ``description`` and ``maximize`` are only used for objectives.
    """
    if found_type == "objectives":
        new_objectives.append(
            Objective(
                name=name,
                description=description,
                symbol=symbol,
                func=func,
                maximize=maximize,
                is_linear=is_linear,
                is_convex=is_convex,
                is_twice_differentiable=is_twice_differentiable,
            )
        )
    elif found_type == "scalarization_funcs":
        new_scal_funcs.append(
            ScalarizationFunction(
                name=name,
                symbol=symbol,
                func=func,
                is_linear=is_linear,
                is_convex=is_convex,
                is_twice_differentiable=is_twice_differentiable,
            )
        )
    else:
        new_extra_funcs.append(
            ExtraFunction(
                name=name,
                symbol=symbol,
                func=func,
                is_linear=is_linear,
                is_convex=is_convex,
                is_twice_differentiable=is_twice_differentiable,
            )
        )

build_combined_scenario_problem

build_combined_scenario_problem(
    scenario_model: ScenarioModel,
) -> tuple[Problem, dict[str, dict[str, dict[str, str]]]]

Build a single Problem that encodes all leaf scenarios simultaneously.

Decision variables are duplicated once per leaf scenario unless a variable appears in anticipation_stop for an ancestor node, in which case all leaves under that node share one copy. Every objective, constraint, extra function, and scalarization function is also duplicated per leaf, with all variable and scenario-specific constant references rewritten to their renamed counterparts.

Elements whose renamed func string is identical across all leaves that carry them are kept as a single shared element (no per-leaf prefix).

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the model to expand into a combined Problem.

required

Returns:

Type Description
Problem

A tuple of:

dict[str, dict[str, dict[str, str]]]
  • A single Problem suitable for passing directly to a solver.
tuple[Problem, dict[str, dict[str, dict[str, str]]]]
  • A symbol map {element_type: {original_symbol: {leaf: new_symbol}}}. Element types are "variables", "constants", "objectives", "constraints", "extra_funcs", and "scalarization_funcs". Leaves that do not carry a given element retain the original symbol.

Raises:

Type Description
ValueError

if the model contains no leaf scenarios.

Source code in desdeo/tools/scenarios.py
def build_combined_scenario_problem(
    scenario_model: "ScenarioModel",
) -> tuple[Problem, dict[str, dict[str, dict[str, str]]]]:
    """Build a single Problem that encodes all leaf scenarios simultaneously.

    Decision variables are duplicated once per leaf scenario unless a variable
    appears in anticipation_stop for an ancestor node, in which case all leaves
    under that node share one copy.  Every objective, constraint, extra function,
    and scalarization function is also duplicated per leaf, with all variable and
    scenario-specific constant references rewritten to their renamed counterparts.

    Elements whose renamed func string is identical across all leaves that carry
    them are kept as a single shared element (no per-leaf prefix).

    Args:
        scenario_model: the model to expand into a combined Problem.

    Returns:
        A tuple of:
        - A single Problem suitable for passing directly to a solver.
        - A symbol map ``{element_type: {original_symbol: {leaf: new_symbol}}}``.
            Element types are ``"variables"``, ``"constants"``, ``"objectives"``,
            ``"constraints"``, ``"extra_funcs"``, and ``"scalarization_funcs"``.
            Leaves that do not carry a given element retain the original symbol.

    Raises:
        ValueError: if the model contains no leaf scenarios.
    """
    leaf_scenarios: list[str] = list(scenario_model.leaf_scenarios)

    if not leaf_scenarios:
        raise ValueError("ScenarioModel has no leaf scenarios to combine.")

    parent_map = _build_parent_map(scenario_model.scenario_tree)

    scenario_problems: dict[str, Problem] = {leaf: scenario_model.get_scenario_problem(leaf) for leaf in leaf_scenarios}

    var_maps, combined_variables = _build_variable_maps(scenario_model, leaf_scenarios, parent_map, scenario_problems)
    const_maps, combined_constants = _build_constant_maps(leaf_scenarios, scenario_problems)

    def _name_update(elem, new_func, leaf):
        new_sym = elem.symbol if leaf is None else f"{leaf}_{elem.symbol}"
        update = {"symbol": new_sym, "func": new_func}
        if leaf is not None:
            update["name"] = f"{elem.name} ({leaf})"
        if isinstance(elem, Objective):
            update["ideal"] = None
            update["nadir"] = None
        return update

    def _combine(get_list, extra_maps=None):
        return _combine_elements(
            leaf_scenarios, scenario_problems, var_maps, const_maps, get_list, _name_update, extra_maps
        )

    objectives_list, objectives_map = _combine(lambda p: p.objectives)

    # Build per-leaf rename maps for objective symbols (and their _min versions).
    obj_extra_maps: dict[str, dict[str, str]] = {
        leaf: {
            name: new_sym
            for orig, per_leaf in objectives_map.items()
            for name, new_sym in (
                [(orig, per_leaf[leaf]), (f"{orig}_min", f"{per_leaf[leaf]}_min")] if per_leaf[leaf] != orig else []
            )
        }
        for leaf in leaf_scenarios
    }

    # Extra functions reference only variables/constants (and possibly objectives).
    extra_funcs_list, extra_funcs_map = _combine(lambda p: p.extra_funcs, obj_extra_maps)

    # Build per-leaf rename maps for extra function symbols.
    # Constraints and scalarization functions may reference extra functions by symbol.
    ef_extra_maps: dict[str, dict[str, str]] = {
        leaf: {orig: per_leaf[leaf] for orig, per_leaf in extra_funcs_map.items() if per_leaf[leaf] != orig}
        for leaf in leaf_scenarios
    }

    # Combined extra maps: objective + extra_func renames for constraints and scal funcs.
    combined_extra_maps: dict[str, dict[str, str]] = {
        leaf: {**obj_extra_maps[leaf], **ef_extra_maps[leaf]} for leaf in leaf_scenarios
    }

    constraints_list, constraints_map = _combine(lambda p: p.constraints, combined_extra_maps)
    scalarization_funcs_list, scalarization_funcs_map = _combine(lambda p: p.scalarization_funcs, combined_extra_maps)

    # Variable symbol map: original_sym -> {leaf -> new_sym}
    variables_map: dict[str, dict[str, str]] = {
        sym: {leaf: var_maps[leaf][sym] for leaf in leaf_scenarios}
        for sym in {v.symbol for v in scenario_model.base_problem.variables}
    }

    # Constant symbol map: original_sym -> {leaf -> new_sym}, defaulting to original
    all_const_syms: set[str] = {sym for lm in const_maps.values() for sym in lm}
    constants_map: dict[str, dict[str, str]] = {
        sym: {leaf: const_maps[leaf].get(sym, sym) for leaf in leaf_scenarios} for sym in all_const_syms
    }

    symbol_maps: dict[str, dict[str, dict[str, str]]] = {
        "variables": variables_map,
        "constants": constants_map,
        "objectives": objectives_map,
        "constraints": constraints_map,
        "extra_funcs": extra_funcs_map,
        "scalarization_funcs": scalarization_funcs_map,
    }

    problem = Problem(
        name=f"{scenario_model.base_problem.name} (combined)",
        description=(
            f"Combined scenario problem from {len(leaf_scenarios)} leaf scenarios: " + ", ".join(leaf_scenarios)
        ),
        constants=list(combined_constants.values()) or None,
        variables=list(combined_variables.values()),
        objectives=objectives_list,
        constraints=constraints_list,
        extra_funcs=extra_funcs_list,
        scalarization_funcs=scalarization_funcs_list,
    )

    return problem, symbol_maps

build_scenario_problem

build_scenario_problem(
    scenario_model: ScenarioModel, scenario_name: str
) -> Problem

Build a concrete Problem for a single named scenario.

Applies the scenario's pool overrides and additions to the base problem and returns the resulting Problem instance ready to be passed to a solver.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel containing the base problem and pools.

required
scenario_name str

the key identifying which scenario to construct.

required

Returns:

Type Description
Problem

A Problem instance with the scenario's elements applied.

Raises:

Type Description
ValueError

if scenario_name is not found in the model.

Source code in desdeo/tools/scenarios.py
def build_scenario_problem(scenario_model: "ScenarioModel", scenario_name: str) -> Problem:
    """Build a concrete Problem for a single named scenario.

    Applies the scenario's pool overrides and additions to the base problem
    and returns the resulting Problem instance ready to be passed to a solver.

    Args:
        scenario_model: the ScenarioModel containing the base problem and pools.
        scenario_name: the key identifying which scenario to construct.

    Returns:
        A Problem instance with the scenario's elements applied.

    Raises:
        ValueError: if scenario_name is not found in the model.
    """
    return scenario_model.get_scenario_problem(scenario_name)

build_scenario_symbol_maps

build_scenario_symbol_maps(
    problem: Problem, scenario_model: ScenarioModel
) -> dict[str, dict[str, dict[str, str]]]

Derive element symbol maps from an already-built combined scenario problem.

A lightweight alternative to calling build_combined_scenario_problem when the combined problem is already available. Infers the per-leaf symbol for each base element by checking whether {leaf}_{orig} exists among the combined problem's element symbols.

Covers objectives, extra_funcs, constraints, and scalarization_funcs; variables are excluded because their naming depends on anticipation_stop and cannot be inferred from symbol presence alone.

Parameters:

Name Type Description Default
problem Problem

the combined scenario problem (as returned by build_combined_scenario_problem or after appending aggregation elements).

required
scenario_model ScenarioModel

the scenario model used to build problem.

required

Returns:

Type Description
dict[str, dict[str, dict[str, str]]]

Symbol maps dict with keys "objectives", "extra_funcs", "constraints", and "scalarization_funcs", compatible with the same-named keys from build_combined_scenario_problem.

Source code in desdeo/tools/scenarios.py
def build_scenario_symbol_maps(
    problem: "Problem",
    scenario_model: "ScenarioModel",
) -> "dict[str, dict[str, dict[str, str]]]":
    """Derive element symbol maps from an already-built combined scenario problem.

    A lightweight alternative to calling `build_combined_scenario_problem`
    when the combined problem is already available.  Infers the per-leaf symbol
    for each base element by checking whether ``{leaf}_{orig}`` exists among the
    combined problem's element symbols.

    Covers ``objectives``, ``extra_funcs``, ``constraints``, and
    ``scalarization_funcs``; variables are excluded because their naming depends
    on ``anticipation_stop`` and cannot be inferred from symbol presence alone.

    Args:
        problem: the combined scenario problem (as returned by
            `build_combined_scenario_problem` or after appending
            aggregation elements).
        scenario_model: the scenario model used to build ``problem``.

    Returns:
        Symbol maps dict with keys ``"objectives"``, ``"extra_funcs"``,
            ``"constraints"``, and ``"scalarization_funcs"``, compatible with
            the same-named keys from `build_combined_scenario_problem`.
    """
    leaf_scenarios = list(scenario_model.leaf_scenarios)
    base = scenario_model.base_problem

    def _map(base_elems, combined_elems):
        combined_syms = {e.symbol for e in (combined_elems or [])}
        return {
            elem.symbol: {
                leaf: f"{leaf}_{elem.symbol}" if f"{leaf}_{elem.symbol}" in combined_syms else elem.symbol
                for leaf in leaf_scenarios
            }
            for elem in (base_elems or [])
        }

    return {
        "objectives": _map(base.objectives, problem.objectives),
        "extra_funcs": _map(base.extra_funcs, problem.extra_funcs),
        "constraints": _map(base.constraints, problem.constraints),
        "scalarization_funcs": _map(base.scalarization_funcs, problem.scalarization_funcs),
    }

find_base_elem

find_base_elem(problem: Problem, sym: str)

Return the first matching element from objectives, extra_funcs, scalarization_funcs, or constraints.

Source code in desdeo/tools/scenarios.py
def find_base_elem(problem: "Problem", sym: str):
    """Return the first matching element from objectives, extra_funcs, scalarization_funcs, or constraints."""
    for elems in [problem.objectives, problem.extra_funcs, problem.scalarization_funcs, problem.constraints]:
        elem = next((e for e in (elems or []) if e.symbol == sym), None)
        if elem is not None:
            return elem
    return None

resolve_elem

resolve_elem(
    sym: str,
    symbol_maps: dict[str, dict[str, dict[str, str]]],
    combined: Problem,
    scenario_model: ScenarioModel,
) -> _ElemResolution

Resolve per-symbol metadata needed by aggregation functions.

Looks up the element type and per-leaf symbol map, retrieves the reference element from the combined problem for technical properties, and the original element from the base problem for name and description.

Raises:

Type Description
ValueError

if sym is not found in symbol_maps.

Source code in desdeo/tools/scenarios.py
def resolve_elem(
    sym: str,
    symbol_maps: "dict[str, dict[str, dict[str, str]]]",
    combined: "Problem",
    scenario_model: "ScenarioModel",
) -> _ElemResolution:
    """Resolve per-symbol metadata needed by aggregation functions.

    Looks up the element type and per-leaf symbol map, retrieves the reference
    element from the combined problem for technical properties, and the original
    element from the base problem for name and description.

    Raises:
        ValueError: if sym is not found in symbol_maps.
    """
    found_type = None
    per_leaf = None
    for elem_type, smap in symbol_maps.items():
        if sym in smap:
            found_type = elem_type
            per_leaf = smap[sym]
            break
    if per_leaf is None:
        raise ValueError(f"Symbol '{sym}' not found in the combined problem.")

    elem_list = list(
        {
            "objectives": combined.objectives,
            "scalarization_funcs": combined.scalarization_funcs,
            "extra_funcs": combined.extra_funcs,
            "constraints": combined.constraints,
        }.get(found_type)
        or []
    )
    first_leaf_sym = per_leaf[next(iter(per_leaf))]
    ref_elem = next((e for e in elem_list if e.symbol == first_leaf_sym), None)

    base_elem = find_base_elem(scenario_model.base_problem, sym)
    return _ElemResolution(
        found_type=found_type,
        per_leaf=per_leaf,
        elem_list=elem_list,
        ref_elem=ref_elem,
        is_linear=getattr(ref_elem, "is_linear", False),
        is_convex=getattr(ref_elem, "is_convex", False),
        is_twice_diff=getattr(ref_elem, "is_twice_differentiable", False),
        maximize=getattr(ref_elem, "maximize", False),
        elem_name=base_elem.name
        if base_elem is not None
        else _longest_common_name(_pool_names_for(scenario_model, found_type, sym, per_leaf), sym),
        elem_desc=getattr(base_elem, "description", None) if base_elem is not None else None,
    )

solve_all_scenarios

solve_all_scenarios(
    scenario_model: ScenarioModel,
    solver_callable: callable,
    solver_options: dict | None = None,
) -> dict[str, SolverResults]

Solve every leaf scenario in the model independently.

Leaf scenarios are nodes in the scenario tree with no children.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to solve.

required
solver_callable callable

a callable that accepts a Problem (and optional options dict) and returns a SolverResults instance.

required
solver_options dict | None

optional dict of keyword arguments forwarded to solver_callable.

None

Returns:

Type Description
dict[str, SolverResults]

A dict mapping each leaf scenario name to its SolverResults.

Source code in desdeo/tools/scenarios.py
def solve_all_scenarios(
    scenario_model: "ScenarioModel",
    solver_callable: callable,
    solver_options: dict | None = None,
) -> dict[str, "SolverResults"]:
    """Solve every leaf scenario in the model independently.

    Leaf scenarios are nodes in the scenario tree with no children.

    Args:
        scenario_model: the ScenarioModel to solve.
        solver_callable: a callable that accepts a Problem (and optional options dict)
            and returns a SolverResults instance.
        solver_options: optional dict of keyword arguments forwarded to solver_callable.

    Returns:
        A dict mapping each leaf scenario name to its SolverResults.
    """
    return {
        name: solve_scenario(scenario_model, name, solver_callable, solver_options)
        for name in scenario_model.leaf_scenarios
    }

solve_scenario

solve_scenario(
    scenario_model: ScenarioModel,
    scenario_name: str,
    solver_callable: callable,
    solver_options: dict | None = None,
) -> SolverResults

Solve a single scenario.

Constructs the scenario problem and passes it to the provided solver.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel containing the base problem and pools.

required
scenario_name str

the key identifying which scenario to solve.

required
solver_callable callable

a callable that accepts a Problem (and optional options dict) and returns a SolverResults instance.

required
solver_options dict | None

optional dict of keyword arguments forwarded to solver_callable.

None

Returns:

Type Description
SolverResults

SolverResults from the solver.

Source code in desdeo/tools/scenarios.py
def solve_scenario(
    scenario_model: "ScenarioModel",
    scenario_name: str,
    solver_callable: callable,
    solver_options: dict | None = None,
) -> "SolverResults":
    """Solve a single scenario.

    Constructs the scenario problem and passes it to the provided solver.

    Args:
        scenario_model: the ScenarioModel containing the base problem and pools.
        scenario_name: the key identifying which scenario to solve.
        solver_callable: a callable that accepts a Problem (and optional options dict)
            and returns a SolverResults instance.
        solver_options: optional dict of keyword arguments forwarded to solver_callable.

    Returns:
        SolverResults from the solver.
    """
    problem = build_scenario_problem(scenario_model, scenario_name)
    options = solver_options or {}
    return solver_callable(problem, **options)

Stochastic tools

Tools for solving scenario-based optimization problems.

add_conditional_value_at_risk

add_conditional_value_at_risk(
    scenario_model: ScenarioModel,
    symbols: list[str],
    alpha: float,
    var_prefix: str = "VAR_",
    cvar_prefix: str = "CVAR_",
    combined: Problem | None = None,
    symbol_maps: dict[str, dict[str, dict[str, str]]]
    | None = None,
) -> tuple[Problem, dict[str, str]]

Add CVaR aggregations using the Rockafellar-Uryasev LP formulation.

For each symbol in symbols, introduces:

  • {var_prefix}{sym}: VaR threshold variable η (shared across scenarios).
  • {leaf}_{var_prefix}{sym}: per-leaf auxiliary z_s ≥ 0 such that z_s >= sym_s - eta.
  • {leaf}_{var_prefix}{sym}_con: per-leaf LTE constraint sym_s - eta - z_s <= 0.
  • {cvar_prefix}{sym}: CVaR = eta + 1/(1-alpha) * sum_s p_s * z_s.

The CVaR element preserves the type of the original element (objective stays objective, scalarization function stays scalarization function, anything else becomes an extra function).

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel used to build or match combined.

required
symbols list[str]

original symbols whose CVaR should be added.

required
alpha float

confidence level, 0 < alpha < 1 (e.g., 0.95 for 95% CVaR).

required
var_prefix str

prefix for the VaR threshold and auxiliary variables. Defaults to 'VAR_'.

'VAR_'
cvar_prefix str

prefix for the resulting CVaR element. Defaults to 'CVAR_'.

'CVAR_'
combined Problem | None

pre-built combined Problem. If provided together with symbol_maps, build_combined_scenario_problem is not called.

None
symbol_maps dict[str, dict[str, dict[str, str]]] | None

pre-built symbol maps; required together with combined.

None

Returns:

Type Description
tuple[Problem, dict[str, str]]

A tuple of the updated combined Problem and a dict mapping each original symbol to its CVaR symbol.

Raises:

Type Description
ValueError

if a requested symbol is not found in the combined problem.

Source code in desdeo/tools/stochastic.py
def add_conditional_value_at_risk(
    scenario_model: "ScenarioModel",
    symbols: list[str],
    alpha: float,
    var_prefix: str = "VAR_",
    cvar_prefix: str = "CVAR_",
    combined: "Problem | None" = None,
    symbol_maps: "dict[str, dict[str, dict[str, str]]] | None" = None,
) -> "tuple[Problem, dict[str, str]]":
    """Add CVaR aggregations using the Rockafellar-Uryasev LP formulation.

    For each symbol in ``symbols``, introduces:

    - ``{var_prefix}{sym}``: VaR threshold variable η (shared across scenarios).
    - ``{leaf}_{var_prefix}{sym}``: per-leaf auxiliary z_s ≥ 0 such that
      z_s >= sym_s - eta.
    - ``{leaf}_{var_prefix}{sym}_con``: per-leaf LTE constraint sym_s - eta - z_s <= 0.
    - ``{cvar_prefix}{sym}``: CVaR = eta + 1/(1-alpha) * sum_s p_s * z_s.

    The CVaR element preserves the type of the original element (objective stays
    objective, scalarization function stays scalarization function, anything else
    becomes an extra function).

    Args:
        scenario_model: the ScenarioModel used to build or match ``combined``.
        symbols: original symbols whose CVaR should be added.
        alpha: confidence level, 0 < alpha < 1 (e.g., 0.95 for 95% CVaR).
        var_prefix: prefix for the VaR threshold and auxiliary variables.
            Defaults to ``'VAR_'``.
        cvar_prefix: prefix for the resulting CVaR element. Defaults to ``'CVAR_'``.
        combined: pre-built combined Problem. If provided together with
            ``symbol_maps``, ``build_combined_scenario_problem`` is not called.
        symbol_maps: pre-built symbol maps; required together with ``combined``.

    Returns:
        A tuple of the updated combined Problem and a dict mapping each original
            symbol to its CVaR symbol.

    Raises:
        ValueError: if a requested symbol is not found in the combined problem.
    """
    if combined is None or symbol_maps is None:
        combined, symbol_maps = build_combined_scenario_problem(scenario_model)
    weights = scenario_model.leaf_scenarios
    scale = 1.0 / (1.0 - alpha)

    new_variables = list(combined.variables)
    new_objectives = list(combined.objectives or [])
    new_scal_funcs = list(combined.scalarization_funcs or [])
    new_extra_funcs = list(combined.extra_funcs or [])
    new_constraints = list(combined.constraints or [])
    added_symbols: dict[str, str] = {}

    for sym in symbols:
        info = resolve_elem(sym, symbol_maps, combined, scenario_model)
        var_sym = f"{var_prefix}{sym}"
        cvar_sym = f"{cvar_prefix}{sym}"
        added_symbols[sym] = cvar_sym

        # VaR threshold η — shared across all scenarios.
        new_variables.append(
            Variable(
                name=f"VaR threshold for {info.elem_name}",
                symbol=var_sym,
                variable_type=VariableTypeEnum.real,
                lowerbound=None,
                upperbound=None,
                initial_value=0.0,
            )
        )

        # Per-leaf auxiliary variables z_s and their constraints.
        leaf_z_syms: dict[str, str] = {}
        for leaf, leaf_sym in info.per_leaf.items():
            z_sym = f"{leaf}_{var_sym}"
            leaf_z_syms[leaf] = z_sym

            new_variables.append(
                Variable(
                    name=f"CVaR auxiliary for {info.elem_name} in {leaf}",
                    symbol=z_sym,
                    variable_type=VariableTypeEnum.real,
                    lowerbound=0.0,
                    upperbound=None,
                    initial_value=0.0,
                )
            )

            # z_s >= sym_s - eta  ->  sym_s - eta - z_s <= 0
            # Use the func expression of the per-leaf element rather than its symbol.
            leaf_func = next((e.func for e in info.elem_list if e.symbol == leaf_sym), leaf_sym)
            new_constraints.append(
                Constraint(
                    name=f"CVaR constraint for {info.elem_name} in {leaf}",
                    symbol=f"{z_sym}_con",
                    func=["Add", leaf_func, ["Negate", var_sym], ["Negate", z_sym]],
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=info.is_linear,
                    is_convex=info.is_convex,
                    is_twice_differentiable=info.is_twice_diff,
                )
            )

        # CVaR = η + scale · Σ_s p_s · z_s
        weighted_z = [["Multiply", weights[leaf], leaf_z_syms[leaf]] for leaf in weights]
        sum_z = weighted_z[0] if len(weighted_z) == 1 else ["Add", *weighted_z]

        append_aggregated_elem(
            info.found_type,
            new_objectives,
            new_scal_funcs,
            new_extra_funcs,
            name=f"CVaR of {info.elem_name}",
            description=f"CVaR of {info.elem_desc}" if info.elem_desc else f"CVaR of {info.elem_name}",
            symbol=cvar_sym,
            func=["Add", var_sym, ["Multiply", scale, sum_z]],
            maximize=info.maximize,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )

    return combined.model_copy(
        update={
            "variables": new_variables,
            "objectives": new_objectives or None,
            "scalarization_funcs": new_scal_funcs or None,
            "extra_funcs": new_extra_funcs or None,
            "constraints": new_constraints or None,
        }
    ), added_symbols

add_expected_asf

add_expected_asf(
    scenario_model: ScenarioModel,
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-06,
    delta: float = 1e-06,
) -> tuple[Problem, str]

Build a combined scenario problem with an expected value of ASF scalarization.

This is here mostly as a convenience for testing, but it can also be used as a template for users who want to build their own scenario problems expected-value scalarizations.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to expand and scalarize.

required
symbol str

symbol for the scalarization function added to the problem.

required
reference_point dict[str, float]

maps objective symbols to reference point values.

required
ideal dict[str, float] | None

maps objective symbols to ideal values. If None, the problem's ideal is used.

None
nadir dict[str, float] | None

maps objective symbols to nadir values. If None, the problem's nadir is used.

None
rho float

augmentation term weight for the ASF.

1e-06
delta float

small perturbation for the differentiable ASF variant.

1e-06

Returns:

Type Description
tuple[Problem, str]

A tuple of the combined Problem and the scalarization function symbol.

Source code in desdeo/tools/stochastic.py
def add_expected_asf(
    scenario_model: "ScenarioModel",
    symbol: str,
    reference_point: dict[str, float],
    ideal: dict[str, float] | None = None,
    nadir: dict[str, float] | None = None,
    rho: float = 1e-6,
    delta: float = 1e-6,
) -> "tuple[Problem, str]":
    """Build a combined scenario problem with an expected value of ASF scalarization.

    This is here mostly as a convenience for testing, but it can also be used as a template
    for users who want to build their own scenario problems expected-value scalarizations.

    Args:
        scenario_model: the ScenarioModel to expand and scalarize.
        symbol: symbol for the scalarization function added to the problem.
        reference_point: maps objective symbols to reference point values.
        ideal: maps objective symbols to ideal values. If None, the problem's
            ideal is used.
        nadir: maps objective symbols to nadir values. If None, the problem's
            nadir is used.
        rho: augmentation term weight for the ASF.
        delta: small perturbation for the differentiable ASF variant.

    Returns:
        A tuple of the combined Problem and the scalarization function symbol.
    """
    base_problem = scenario_model.base_problem
    if base_problem.is_twice_differentiable or base_problem.is_linear:
        scal_problem, scal = add_asf_diff(
            problem=base_problem,
            symbol=symbol,
            reference_point=reference_point,
            ideal=ideal,
            nadir=nadir,
            rho=rho,
            delta=delta,
        )
    else:
        scal_problem, scal = add_asf_nondiff(
            problem=base_problem,
            symbol=symbol,
            reference_point=reference_point,
            ideal=ideal,
            nadir=nadir,
            rho=rho,
            delta=delta,
        )

    modified_model = scenario_model.with_base_problem(problem=scal_problem, validate=True)
    combined, symbol_maps = build_combined_scenario_problem(modified_model)
    combined, added = add_expected_value(modified_model, [scal], combined=combined, symbol_maps=symbol_maps)

    return combined, added[scal]

add_expected_value

add_expected_value(
    scenario_model: ScenarioModel,
    symbols: list[str],
    prefix: str = "E_",
    combined: Problem | None = None,
    symbol_maps: dict[str, dict[str, dict[str, str]]]
    | None = None,
) -> tuple[Problem, dict[str, str]]

Add expected-value aggregations for selected symbols to the combined scenario problem.

For each symbol the expected value is a probability-weighted sum of the per-leaf copies of that symbol in the combined problem. The new element type matches the original: objectives stay objectives, scalarization functions stay scalarization functions, and everything else (extra functions, constraints, …) becomes an extra function.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to expand.

required
symbols list[str]

original symbols whose expected values should be added.

required
prefix str

prefix prepended to each original symbol to form the new symbol. Defaults to 'E_'.

'E_'
combined Problem | None

pre-built combined Problem. If provided together with symbol_maps, build_combined_scenario_problem is not called. Must match scenario_model.

None
symbol_maps dict[str, dict[str, dict[str, str]]] | None

pre-built symbol maps from build_combined_scenario_problem. Must be provided together with combined; ignored otherwise.

None

Returns:

Type Description
Problem

A tuple of the combined Problem with the expected-value elements appended,

dict[str, str]

and a dict mapping each original symbol to its new expected-value symbol.

Raises:

Type Description
ValueError

if a requested symbol is not found in the combined problem.

Source code in desdeo/tools/stochastic.py
def add_expected_value(
    scenario_model: "ScenarioModel",
    symbols: list[str],
    prefix: str = "E_",
    combined: "Problem | None" = None,
    symbol_maps: "dict[str, dict[str, dict[str, str]]] | None" = None,
) -> "tuple[Problem, dict[str, str]]":
    """Add expected-value aggregations for selected symbols to the combined scenario problem.

    For each symbol the expected value is a probability-weighted sum of the per-leaf
    copies of that symbol in the combined problem.  The new element type matches the
    original: objectives stay objectives, scalarization functions stay scalarization
    functions, and everything else (extra functions, constraints, …) becomes an extra
    function.

    Args:
        scenario_model: the ScenarioModel to expand.
        symbols: original symbols whose expected values should be added.
        prefix: prefix prepended to each original symbol to form the new symbol.
            Defaults to ``'E_'``.
        combined: pre-built combined Problem. If provided together with
            ``symbol_maps``, ``build_combined_scenario_problem`` is not called.
            Must match ``scenario_model``.
        symbol_maps: pre-built symbol maps from ``build_combined_scenario_problem``.
            Must be provided together with ``combined``; ignored otherwise.

    Returns:
        A tuple of the combined Problem with the expected-value elements appended,
        and a dict mapping each original symbol to its new expected-value symbol.

    Raises:
        ValueError: if a requested symbol is not found in the combined problem.
    """
    if combined is None or symbol_maps is None:
        combined, symbol_maps = build_combined_scenario_problem(scenario_model)
    weights = scenario_model.leaf_scenarios

    new_objectives = list(combined.objectives or [])
    new_scal_funcs = list(combined.scalarization_funcs or [])
    new_extra_funcs = list(combined.extra_funcs or [])
    added_symbols: dict[str, str] = {}

    # Variables and constants are direct values; everything else is a computed
    # expression whose func must be inlined rather than referenced by symbol.
    _func_bearing = {"objectives", "extra_funcs", "scalarization_funcs", "constraints"}

    for sym in symbols:
        info = resolve_elem(sym, symbol_maps, combined, scenario_model)
        expected_sym = f"{prefix}{sym}"
        added_symbols[sym] = expected_sym

        if info.found_type in _func_bearing:
            terms = [
                [
                    "Multiply",
                    weights[leaf],
                    next((e.func for e in info.elem_list if e.symbol == info.per_leaf[leaf]), info.per_leaf[leaf]),
                ]
                for leaf in weights
            ]
        else:
            terms = [["Multiply", weights[leaf], info.per_leaf[leaf]] for leaf in weights]
        expected_expr = terms[0] if len(terms) == 1 else ["Add", *terms]

        append_aggregated_elem(
            info.found_type,
            new_objectives,
            new_scal_funcs,
            new_extra_funcs,
            name=f"Expected {info.elem_name}",
            description=f"Expected value of {info.elem_desc}"
            if info.elem_desc
            else f"Expected value of {info.elem_name}",
            symbol=expected_sym,
            func=expected_expr,
            maximize=info.maximize,
            is_linear=info.is_linear,
            is_convex=info.is_convex,
            is_twice_differentiable=info.is_twice_diff,
        )

    return combined.model_copy(
        update={
            "objectives": new_objectives or None,
            "scalarization_funcs": new_scal_funcs or None,
            "extra_funcs": new_extra_funcs or None,
        }
    ), added_symbols

Robust tools

Tools for worst-case robust optimization over scenario-based problems.

add_single_objective_worst_case_regret

add_single_objective_worst_case_regret(
    scenario_model: ScenarioModel,
    symbols: list[str],
    ideals: dict[str, dict[str, float]],
    prefix: str = "regret_wc_",
    combined: Problem | None = None,
    symbol_maps: dict[str, dict[str, dict[str, str]]]
    | None = None,
) -> tuple[Problem, dict[str, str]]

Add worst-case regret aggregations for selected symbols to the combined scenario problem.

For each symbol, the per-scenario regret is the difference between the objective value in that scenario and its ideal (best achievable) value in that scenario:

  • Minimise objectives: regret_s = f_s - ideal_s (ideal is the minimum).
  • Maximise objectives: regret_s = ideal_s - f_s (ideal is the maximum).

The worst-case regret across all scenarios is then expressed via the standard epigraph reformulation: minimise t subject to regret_s - t <= 0 for every leaf scenario s. The resulting element is always a minimise objective (or extra function / scalarization function matching the original type) regardless of the original optimisation direction.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to expand.

required
symbols list[str]

original symbols whose worst-case regret should be added.

required
ideals dict[str, dict[str, float]]

mapping {symbol -> {leaf -> ideal_value}} giving the ideal (best achievable) value of each symbol in each leaf scenario. Every leaf returned by scenario_model.leaf_scenarios must have an entry for each symbol.

required
prefix str

prefix prepended to each original symbol to form the new symbol. Defaults to 'regret_wc_'.

'regret_wc_'
combined Problem | None

pre-built combined Problem. If provided together with symbol_maps, build_combined_scenario_problem is not called. Must match scenario_model.

None
symbol_maps dict[str, dict[str, dict[str, str]]] | None

pre-built symbol maps from build_combined_scenario_problem. Must be provided together with combined; ignored otherwise.

None

Returns:

Type Description
Problem

A tuple of the combined Problem with worst-case regret elements appended,

dict[str, str]

and a dict mapping each original symbol to its regret symbol.

Raises:

Type Description
ValueError

if a requested symbol is not found in the combined problem.

ValueError

if ideals is missing a leaf entry for any requested symbol.

Source code in desdeo/tools/robust.py
def add_single_objective_worst_case_regret(
    scenario_model: "ScenarioModel",
    symbols: list[str],
    ideals: "dict[str, dict[str, float]]",
    prefix: str = "regret_wc_",
    combined: "Problem | None" = None,
    symbol_maps: "dict[str, dict[str, dict[str, str]]] | None" = None,
) -> "tuple[Problem, dict[str, str]]":
    """Add worst-case regret aggregations for selected symbols to the combined scenario problem.

    For each symbol, the per-scenario regret is the difference between the objective
    value in that scenario and its ideal (best achievable) value in that scenario:

    * **Minimise** objectives: ``regret_s = f_s - ideal_s``  (ideal is the minimum).
    * **Maximise** objectives: ``regret_s = ideal_s - f_s``  (ideal is the maximum).

    The worst-case regret across all scenarios is then expressed via the standard
    epigraph reformulation: minimise ``t`` subject to ``regret_s - t <= 0`` for
    every leaf scenario ``s``.  The resulting element is always a **minimise**
    objective (or extra function / scalarization function matching the original type)
    regardless of the original optimisation direction.

    Args:
        scenario_model: the ScenarioModel to expand.
        symbols: original symbols whose worst-case regret should be added.
        ideals: mapping ``{symbol -> {leaf -> ideal_value}}`` giving the ideal
            (best achievable) value of each symbol in each leaf scenario.  Every
            leaf returned by ``scenario_model.leaf_scenarios`` must have an entry
            for each symbol.
        prefix: prefix prepended to each original symbol to form the new symbol.
            Defaults to ``'regret_wc_'``.
        combined: pre-built combined Problem.  If provided together with
            ``symbol_maps``, ``build_combined_scenario_problem`` is not called.
            Must match ``scenario_model``.
        symbol_maps: pre-built symbol maps from ``build_combined_scenario_problem``.
            Must be provided together with ``combined``; ignored otherwise.

    Returns:
        A tuple of the combined Problem with worst-case regret elements appended,
        and a dict mapping each original symbol to its regret symbol.

    Raises:
        ValueError: if a requested symbol is not found in the combined problem.
        ValueError: if ``ideals`` is missing a leaf entry for any requested symbol.
    """
    if combined is None or symbol_maps is None:
        combined, symbol_maps = build_combined_scenario_problem(scenario_model)

    new_variables = list(combined.variables)
    new_objectives = list(combined.objectives or [])
    new_scal_funcs = list(combined.scalarization_funcs or [])
    new_extra_funcs = list(combined.extra_funcs or [])
    new_constraints = list(combined.constraints or [])
    added_symbols: dict[str, str] = {}

    for sym in symbols:
        info = resolve_elem(sym, symbol_maps, combined, scenario_model)
        regret_sym = f"{prefix}{sym}"
        t_sym = f"_t_{regret_sym}"
        added_symbols[sym] = regret_sym

        sym_ideals = ideals.get(sym, {})
        missing_leaves = set(info.per_leaf) - set(sym_ideals)
        if missing_leaves:
            raise ValueError(f"ideals is missing entries for symbol '{sym}' in leaves: {missing_leaves}")

        new_variables.append(
            Variable(
                name=f"Worst-case regret epigraph variable for {info.elem_name}",
                symbol=t_sym,
                variable_type=VariableTypeEnum.real,
                lowerbound=None,
                upperbound=None,
                initial_value=0.0,
            )
        )

        for leaf, leaf_sym in info.per_leaf.items():
            ideal_val = sym_ideals[leaf]
            # Minimise: regret_s = f_s - ideal_s  ->  f_s - ideal_s - t <= 0
            # Maximise: regret_s = ideal_s - f_s  ->  ideal_s - f_s - t <= 0
            if info.maximize:
                con_func = ["Add", ideal_val, ["Negate", leaf_sym], ["Negate", t_sym]]
            else:
                con_func = ["Add", leaf_sym, -ideal_val, ["Negate", t_sym]]
            new_constraints.append(
                Constraint(
                    name=f"Worst-case regret bound for {info.elem_name} in {leaf}",
                    symbol=f"{leaf}_{regret_sym}_con",
                    func=con_func,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=info.is_linear,
                    is_convex=info.is_convex,
                    is_twice_differentiable=info.is_twice_diff,
                )
            )

        append_aggregated_elem(
            info.found_type,
            new_objectives,
            new_scal_funcs,
            new_extra_funcs,
            name=f"Worst-case regret {info.elem_name}",
            description=f"Worst-case regret of {info.elem_desc}"
            if info.elem_desc
            else f"Worst-case regret of {info.elem_name}",
            symbol=regret_sym,
            func=t_sym,
            maximize=False,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )

    return combined.model_copy(
        update={
            "variables": new_variables,
            "objectives": new_objectives or None,
            "scalarization_funcs": new_scal_funcs or None,
            "extra_funcs": new_extra_funcs or None,
            "constraints": new_constraints or None,
        }
    ), added_symbols

add_weighted_scenarios

add_weighted_scenarios(
    scenario_model: ScenarioModel,
    symbols: list[str],
    weights: dict[str, float],
    prefix: str = "weighted_",
    combined: Problem | None = None,
    symbol_maps: dict[str, dict[str, dict[str, str]]]
    | None = None,
) -> tuple[Problem, dict[str, str]]

Add user-weighted aggregations for selected symbols to the combined scenario problem.

Identical to add_expected_value in desdeo.tools.stochastic, except that the per-leaf weights come from the caller rather than from the scenario probabilities in scenario_model. This lets you express, e.g., pessimistic weightings that put more mass on bad scenarios than their true probabilities warrant.

The new element type matches the original: objectives stay objectives, scalarization functions stay scalarization functions, and everything else becomes an extra function.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to expand.

required
symbols list[str]

original symbols whose weighted sums should be added.

required
weights dict[str, float]

mapping from leaf scenario name to its weight. Must contain a key for every leaf in scenario_model.leaf_scenarios.

required
prefix str

prefix prepended to each original symbol to form the new symbol. Defaults to 'weighted_'.

'weighted_'
combined Problem | None

pre-built combined Problem. If provided together with symbol_maps, build_combined_scenario_problem is not called. Must match scenario_model.

None
symbol_maps dict[str, dict[str, dict[str, str]]] | None

pre-built symbol maps from build_combined_scenario_problem. Must be provided together with combined; ignored otherwise.

None

Returns:

Type Description
Problem

A tuple of the combined Problem with the weighted elements appended, and a dict

dict[str, str]

mapping each original symbol to its new weighted symbol.

Raises:

Type Description
ValueError

if a requested symbol is not found in the combined problem.

ValueError

if weights is missing a key for any leaf scenario.

Source code in desdeo/tools/robust.py
def add_weighted_scenarios(
    scenario_model: "ScenarioModel",
    symbols: list[str],
    weights: dict[str, float],
    prefix: str = "weighted_",
    combined: "Problem | None" = None,
    symbol_maps: "dict[str, dict[str, dict[str, str]]] | None" = None,
) -> "tuple[Problem, dict[str, str]]":
    """Add user-weighted aggregations for selected symbols to the combined scenario problem.

    Identical to ``add_expected_value`` in ``desdeo.tools.stochastic``, except that the
    per-leaf weights come from the caller rather than from the scenario probabilities in
    ``scenario_model``.  This lets you express, e.g., pessimistic weightings that put
    more mass on bad scenarios than their true probabilities warrant.

    The new element type matches the original: objectives stay objectives, scalarization
    functions stay scalarization functions, and everything else becomes an extra function.

    Args:
        scenario_model: the ScenarioModel to expand.
        symbols: original symbols whose weighted sums should be added.
        weights: mapping from leaf scenario name to its weight.  Must contain a key
            for every leaf in ``scenario_model.leaf_scenarios``.
        prefix: prefix prepended to each original symbol to form the new symbol.
            Defaults to ``'weighted_'``.
        combined: pre-built combined Problem. If provided together with
            ``symbol_maps``, ``build_combined_scenario_problem`` is not called.
            Must match ``scenario_model``.
        symbol_maps: pre-built symbol maps from ``build_combined_scenario_problem``.
            Must be provided together with ``combined``; ignored otherwise.

    Returns:
        A tuple of the combined Problem with the weighted elements appended, and a dict
        mapping each original symbol to its new weighted symbol.

    Raises:
        ValueError: if a requested symbol is not found in the combined problem.
        ValueError: if ``weights`` is missing a key for any leaf scenario.
    """
    if combined is None or symbol_maps is None:
        combined, symbol_maps = build_combined_scenario_problem(scenario_model)
    leaf_scenarios = scenario_model.leaf_scenarios

    missing = set(leaf_scenarios) - set(weights)
    if missing:
        raise ValueError(f"weights is missing keys for leaf scenarios: {missing}")

    new_objectives = list(combined.objectives or [])
    new_scal_funcs = list(combined.scalarization_funcs or [])
    new_extra_funcs = list(combined.extra_funcs or [])
    added_symbols: dict[str, str] = {}

    for sym in symbols:
        info = resolve_elem(sym, symbol_maps, combined, scenario_model)
        terms = [["Multiply", weights[leaf], info.per_leaf[leaf]] for leaf in leaf_scenarios]
        weighted_sym = f"{prefix}{sym}"
        added_symbols[sym] = weighted_sym

        append_aggregated_elem(
            info.found_type,
            new_objectives,
            new_scal_funcs,
            new_extra_funcs,
            name=f"Weighted {info.elem_name}",
            description=f"Weighted scenario value of {info.elem_desc}"
            if info.elem_desc
            else f"Weighted scenario value of {info.elem_name}",
            symbol=weighted_sym,
            func=terms[0] if len(terms) == 1 else ["Add", *terms],
            maximize=info.maximize,
            is_linear=info.is_linear,
            is_convex=info.is_convex,
            is_twice_differentiable=info.is_twice_diff,
        )

    return combined.model_copy(
        update={
            "objectives": new_objectives or None,
            "scalarization_funcs": new_scal_funcs or None,
            "extra_funcs": new_extra_funcs or None,
        }
    ), added_symbols

add_worst_case_robust

add_worst_case_robust(
    scenario_model: ScenarioModel,
    symbols: list[str],
    prefix: str = "robust_",
    combined: Problem | None = None,
    symbol_maps: dict[str, dict[str, dict[str, str]]]
    | None = None,
) -> tuple[Problem, dict[str, str]]

Add worst-case robust aggregations for selected symbols to the combined scenario problem.

Uses the standard epigraph reformulation with an auxiliary variable t and per-leaf bound constraints, avoiding non-smooth Max/Min expressions that most solvers cannot handle.

The worst-case direction matches the original optimisation direction:

  • Minimise objectives / scalarization functions / extra functions — worst case is the largest value across scenarios. Adds constraints f_s - t <= 0 and exposes t as a new minimise element (equivalent to min max_s f_s).
  • Maximise objectives — worst case is the smallest value across scenarios. Adds constraints t - f_s <= 0 and exposes t as a new maximise element (equivalent to max min_s f_s).

The new element type matches the original: objectives stay objectives, scalarization functions stay scalarization functions, and everything else becomes an extra function. The epigraph variable is named _t_{prefix}{sym} and the returned element is {prefix}{sym}.

Parameters:

Name Type Description Default
scenario_model ScenarioModel

the ScenarioModel to expand.

required
symbols list[str]

original symbols whose worst-case values should be added.

required
prefix str

prefix prepended to each original symbol to form the new symbol. Defaults to 'robust_'.

'robust_'
combined Problem | None

pre-built combined Problem. If provided together with symbol_maps, build_combined_scenario_problem is not called. Must match scenario_model.

None
symbol_maps dict[str, dict[str, dict[str, str]]] | None

pre-built symbol maps from build_combined_scenario_problem. Must be provided together with combined; ignored otherwise.

None

Returns:

Type Description
Problem

A tuple of the combined Problem with robust elements appended, and a dict

dict[str, str]

mapping each original symbol to its robust symbol.

Raises:

Type Description
ValueError

if a requested symbol is not found in the combined problem.

Source code in desdeo/tools/robust.py
def add_worst_case_robust(
    scenario_model: "ScenarioModel",
    symbols: list[str],
    prefix: str = "robust_",
    combined: "Problem | None" = None,
    symbol_maps: "dict[str, dict[str, dict[str, str]]] | None" = None,
) -> "tuple[Problem, dict[str, str]]":
    """Add worst-case robust aggregations for selected symbols to the combined scenario problem.

    Uses the standard epigraph reformulation with an auxiliary variable ``t`` and
    per-leaf bound constraints, avoiding non-smooth ``Max``/``Min`` expressions that
    most solvers cannot handle.

    The worst-case direction matches the original optimisation direction:

    * **Minimise** objectives / scalarization functions / extra functions — worst case
      is the largest value across scenarios.  Adds constraints ``f_s - t <= 0`` and
      exposes ``t`` as a new **minimise** element (equivalent to ``min max_s f_s``).
    * **Maximise** objectives — worst case is the smallest value across scenarios.
      Adds constraints ``t - f_s <= 0`` and exposes ``t`` as a new **maximise** element
      (equivalent to ``max min_s f_s``).

    The new element type matches the original: objectives stay objectives, scalarization
    functions stay scalarization functions, and everything else becomes an extra function.
    The epigraph variable is named ``_t_{prefix}{sym}`` and the returned element is
    ``{prefix}{sym}``.

    Args:
        scenario_model: the ScenarioModel to expand.
        symbols: original symbols whose worst-case values should be added.
        prefix: prefix prepended to each original symbol to form the new symbol.
            Defaults to ``'robust_'``.
        combined: pre-built combined Problem. If provided together with
            ``symbol_maps``, ``build_combined_scenario_problem`` is not called.
            Must match ``scenario_model``.
        symbol_maps: pre-built symbol maps from ``build_combined_scenario_problem``.
            Must be provided together with ``combined``; ignored otherwise.

    Returns:
        A tuple of the combined Problem with robust elements appended, and a dict
        mapping each original symbol to its robust symbol.

    Raises:
        ValueError: if a requested symbol is not found in the combined problem.
    """
    if combined is None or symbol_maps is None:
        combined, symbol_maps = build_combined_scenario_problem(scenario_model)

    new_variables = list(combined.variables)
    new_objectives = list(combined.objectives or [])
    new_scal_funcs = list(combined.scalarization_funcs or [])
    new_extra_funcs = list(combined.extra_funcs or [])
    new_constraints = list(combined.constraints or [])
    added_symbols: dict[str, str] = {}

    for sym in symbols:
        info = resolve_elem(sym, symbol_maps, combined, scenario_model)
        robust_sym = f"{prefix}{sym}"
        t_sym = f"_t_{robust_sym}"
        added_symbols[sym] = robust_sym

        is_maximize = info.found_type == "objectives" and info.maximize

        # Epigraph variable t.
        # Minimise objective: t >= f_s for all s  ->  t = max_s f_s  ->  minimise t.
        # Maximise objective: t <= f_s for all s  ->  t = min_s f_s  ->  maximise t.
        new_variables.append(
            Variable(
                name=f"Worst-case robust epigraph variable for {info.elem_name}",
                symbol=t_sym,
                variable_type=VariableTypeEnum.real,
                lowerbound=None,
                upperbound=None,
                initial_value=0.0,
            )
        )

        for leaf, leaf_sym in info.per_leaf.items():
            # Maximise: t - f_s <= 0  ->  t <= f_s.  Minimise: f_s - t <= 0  ->  f_s <= t.
            con_func = ["Add", t_sym, ["Negate", leaf_sym]] if is_maximize else ["Add", leaf_sym, ["Negate", t_sym]]
            new_constraints.append(
                Constraint(
                    name=f"Worst-case robust bound for {info.elem_name} in {leaf}",
                    symbol=f"{leaf}_{robust_sym}_con",
                    func=con_func,
                    cons_type=ConstraintTypeEnum.LTE,
                    is_linear=info.is_linear,
                    is_convex=info.is_convex,
                    is_twice_differentiable=info.is_twice_diff,
                )
            )

        append_aggregated_elem(
            info.found_type,
            new_objectives,
            new_scal_funcs,
            new_extra_funcs,
            name=f"Worst-case {info.elem_name}",
            description=f"Worst-case robust value of {info.elem_desc}"
            if info.elem_desc
            else f"Worst-case robust value of {info.elem_name}",
            symbol=robust_sym,
            func=t_sym,
            maximize=is_maximize,
            is_linear=True,
            is_convex=True,
            is_twice_differentiable=True,
        )

    return combined.model_copy(
        update={
            "variables": new_variables,
            "objectives": new_objectives or None,
            "scalarization_funcs": new_scal_funcs or None,
            "extra_funcs": new_extra_funcs or None,
            "constraints": new_constraints or None,
        }
    ), added_symbols

Unary indicators

This module implements unary indicators that can be used to evaluate the quality of a single solution set.

It assumes that the solution set has been normalized just that some ideal point (not necessarily the ideal point of the set) is the origin and some nadir point (not necessarily the nadir point of the set) is (1, 1, ..., 1). The normalized solution set is assumed to be inside the bounding box [0, 1]^k where k is the number of objectives. If these conditions are not met, the results of the indicators will not be meaningful.

Additionally, the set may be assumed to only contain mutually non-dominated solutions, depending on the indicator.

For now, we rely on pymoo for the implementation of some of the indicators.

Find more information about the indicators in: Audet, Charles, et al. "Performance indicators in multiobjective optimization." European journal of operational research 292.2 (2021): 397-422.

DistanceIndicators

Bases: BaseModel

A container for closely related distance based indicators.

Source code in desdeo/tools/indicators_unary.py
class DistanceIndicators(BaseModel):
    """A container for closely related distance based indicators."""

    igd: float = Field(description="The inverted generational distance indicator value.")
    "The inverted generational distance indicator value."
    igd_p: float = Field(
        description=(
            "The inverted generational distance indicator, where instead of taking arithmetic "
            "mean of the distances, we take the geometric mean."
        )
    )
    "The inverted generational distance indicator, where instead of taking arithmetic mean of the distances,"
    " we take the geometric mean."
    gd: float = Field(description="The generational distance indicator value.")
    "The generational distance indicator value."
    gd_p: float = Field(
        description=(
            "The generational distance indicator, where instead of taking arithmetic mean of the "
            "distances, we take the geometric mean."
        )
    )
    "The generational distance indicator, where instead of taking arithmetic mean of the distances,"
    " we take the geometric mean."
    ahd: float = Field(description="The average Hausdorff distance indicator value.")
    "The average Hausdorff distance indicator value."

ahd class-attribute instance-attribute

ahd: float = Field(
    description="The average Hausdorff distance indicator value."
)

The average Hausdorff distance indicator value.

gd class-attribute instance-attribute

gd: float = Field(
    description="The generational distance indicator value."
)

The generational distance indicator value.

gd_p class-attribute instance-attribute

gd_p: float = Field(
    description="The generational distance indicator, where instead of taking arithmetic mean of the distances, we take the geometric mean."
)

The generational distance indicator, where instead of taking arithmetic mean of the distances,

igd class-attribute instance-attribute

igd: float = Field(
    description="The inverted generational distance indicator value."
)

The inverted generational distance indicator value.

igd_p class-attribute instance-attribute

igd_p: float = Field(
    description="The inverted generational distance indicator, where instead of taking arithmetic mean of the distances, we take the geometric mean."
)

The inverted generational distance indicator, where instead of taking arithmetic mean of the distances,

IGDPlusIndicators

Bases: BaseModel

A container for the IGD+ distance-based indicator.

Source code in desdeo/tools/indicators_unary.py
class IGDPlusIndicators(BaseModel):
    """A container for the IGD+ distance-based indicator."""

    igd_plus: float = Field(description="The modified inverted generational distance (IGD+) indicator value.")

R2Indicator

Bases: BaseModel

Container for the R2 indicator value of a solution set.

Source code in desdeo/tools/indicators_unary.py
class R2Indicator(BaseModel):
    """Container for the R2 indicator value of a solution set."""

    r2_value: float

RMetricIndicators

Bases: BaseModel

A container for R-metric indicators: R-HV and R-IGD.

Source code in desdeo/tools/indicators_unary.py
class RMetricIndicators(BaseModel):
    """A container for R-metric indicators: R-HV and R-IGD."""

    r_hv: float = Field(description="The R-HV indicator value, based on hypervolume.")
    "The R-HV indicator value, based on hypervolume."
    r_igd: float = Field(description="The R-IGD indicator value, based on inverted generational distance.")
    "The R-IGD indicator value, based on inverted generational distance."

r_hv class-attribute instance-attribute

r_hv: float = Field(
    description="The R-HV indicator value, based on hypervolume."
)

The R-HV indicator value, based on hypervolume.

r_igd class-attribute instance-attribute

r_igd: float = Field(
    description="The R-IGD indicator value, based on inverted generational distance."
)

The R-IGD indicator value, based on inverted generational distance.

distance_indicators

distance_indicators(
    solution_set: ndarray,
    reference_set: ndarray,
    p: float = 2.0,
) -> DistanceIndicators

Calculates various distance based indicators between a solution set and a reference set.

Parameters:

Name Type Description Default
solution_set ndarray

A 2D numpy array where each row is a solution and each column is an objective value. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the set itself can lie within the hypercube, but not outside it. The solutions are assumed to be non-dominated.

required
reference_set ndarray

A 2D numpy array where each row is a solution and each column is an objective value. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the reference set should probably be (0, 0, ..., 0) and (1, 1, ..., 1) respectively. The reference set is assumed to be non-dominated.

required
p float

The power of the Minkowski metric. Set to 1 for Manhattan distance and 2 for Euclidean distance, and np.inf (or math.inf) for Chebyshev distance. Defaults to 2.0.

2.0

Returns:

Name Type Description
DistanceIndicators DistanceIndicators

A Pydantic class containing the IGD, IGD+, GD, GD+, and AHD indicators values.

Source code in desdeo/tools/indicators_unary.py
def distance_indicators(solution_set: np.ndarray, reference_set: np.ndarray, p: float = 2.0) -> DistanceIndicators:
    """Calculates various distance based indicators between a solution set and a reference set.

    Args:
        solution_set (np.ndarray): A 2D numpy array where each row is a solution and each column is an objective value.
            The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the set itself
            can lie within the hypercube, but not outside it. The solutions are assumed to be non-dominated.
        reference_set (np.ndarray): A 2D numpy array where each row is a solution and each column is an objective value.
            The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the reference
            set should probably be (0, 0, ..., 0) and (1, 1, ..., 1) respectively. The reference set is assumed to be
            non-dominated.
        p (float, optional): The power of the Minkowski metric. Set to 1 for Manhattan distance and 2 for Euclidean
            distance, and np.inf (or math.inf) for Chebyshev distance. Defaults to 2.0.

    Returns:
        DistanceIndicators: A Pydantic class containing the IGD, IGD+, GD, GD+, and AHD indicators values.
    """
    distance_matrix = cdist(solution_set, reference_set, metric="minkowski", p=p)
    _igd = np.min(distance_matrix, axis=0).mean()
    _gd = np.min(distance_matrix, axis=1).mean()
    ref_size = reference_set.shape[0]
    set_size = solution_set.shape[0]

    _igd_p = (_igd * ref_size) / (ref_size ** (1 / p))
    _gd_p = (_gd * set_size) / (set_size ** (1 / p))
    _ahd = max(_igd_p, _gd_p)
    return DistanceIndicators(igd=_igd, igd_p=_igd_p, gd=_gd, gd_p=_gd_p, ahd=_ahd)

distance_indicators_batch

distance_indicators_batch(
    solution_sets: dict[str, ndarray],
    reference_set: ndarray,
    p: float = 2.0,
) -> dict[str, DistanceIndicators]

Calculate the IGD, GD, GD_P, IGD_P, and AHD for a sets of solutions.

Parameters:

Name Type Description Default
solution_sets dict[str, ndarray]

A dict of strings mapped to 2D numpy arrays where each array contains a set of solutions. Each row is a solution and each column is an objective value. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the set itself can lie within the hypercube, but not outside it. The solutions are assumed to be non-dominated within their respective sets. The sets must have the same number of objectives/columns but can have different number of solutions/rows. The keys of the dict are the names of the sets.

required
reference_set ndarray

A 2D numpy array where each row is a solution and each column is an objective value. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the reference set should probably be (0, 0, ..., 0) and (1, 1, ..., 1) respectively. The reference set is assumed to be non-dominated.

required
p float

The power of the Minkowski metric. Set to 1 for Manhattan distance and 2 for Euclidean distance, and np.inf (or math.inf) for Chebyshev distance. Defaults to 2.0.

2.0

Returns:

Type Description
dict[str, DistanceIndicators]

dict[str, DistanceIndicators]: A dict of strings mapped to DistanceIndicators objects. The keys of the dict are the names of the sets. The DistanceIndicators objects contain the IGD, IGD+, GD, GD+, and AHD indicators values. This data structure can be easily converted to a DataFrame or saved to disk as a JSON file.

Source code in desdeo/tools/indicators_unary.py
def distance_indicators_batch(
    solution_sets: dict[str, np.ndarray], reference_set: np.ndarray, p: float = 2.0
) -> dict[str, DistanceIndicators]:
    """Calculate the IGD, GD, GD_P, IGD_P, and AHD for a sets of solutions.

    Args:
        solution_sets (dict[str, np.ndarray]): A dict of strings mapped to 2D numpy arrays where each array contains a
            set of solutions. Each row is a solution and each column is an
            objective value. The solutions are assumed to be normalized within
            the unit hypercube. The ideal and nadir of the set itself can lie
            within the hypercube, but not outside it. The solutions are assumed
            to be non-dominated within their respective sets. The sets must have
            the same number of objectives/columns but can have different number
            of solutions/rows. The keys of the dict are the names of the sets.
        reference_set (np.ndarray): A 2D numpy array where each row is a solution and each column is an objective value.
            The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the reference
            set should probably be (0, 0, ..., 0) and (1, 1, ..., 1) respectively. The reference set is assumed to be
            non-dominated.
        p (float, optional): The power of the Minkowski metric. Set to 1 for Manhattan distance and 2 for Euclidean
            distance, and np.inf (or math.inf) for Chebyshev distance. Defaults to 2.0.

    Returns:
        dict[str, DistanceIndicators]: A dict of strings mapped to DistanceIndicators objects. The keys of the dict are
            the names of the sets. The DistanceIndicators objects contain the IGD, IGD+, GD, GD+, and AHD indicators
            values. This data structure can be easily converted to a DataFrame or saved to disk as a JSON file.
    """
    inds = {}
    for set_name, sols in solution_sets.items():
        inds[set_name] = distance_indicators(sols, reference_set, p=p)
    return inds

get_pareto_front

get_pareto_front(solutions)

Extract the Pareto front from a set of solutions.

Source code in desdeo/tools/indicators_unary.py
def get_pareto_front(solutions):
    """Extract the Pareto front from a set of solutions."""
    pareto_front = []
    for i, solution in enumerate(solutions):
        remaining_solutions = np.delete(solutions, i, axis=0)
        if not is_dominated(solution, remaining_solutions):
            pareto_front.append(solution)
    return np.array(pareto_front)

hv

hv(
    solution_set: ndarray, reference_point_component: float
) -> float

Calculate the hypervolume indicator for a set of solutions.

Parameters:

Name Type Description Default
solution_set ndarray

A 2D numpy array where each row is a solution and each column is an objective value. The solutions are assumed to be non-dominated. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the set itself can lie within the hypercube, but not outside it.

required
reference_point_component float

The value of the reference point component. The reference point is assumed to be the same for all objectives. The reference point must be at least 1.

required

Returns:

Name Type Description
float float

The hypervolume indicator value.

Source code in desdeo/tools/indicators_unary.py
def hv(solution_set: np.ndarray, reference_point_component: float) -> float:
    """Calculate the hypervolume indicator for a set of solutions.

    Args:
        solution_set (np.ndarray): A 2D numpy array where each row is a solution and each column is an objective value.
            The solutions are assumed to be non-dominated. The solutions are assumed to be normalized within the unit
            hypercube. The ideal and nadir of the set itself can lie within the hypercube, but not outside it.
        reference_point_component (float): The value of the reference point component. The reference point is assumed to
            be the same for all objectives. The reference point must be at least 1.

    Returns:
        float: The hypervolume indicator value.
    """
    hv = Hypervolume(reference_point_component)
    ind = hv(solution_set)

    if ind is None:
        raise ValueError("Hypervolume calculation failed.")

    return float(ind)

hv_batch

hv_batch(
    solution_sets: dict[str, ndarray],
    reference_points_component: list[float],
) -> dict[str, list[float | None]]

Calculate the hypervolume indicator for a set of solutions over a range of reference points.

Parameters:

Name Type Description Default
solution_sets dict[str, ndarray]

A dict of strings mapped to 2D numpy arrays where each array contains a set of solutions. Each row is a solution and each column is an objective value. The solutions are assumed to be non-dominated within their respective sets. The solutions are assumed to be normalized within the unit hypercube. The ideal and nadir of the set itself can lie within the hypercube, but not outside it. The sets must have the same number of objectives/columns but can have different number of solutions/rows. The keys of the dict are the names of the sets.

required
reference_points_component list[float]

A list of the value of the reference point component. The hypervolume is calculated for each set of solutions for each reference point component. The reference point is assumed to be the same for all objectives. The reference point must be at least 1.

required

Returns:

Type Description
dict[str, list[float | None]]

dict[str, list[float | None]]: A dict of strings mapped to lists of hypervolume indicator values. The keys of the dict are the names of the sets. The lists contain the hypervolume indicator values for each reference point component. If the calculation fails, the value is set to None, and should be handled by the user.

Source code in desdeo/tools/indicators_unary.py
def hv_batch(
    solution_sets: dict[str, np.ndarray], reference_points_component: list[float]
) -> dict[str, list[float | None]]:
    """Calculate the hypervolume indicator for a set of solutions over a range of reference points.

    Args:
        solution_sets (dict[str, np.ndarray]): A dict of strings mapped to 2D numpy arrays where each array contains a
            set of solutions.
            Each row is a solution and each column is an objective value. The solutions are assumed to be non-dominated
            within their respective sets. The solutions are assumed to be normalized within the unit hypercube. The
            ideal and nadir of the set itself can lie within the hypercube, but not outside it. The sets must have the
            same number of objectives/columns but can have different number of solutions/rows.
            The keys of the dict are the names of the sets.
        reference_points_component (list[float]): A list of the value of the reference point component. The
            hypervolume is calculated for each set of solutions for each reference point component. The reference point
            is assumed to be the same for all objectives. The reference point must be at least 1.

    Returns:
        dict[str, list[float | None]]: A dict of strings mapped to lists of hypervolume indicator values. The keys of
            the dict are the names of the sets. The lists contain the hypervolume indicator values for each reference
            point component. If the calculation fails, the value is set to None, and should be handled by the user.
    """
    hvs = {key: [] for key in solution_sets}
    solution_sets[next(iter(solution_sets.keys()))].shape[1]

    for rp in reference_points_component:
        hv = Hypervolume(rp)
        for set_name, sols in solution_sets.items():
            ind = hv(sols)
            if ind is None:
                warn("Hypervolume calculation failed. Setting value to None", category=RuntimeWarning, stacklevel=2)
                hvs[set_name].append(None)
            else:
                hvs[set_name].append(float(ind))

    return hvs

igd_plus_batch

igd_plus_batch(
    solution_sets: dict[str, ndarray],
    reference_set: ndarray,
    p: float = 2.0,
) -> dict[str, IGDPlusIndicators]

Computes the IGD+ indicator for multiple solution sets.

Notes

The minimization of the objective function values is assumed.

Parameters:

Name Type Description Default
solution_sets dict[str, ndarray]

A dictionary of solution sets.

required
reference_set ndarray

The reference Pareto front.

required
p float

The power of the Minkowski metric. Defaults to 2.0 (Euclidean distance).

2.0

Returns:

Type Description
dict[str, IGDPlusIndicators]

dict[str, IGDPlusIndicators]: A dictionary of IGDPlusIndicators.

Source code in desdeo/tools/indicators_unary.py
def igd_plus_batch(
    solution_sets: dict[str, np.ndarray], reference_set: np.ndarray, p: float = 2.0
) -> dict[str, IGDPlusIndicators]:
    """Computes the IGD+ indicator for multiple solution sets.

    Notes:
        The minimization of the objective function values is assumed.

    Args:
        solution_sets (dict[str, np.ndarray]): A dictionary of solution sets.
        reference_set (np.ndarray): The reference Pareto front.
        p (float, optional): The power of the Minkowski metric. Defaults to 2.0 (Euclidean distance).

    Returns:
        dict[str, IGDPlusIndicators]: A dictionary of IGDPlusIndicators.
    """
    results = {}
    for set_name, solution_set in solution_sets.items():
        results[set_name] = igd_plus_indicator(solution_set, reference_set, p)
    return results

igd_plus_indicator

igd_plus_indicator(
    solution_set: ndarray,
    reference_set: ndarray,
    p: float = 2.0,
) -> IGDPlusIndicators

Computes the IGD+ indicator for a given solution set.

Notes

The minimization of the objective function values is assumed.

Parameters:

Name Type Description Default
solution_set ndarray

The solution set being evaluated.

required
reference_set ndarray

The reference Pareto front.

required
p float

The power of the Minkowski metric. Defaults to 2.0 (Euclidean distance).

2.0

Returns:

Name Type Description
IGDPlusIndicators IGDPlusIndicators

A Pydantic class containing the IGD+ indicator value.

Source code in desdeo/tools/indicators_unary.py
def igd_plus_indicator(solution_set: np.ndarray, reference_set: np.ndarray, p: float = 2.0) -> IGDPlusIndicators:
    """Computes the IGD+ indicator for a given solution set.

    Notes:
        The minimization of the objective function values is assumed.

    Args:
        solution_set (np.ndarray): The solution set being evaluated.
        reference_set (np.ndarray): The reference Pareto front.
        p (float, optional): The power of the Minkowski metric. Defaults to 2.0 (Euclidean distance).

    Returns:
        IGDPlusIndicators: A Pydantic class containing the IGD+ indicator value.
    """
    num_ref_points = reference_set.shape[0]
    total_distance = 0.0

    for y_p in reference_set:
        min_distance = float("inf")

        for y_n in solution_set:
            # Compute IGD+ distance (only positive differences)
            distance = np.sum(np.maximum(0, y_n - y_p) ** p)  # Sum over objectives
            min_distance = min(min_distance, distance)  # Store the closest one

        total_distance += min_distance ** (1 / p)  # Apply the root AFTER summing over objectives

    igd_plus_value = total_distance / num_ref_points
    return IGDPlusIndicators(igd_plus=igd_plus_value)

is_dominated

is_dominated(solution, other_solutions)

Check if a solution is dominated by any other solution.

Source code in desdeo/tools/indicators_unary.py
def is_dominated(solution, other_solutions):
    """Check if a solution is dominated by any other solution."""
    return any(np.all(other <= solution) and np.any(other < solution) for other in other_solutions)

r2_batch

r2_batch(
    solution_sets: dict[str, ndarray],
    lambda_set: ndarray,
    z_star: ndarray,
    rho: float = 0.05,
) -> dict[str, R2Indicator]

Computes the R2 indicator for multiple solution sets.

Parameters:

Name Type Description Default
solution_sets dict[str, ndarray]

Dictionary of solution sets.

required
lambda_set ndarray

Set of weight vectors.

required
z_star ndarray

Ideal point.

required
rho float

Augmented Tchebycheff parameter.

0.05

Returns:

Type Description
dict[str, R2Indicator]

dict[str, R2IndicatorResult]: Dictionary of results.

Source code in desdeo/tools/indicators_unary.py
def r2_batch(
    solution_sets: dict[str, np.ndarray], lambda_set: np.ndarray, z_star: np.ndarray, rho: float = 0.05
) -> dict[str, R2Indicator]:
    """Computes the R2 indicator for multiple solution sets.

    Args:
        solution_sets (dict[str, np.ndarray]): Dictionary of solution sets.
        lambda_set (np.ndarray): Set of weight vectors.
        z_star (np.ndarray): Ideal point.
        rho (float, optional): Augmented Tchebycheff parameter.

    Returns:
        dict[str, R2IndicatorResult]: Dictionary of results.
    """
    return {name: r2_indicator(solution_set, lambda_set, z_star, rho) for name, solution_set in solution_sets.items()}

r2_indicator

r2_indicator(
    solution_set: ndarray,
    lambda_set: ndarray,
    z_star: ndarray,
    rho: float = 0.05,
) -> R2Indicator

Computes the unary R2 indicator for a given solution set.

Parameters:

Name Type Description Default
solution_set ndarray

The Pareto front approximation.

required
lambda_set ndarray

The set of normalized weight vectors (λ).

required
z_star ndarray

The ideal point (must dominate or weakly dominate all solutions).

required
rho float

Small positive number for augmented Tchebycheff. Default is 0.05.

0.05

Returns:

Name Type Description
R2IndicatorResult R2Indicator

Pydantic class with R2 value.

Source code in desdeo/tools/indicators_unary.py
def r2_indicator(
    solution_set: np.ndarray, lambda_set: np.ndarray, z_star: np.ndarray, rho: float = 0.05
) -> R2Indicator:
    """Computes the unary R2 indicator for a given solution set.

    Args:
        solution_set (np.ndarray): The Pareto front approximation.
        lambda_set (np.ndarray): The set of normalized weight vectors (λ).
        z_star (np.ndarray): The ideal point (must dominate or weakly dominate all solutions).
        rho (float, optional): Small positive number for augmented Tchebycheff. Default is 0.05.

    Returns:
        R2IndicatorResult: Pydantic class with R2 value.
    """
    total_score = 0.0
    for lambd in lambda_set:
        best_score = max(tchebycheff_utility(fx, lambd, z_star, rho) for fx in solution_set)
        total_score += best_score

    r2_value = total_score / len(lambda_set)
    return R2Indicator(r2_value=r2_value)

r_metric_indicator

r_metric_indicator(
    solution_set: ndarray,
    ref_points: ndarray,
    w: ndarray = None,
    delta: float = 0.2,
) -> RMetricIndicators

Calculate the R-metric (either R-HV or R-IGD) for a given solution set.

solution_set : np.ndarray The set of solutions.

np.ndarray

A set of reference points..

np.ndarray, optional

Weights for each objective.

float, optional

Region of interest for the metric calculation.

RMetricIndicators An object containing the computed R-HV and R-IGD values.

Source code in desdeo/tools/indicators_unary.py
def r_metric_indicator(
    solution_set: np.ndarray, ref_points: np.ndarray, w: np.ndarray = None, delta: float = 0.2
) -> RMetricIndicators:
    """Calculate the R-metric (either R-HV or R-IGD) for a given solution set.

    Parameters:
    solution_set : np.ndarray
        The set of solutions.

    ref_points : np.ndarray
        A set of reference points..

    w : np.ndarray, optional
        Weights for each objective.

    delta : float, optional
        Region of interest for the metric calculation.

    Returns:
    RMetricIndicators
        An object containing the computed R-HV and R-IGD values.
    """
    # Calculate the Pareto front
    pareto_front = get_pareto_front(solution_set)

    rmetric = RMetric(problem=None, ref_points=ref_points, w=w, delta=delta, pf=pareto_front)
    r_igd, r_hv = rmetric.do(solution_set)
    return RMetricIndicators(r_hv=r_hv, r_igd=r_igd)

r_metric_indicators_batch

r_metric_indicators_batch(
    solution_set: dict[str, ndarray],
    ref_points: ndarray,
    w: ndarray = None,
    delta: float = 0.2,
) -> dict[str, RMetricIndicators]

Calculate the R-metrics (R-HV and R-IGD) for a batch of solution sets.

Source code in desdeo/tools/indicators_unary.py
def r_metric_indicators_batch(
    solution_set: dict[str, np.ndarray], ref_points: np.ndarray, w: np.ndarray = None, delta: float = 0.2
) -> dict[str, RMetricIndicators]:
    """Calculate the R-metrics (R-HV and R-IGD) for a batch of solution sets."""
    inds = {}
    for set_name, sols in solution_set.items():
        inds[set_name] = r_metric_indicator(sols, ref_points, w, delta)
    return inds

tchebycheff_utility

tchebycheff_utility(
    fx: ndarray,
    lambd: ndarray,
    z_star: ndarray,
    rho: float = 0.05,
) -> float

Calculates the augmented Tchebycheff utility of a solution.

Source code in desdeo/tools/indicators_unary.py
def tchebycheff_utility(fx: np.ndarray, lambd: np.ndarray, z_star: np.ndarray, rho: float = 0.05) -> float:
    """Calculates the augmented Tchebycheff utility of a solution."""
    diff = np.abs(z_star - fx)
    max_term = np.max(lambd * diff)
    sum_term = np.sum(diff)
    return -(max_term + rho * sum_term)

Binary indicators

This module implements unary indicators that can be used to compare two solution sets.

It assumes that the solution set has been normalized just that some ideal point (not necessarily the ideal point of the set) is the origin and some nadir point (not necessarily the nadir point of the set) is (1, 1, ..., 1). The normalized solution set is assumed to be inside the bounding box [0, 1]^k where k is the number of objectives. If these conditions are not met, the results of the indicators will not be meaningful.

Additionally, the set may be assumed to only contain mutually non-dominated solutions, depending on the indicator.

For now, we rely on pymoo for the implementation of many of the indicators.

epsilon_component

epsilon_component(
    solution1: ndarray, solution2: ndarray
) -> float

Computes the additive epsilon-indicator between two solutions.

Basically, returns the minimum amount by which the values in solution1 must be translated (minimization assumed) such that it (weakly) dominates solution2. If solution1 already dominates solution2, returns 0.0.

Parameters:

Name Type Description Default
solution1 ndarray

Should be an one-dimensional array, where each value is normalized between [0, 1]

required
solution2 ndarray

Should be an one-dimensional array, where each value is normalized between [0, 1]

required

Returns:

Name Type Description
float float

The maximum distance between the values in s1 and s2.

Source code in desdeo/tools/indicators_binary.py
@njit()
def epsilon_component(solution1: np.ndarray, solution2: np.ndarray) -> float:
    """Computes the additive epsilon-indicator between two solutions.

    Basically, returns the minimum amount by which the values in solution1 must be translated (minimization assumed)
    such that it (weakly) dominates solution2. If solution1 already dominates solution2, returns 0.0.

    Args:
        solution1 (np.ndarray): Should be an one-dimensional array, where each value is normalized between [0, 1]
        solution2 (np.ndarray): Should be an one-dimensional array, where each value is normalized between [0, 1]

    Returns:
        float: The maximum distance between the values in s1 and s2.
    """
    return max(0.0, max(solution1 - solution2))  # noqa: PLW3301  (numba @njit cannot unpack an array with *)

epsilon_indicator

epsilon_indicator(
    set1: ndarray,
    set2: ndarray,
    kind: Literal[
        "additive", "multiplicative"
    ] = "additive",
) -> float

Computes the additive epsilon-indicator between two solution sets.

Parameters:

Name Type Description Default
set1 ndarray

Should be a two-dimensional array, where each row is a solution normalized between [0, 1]

required
set2 ndarray

Should be a two-dimensional array, where each row is a solution normalized between [0, 1]

required
kind Literal['additive', 'multiplicative']

The kind of epsilon-indicator to compute. Defaults to "additive".

'additive'

Returns:

Name Type Description
float float

the epsilon-indicator between the two sets.

Source code in desdeo/tools/indicators_binary.py
def epsilon_indicator(
    set1: np.ndarray, set2: np.ndarray, kind: Literal["additive", "multiplicative"] = "additive"
) -> float:
    """Computes the additive epsilon-indicator between two solution sets.

    Args:
        set1 (np.ndarray): Should be a two-dimensional array, where each row is a solution normalized between [0, 1]
        set2 (np.ndarray): Should be a two-dimensional array, where each row is a solution normalized between [0, 1]
        kind (Literal["additive", "multiplicative"]): The kind of epsilon-indicator to compute. Defaults to "additive".

    Returns:
        float: the  epsilon-indicator between the two sets.
    """
    if kind == "additive":
        return epsilon_additive(set1, ref=set2)
    if kind == "multiplicative":
        return epsilon_mult(set1, ref=set2)
    raise ValueError(f"Unknown kind: {kind}. Use 'additive' or 'multiplicative'.")

hv_component

hv_component(
    solution1: ndarray, solution2: ndarray, ref: float = 2.0
) -> float

Computes the hypervolume contribution of solution1 with respect to solution2.

Parameters:

Name Type Description Default
solution1 ndarray

Should be an one-dimensional array, where each value is normalized between [0, 1]

required
solution2 ndarray

Should be an one-dimensional array, where each value is normalized between [0, 1]

required
ref float

The reference point for the hypervolume calculation. Defaults to 2.0.

2.0

Returns:

Name Type Description
float float

The hypervolume contribution of solution1 with respect to solution2.

Source code in desdeo/tools/indicators_binary.py
def hv_component(solution1: np.ndarray, solution2: np.ndarray, ref: float = 2.0) -> float:
    """Computes the hypervolume contribution of solution1 with respect to solution2.

    Args:
        solution1 (np.ndarray): Should be an one-dimensional array, where each value is normalized between [0, 1]
        solution2 (np.ndarray): Should be an one-dimensional array, where each value is normalized between [0, 1]
        ref (float): The reference point for the hypervolume calculation. Defaults to 2.0.

    Returns:
        float: The hypervolume contribution of solution1 with respect to solution2.
    """
    if dominates(solution1, solution2):
        return np.prod(ref - solution2) - np.prod(ref - solution1)
    return hv(solution_set=np.array([solution1, solution2]), reference_point_component=ref)

self_epsilon

self_epsilon(solution_set: ndarray) -> np.ndarray

Computes the pairwise additive epsilon-indicator for a solution set.

Parameters:

Name Type Description Default
solution_set ndarray

Should be a two-dimensional array, where each row is a solution normalized between [0, 1].

required

Returns:

Type Description
ndarray

np.ndarray: A two-dimensional array where the entry at (i, j) is the additive epsilon-indicator between the i-th and j-th solution in the set.

Source code in desdeo/tools/indicators_binary.py
@njit()
def self_epsilon(solution_set: np.ndarray) -> np.ndarray:
    """Computes the pairwise additive epsilon-indicator for a solution set.

    Args:
        solution_set (np.ndarray): Should be a two-dimensional array, where each row is a
            solution normalized between [0, 1].

    Returns:
        np.ndarray: A two-dimensional array where the entry at (i, j) is the
            additive epsilon-indicator between the i-th and j-th solution in the set.
    """
    n_solutions = solution_set.shape[0]
    eps_matrix = np.zeros((n_solutions, n_solutions), dtype=np.float64)
    for i in range(n_solutions):
        for j in range(n_solutions):
            eps_matrix[i, j] = epsilon_component(solution_set[i], solution_set[j])
    return eps_matrix

self_hv

self_hv(
    solution_set: ndarray, ref: float = 2.0
) -> np.ndarray

Computes the pairwise hypervolume contribution for a solution set.

Parameters:

Name Type Description Default
solution_set ndarray

Should be a two-dimensional array, where each row is a solution normalized between [0, 1].

required
ref float

The reference point for the hypervolume calculation. Defaults to 2.0.

2.0

Returns:

Type Description
ndarray

np.ndarray: A two-dimensional array where the entry at (i, j) is the hypervolume contribution of the i-th solution with respect to the j-th solution in the set.

Source code in desdeo/tools/indicators_binary.py
def self_hv(solution_set: np.ndarray, ref: float = 2.0) -> np.ndarray:
    """Computes the pairwise hypervolume contribution for a solution set.

    Args:
        solution_set (np.ndarray): Should be a two-dimensional array, where each row is a
            solution normalized between [0, 1].
        ref (float): The reference point for the hypervolume calculation. Defaults to 2.0.

    Returns:
        np.ndarray: A two-dimensional array where the entry at (i, j) is the
            hypervolume contribution of the i-th solution with respect to the j-th solution in the set.
    """
    n_solutions = solution_set.shape[0]
    hv_matrix = np.zeros((n_solutions, n_solutions), dtype=np.float64)
    for i in range(n_solutions):
        for j in range(n_solutions):
            hv_matrix[i, j] = hv_component(solution_set[i], solution_set[j], ref=ref)
    return hv_matrix

Reference vectors

Reference vector generation for decomposition-based evolutionary methods.

add_edge_vectors

add_edge_vectors(values: ndarray) -> np.ndarray

Add edge (axis-aligned) vectors to the set of reference vectors.

This ensures that each axis direction is represented in the set.

Parameters:

Name Type Description Default
values ndarray

Array of reference vectors.

required

Returns:

Type Description
ndarray

np.ndarray: Array of reference vectors with edge vectors added and normalized.

Source code in desdeo/tools/reference_vectors.py
def add_edge_vectors(values: np.ndarray) -> np.ndarray:
    """Add edge (axis-aligned) vectors to the set of reference vectors.

    This ensures that each axis direction is represented in the set.

    Args:
        values (np.ndarray): Array of reference vectors.

    Returns:
        np.ndarray: Array of reference vectors with edge vectors added and normalized.
    """
    edge_vectors = np.eye(values.shape[1])
    values = np.vstack([values, edge_vectors])
    return normalize(values)

approx_lattice_resolution

approx_lattice_resolution(
    number_of_vectors: int, num_dims: int
) -> int

Approximate the lattice resolution based on the number of vectors and dimensions.

Parameters:

Name Type Description Default
number_of_vectors int

Desired number of reference vectors.

required
num_dims int

Number of objectives (dimensions).

required

Returns:

Name Type Description
int int

The smallest lattice resolution that produces more than the desired number of vectors.

Source code in desdeo/tools/reference_vectors.py
def approx_lattice_resolution(number_of_vectors: int, num_dims: int) -> int:
    """Approximate the lattice resolution based on the number of vectors and dimensions.

    Args:
        number_of_vectors (int): Desired number of reference vectors.
        num_dims (int): Number of objectives (dimensions).

    Returns:
        int: The smallest lattice resolution that produces more than the desired 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

create_simplex

create_simplex(
    number_of_objectives: int,
    lattice_resolution: int | None = None,
    number_of_vectors: int | None = None,
) -> np.ndarray

Create reference vectors using the simplex lattice design.

Parameters:

Name Type Description Default
number_of_objectives int

Number of objectives (dimensions).

required
lattice_resolution int

Lattice resolution to use. If None, will be determined from number_of_vectors.

None
number_of_vectors int

Desired number of reference vectors. Used if lattice_resolution is None.

None

Returns:

Type Description
ndarray

np.ndarray: Array of normalized reference vectors.

Raises:

Type Description
ValueError

If both lattice_resolution and number_of_vectors are None.

Source code in desdeo/tools/reference_vectors.py
def create_simplex(
    number_of_objectives: int,
    lattice_resolution: int | None = None,
    number_of_vectors: int | None = None,
) -> np.ndarray:
    """Create reference vectors using the simplex lattice design.

    Args:
        number_of_objectives (int): Number of objectives (dimensions).
        lattice_resolution (int, optional): Lattice resolution to use. If None, will be
            determined from number_of_vectors.
        number_of_vectors (int, optional): Desired number of reference vectors. Used if lattice_resolution is None.

    Returns:
        np.ndarray: Array of normalized reference vectors.

    Raises:
        ValueError: If both lattice_resolution and number_of_vectors are None.
    """
    if lattice_resolution is None and number_of_vectors is None:
        raise ValueError("Either lattice resolution or number of vectors must be specified.")

    if lattice_resolution is None:
        lattice_resolution = approx_lattice_resolution(number_of_vectors, number_of_objectives)

    number_of_vectors = comb(
        lattice_resolution + number_of_objectives - 1,
        number_of_objectives - 1,
        exact=True,
    )

    temp1 = range(1, number_of_objectives + lattice_resolution)
    temp1 = np.array(list(combinations(temp1, number_of_objectives - 1)))
    temp2 = np.array([range(number_of_objectives - 1)] * number_of_vectors)
    temp = temp1 - temp2 - 1
    weight = np.zeros((number_of_vectors, number_of_objectives), dtype=int)
    weight[:, 0] = temp[:, 0]
    for i in range(1, number_of_objectives - 1):
        weight[:, i] = temp[:, i] - temp[:, i - 1]
    weight[:, -1] = lattice_resolution - temp[:, -1]
    values = weight / lattice_resolution
    return normalize(values)

householder

householder(vector)

Return reflection matrix via householder transformation.

Source code in desdeo/tools/reference_vectors.py
def householder(vector):
    """Return reflection matrix via householder transformation."""
    identity_mat = np.eye(len(vector))
    v = vector[np.newaxis]
    denominator = np.matmul(v, v.T)
    numerator = np.matmul(v.T, v)
    return identity_mat - (2 * numerator / denominator)

neighbouring_angles

neighbouring_angles(values: ndarray) -> np.ndarray

Calculate the angles to the nearest neighbor for each reference vector.

Parameters:

Name Type Description Default
values ndarray

Array of normalized reference vectors.

required

Returns:

Type Description
ndarray

np.ndarray: Array of angles (in radians) to the nearest neighbor for each vector.

Source code in desdeo/tools/reference_vectors.py
def neighbouring_angles(values: np.ndarray) -> np.ndarray:
    """Calculate the angles to the nearest neighbor for each reference vector.

    Args:
        values (np.ndarray): Array of normalized reference vectors.

    Returns:
        np.ndarray: Array of angles (in radians) to the nearest neighbor for each vector.
    """
    cosvv = np.dot(values, values.transpose())
    cosvv.sort(axis=1)
    cosvv = np.flip(cosvv, 1)
    cosvv[cosvv > 1] = 1
    return np.arccos(cosvv[:, 1])

normalize

normalize(values: ndarray) -> np.ndarray

Normalize a set of vectors to unit length (project onto the unit hypersphere).

Parameters:

Name Type Description Default
values ndarray

Array of vectors to normalize.

required

Returns:

Type Description
ndarray

np.ndarray: Normalized vectors.

Source code in desdeo/tools/reference_vectors.py
def normalize(values: np.ndarray) -> np.ndarray:
    """Normalize a set of vectors to unit length (project onto the unit hypersphere).

    Args:
        values (np.ndarray): Array of vectors to normalize.

    Returns:
        np.ndarray: Normalized vectors.
    """
    norm_2 = np.linalg.norm(values, axis=1).reshape(-1, 1)
    norm_2[norm_2 == 0] = np.finfo(float).eps
    return np.divide(values, norm_2)

rotate

rotate(initial_vector, rotated_vector, other_vectors)

Calculate the rotation matrix that rotates the initial_vector to the rotated_vector.

Apply that rotation on other_vectors and return. Uses Householder reflections twice to achieve this.

Source code in desdeo/tools/reference_vectors.py
def rotate(initial_vector, rotated_vector, other_vectors):
    """Calculate the rotation matrix that rotates the initial_vector to the rotated_vector.

    Apply that rotation on other_vectors and return.
    Uses Householder reflections twice to achieve this.
    """
    init_vec_norm = normalize(initial_vector)
    rot_vec_norm = normalize(np.asarray(rotated_vector))
    middle_vec_norm = normalize(init_vec_norm + rot_vec_norm)
    first_reflector = init_vec_norm - middle_vec_norm
    second_reflector = middle_vec_norm - rot_vec_norm
    Q1 = householder(first_reflector)  # noqa: N806
    Q2 = householder(second_reflector)  # noqa: N806
    reflection_matrix = np.matmul(Q2, Q1)
    return np.matmul(other_vectors, np.transpose(reflection_matrix))

rotate_toward

rotate_toward(
    initial_vector,
    final_vector,
    other_vectors,
    degrees: float = 5,
)

Rotate other_vectors (with the centre at initial_vector) towards final_vector by an angle degrees.

Parameters

initial_vector : np.ndarray Centre of the vectors to be rotated. final_vector : np.ndarray The final position of the center of other_vectors. other_vectors : np.ndarray The array of vectors to be rotated degrees : float, optional The amount of rotation (the default is 5)

Returns:

rotated_vectors : np.ndarray The rotated vectors reached: bool True if final_vector has been reached

Source code in desdeo/tools/reference_vectors.py
def rotate_toward(initial_vector, final_vector, other_vectors, degrees: float = 5):
    """Rotate other_vectors (with the centre at initial_vector) towards final_vector by an angle degrees.

    Parameters
    ----------
    initial_vector : np.ndarray
        Centre of the vectors to be rotated.
    final_vector : np.ndarray
        The final position of the center of other_vectors.
    other_vectors : np.ndarray
        The array of vectors to be rotated
    degrees : float, optional
        The amount of rotation (the default is 5)

    Returns:
    -------
    rotated_vectors : np.ndarray
        The rotated vectors
    reached: bool
        True if final_vector has been reached
    """
    final_vector = normalize(final_vector)
    initial_vector = normalize(initial_vector)
    cos_phi = np.dot(initial_vector, final_vector)
    theta = degrees * np.pi / 180
    cos_theta = np.cos(theta)
    phi = np.arccos(cos_phi)
    if phi < theta:
        return (rotate(initial_vector, final_vector, other_vectors), True)
    cos_phi_theta = np.cos(phi - theta)
    A = np.asarray([[cos_phi, 1], [1, cos_phi]])  # noqa: N806
    B = np.asarray([cos_phi_theta, cos_theta])  # noqa: N806
    x = np.linalg.solve(A, B)
    rotated_vector = x[0] * initial_vector + x[1] * final_vector
    return (rotate(initial_vector, rotated_vector, other_vectors), False)

shear

shear(vectors, degrees: float = 5)

Shear a set of vectors lying on the plane z=0 towards the z-axis.

The resulting vectors are'degrees' angle away from the z axis.

Parameters

vectors : numpy.ndarray The final element of each vector should be zero. degrees : float, optional The angle that the resultant vectors make with the z axis. Unit is radians. (the default is 5)

Source code in desdeo/tools/reference_vectors.py
def shear(vectors, degrees: float = 5):
    """Shear a set of vectors lying on the plane z=0 towards the z-axis.

    The resulting vectors are'degrees' angle away from the z axis.

    Parameters
    ----------
    vectors : numpy.ndarray
        The final element of each vector should be zero.
    degrees : float, optional
        The angle that the resultant vectors make with the z axis. Unit is radians.
        (the default is 5)
    """
    angle = degrees * np.pi / 180
    m = 1 / np.tan(angle)
    norm = np.linalg.norm(vectors, axis=1)
    vectors[:, -1] += norm * m
    return normalize(vectors)

Reference point generation

Generate reference points for the IPA algorithm.

generate_points

generate_points(
    num_points: int, num_dims: int
) -> tuple[np.ndarray, np.ndarray]

Generate reference points for the IPA algorithm.

Creates a (large) number of reference points on a plane perpendicular to the largest space diagonal of the unit hypercube in the num_dims-dimensional space. First, the vertices of the unit hypercube are generated. Then, the vertices are projected onto the plane perpendicular to the largest space diagonal (vertex first parallel projection) and rotated such that the plane is perpendular to one of the axes, making all objective values zero. A convex hull is then constructed from the projected vertices, and a bounding box is constructed around the convex hull. Finally, points are generated uniformly within the bounding box until num_points points are generated inside the convex hull. Note that the number of dimensions must be at least 2. Also, the number of dimensions of the reference points is one less than the number of dimensions of the objective space. This is because the reference points are generated on the projected plane.

Parameters:

Name Type Description Default
num_points int

The number of reference points to generate.

required
num_dims int

The number of dimensions of the space in which the reference points are generated.

required

Returns:

Type Description
tuple[ndarray, ndarray]

np.ndarray: A (num_points) x (num_dims-1) array of reference points.

Source code in desdeo/tools/generateReferencePoints.py
def generate_points(
    num_points: int, num_dims: int
) -> tuple[
    np.ndarray,
    np.ndarray,
]:
    """Generate reference points for the IPA algorithm.

    Creates a (large) number of reference points on a plane perpendicular to the largest space diagonal of the unit
    hypercube in the num_dims-dimensional space. First, the vertices of the unit hypercube are generated. Then, the
    vertices are projected onto the plane perpendicular to the largest space diagonal (vertex first parallel
    projection) and rotated such that the plane is perpendular to one of the axes, making all objective values zero.
    A convex hull is then constructed from the projected vertices, and a bounding box is constructed
    around the convex hull. Finally, points are generated uniformly within the bounding box until num_points points
    are generated _inside_ the convex hull. Note that the number of dimensions must be at least 2. Also, the number of
    dimensions of the reference points is one less than the number of dimensions of the objective space. This is because
    the reference points are generated on the projected plane.

    Args:
        num_points (int): The number of reference points to generate.
        num_dims (int): The number of dimensions of the space in which the reference points are generated.

    Returns:
        np.ndarray: A (num_points) x (num_dims-1) array of reference points.
    """
    bounding_box, a, b, _ = get_reference_hull(num_dims)  # bounding_box, A, b, hull
    points = numba_random_gen(num_points, bounding_box, a, b)
    # Project vertices onto plane perpendicular to largest space diagonal
    points_rotated = rotate_out(points)
    return points, points_rotated

get_hull_equations

get_hull_equations(
    hull: ConvexHull,
) -> tuple[np.ndarray, np.ndarray]

Get the equations of the hyperplanes defining the convex hull.

Parameters:

Name Type Description Default
hull ConvexHull

A convex hull.

required

Returns:

Type Description
ndarray

np.ndarray: A (num_dims-1) x num_hyperplanes array of the coefficients of the hyperplanes defining the convex hull.

ndarray

np.ndarray: A (num_hyperplanes) array of the constants of the hyperplanes defining the convex hull.

Source code in desdeo/tools/generateReferencePoints.py
def get_hull_equations(hull: ConvexHull) -> tuple[np.ndarray, np.ndarray]:
    """Get the equations of the hyperplanes defining the convex hull.

    Args:
        hull (scipy.spatial.ConvexHull): A convex hull.

    Returns:
        np.ndarray: A (num_dims-1) x num_hyperplanes array of the coefficients of the hyperplanes defining the convex
            hull.
        np.ndarray: A (num_hyperplanes) array of the constants of the hyperplanes defining the convex hull.
    """
    return np.ascontiguousarray(hull.equations[:, :-1].T), np.ascontiguousarray(hull.equations[:, -1].T)

get_reference_hull

get_reference_hull(
    num_dims,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, ConvexHull]

Get the convex hull of the valid reference points for IPA.

This algorithm generates the vertices of the unit hypercube in the (num_dims)-dimensional space. Then, the vertices are projected onto the plane perpendicular to the largest space diagonal (vertex first parallel projection) and rotated such that the plane is perpendular to one of the axes. Then, the points are are flattened to (num_dims-1)-dimensional space. A convex hull is then constructed from the projected vertices, and a bounding box is constructed around the convex hull.

Parameters:

Name Type Description Default
num_dims int

The number of dimensions of the space in which the reference points are generated.

required

Returns:

Type Description
ndarray

np.ndarray: A (2) x (num_dims-1) array of the bounding box. Reference points are guaranteed to be within this box. However, not all points within this box are valid reference points.

ndarray

np.ndarray: A (num_dims-1) x (num_dims-1) array of the coefficients of the hyperplanes defining the convex hull of the bounds of the reference points. A point is a valid reference point if it lies within the convex hull.

ndarray

np.ndarray: A (num_dims-1) array of the constants of the hyperplanes defining the convex hull. See above.

ConvexHull

scipy.spatial.ConvexHull: The convex hull of the projected vertices/valid reference points.

Source code in desdeo/tools/generateReferencePoints.py
def get_reference_hull(num_dims) -> tuple[np.ndarray, np.ndarray, np.ndarray, ConvexHull]:
    """Get the convex hull of the valid reference points for IPA.

    This algorithm generates the vertices of the unit hypercube in the (num_dims)-dimensional space.
    Then, the vertices are projected onto the plane perpendicular to the largest space diagonal (vertex first parallel
    projection) and rotated such that the plane is perpendular to one of the axes. Then, the points are are flattened
    to (num_dims-1)-dimensional space. A convex hull is then constructed from the projected vertices, and a bounding box
    is constructed around the convex hull.

    Args:
        num_dims (int): The number of dimensions of the space in which the reference points are generated.

    Returns:
        np.ndarray: A (2) x (num_dims-1) array of the bounding box. Reference points are guaranteed to be within
            this box. However, not all points within this box are valid reference points.
        np.ndarray: A (num_dims-1) x (num_dims-1) array of the coefficients of the hyperplanes defining the convex hull
            of the bounds of the reference points. A point is a valid reference point if it lies within the convex hull.
        np.ndarray: A (num_dims-1) array of the constants of the hyperplanes defining the convex hull. See above.
        scipy.spatial.ConvexHull: The convex hull of the projected vertices/valid reference points.
    """
    vertices = np.array(list(product([0, 1], repeat=num_dims)))

    # Project vertices onto plane perpendicular to largest space diagonal, rotate to make one of the objectives zero.
    # Then flatten to (num_dims-1) dimensions.
    rotated_vertices = rotate_in(vertices)

    bounding_box = np.array([np.min(rotated_vertices, axis=0), np.max(rotated_vertices, axis=0)])

    hull = ConvexHull(rotated_vertices)

    a, b = get_hull_equations(hull)  # A, b

    return bounding_box, a, b, hull

householder

householder(vector)

Return reflection matrix via householder transformation.

Source code in desdeo/tools/generateReferencePoints.py
def householder(vector):
    """Return reflection matrix via householder transformation."""
    identity_mat = np.eye(len(vector))
    v = vector[np.newaxis]
    denominator = np.matmul(v, v.T)
    numerator = np.matmul(v.T, v)
    return identity_mat - (2 * numerator / denominator)

normalize

normalize(vectors)

Normalize a set of vectors.

The length of the returned vectors will be 1.

Parameters

vectors : np.ndarray Set of vectors of any length, except zero.

Source code in desdeo/tools/generateReferencePoints.py
def normalize(vectors):
    """Normalize a set of vectors.

    The length of the returned vectors will be 1.

    Parameters
    ----------
    vectors : np.ndarray
        Set of vectors of any length, except zero.

    """
    if len(np.asarray(vectors).shape) == 1:
        return vectors / np.linalg.norm(vectors)
    norm = np.linalg.norm(vectors, axis=1)
    return vectors / norm[:, np.newaxis]

numba_random_gen

numba_random_gen(
    num_points: int,
    bounding_box: ndarray,
    a: ndarray,
    b: ndarray,
) -> np.ndarray

Generates num_points random points within the convex hull defined by A and b.

Parameters:

Name Type Description Default
num_points int

The number of points to generate.

required
bounding_box ndarray

A (2) x (num_objs - 1) array defining the bounding box within which to generate points initially.

required
a ndarray

A (num_hyperplanes) x (num_objs - 1) array of the coefficients of the hyperplanes defining the convex hull. Basically the first num_objs - 1 columns of hull.equations.

required
b ndarray

A (num_hyperplanes) array of the constants of the hyperplanes defining the convex hull. Basically the last column of hull.equations.

required

Returns:

Type Description
ndarray

np.ndarray: A (num_points) x (num_objs - 1) array of points within the convex hull defined by A and b.

Source code in desdeo/tools/generateReferencePoints.py
@njit()
def numba_random_gen(num_points: int, bounding_box: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
    """Generates num_points random points within the convex hull defined by A and b.

    Args:
        num_points (int): The number of points to generate.
        bounding_box (np.ndarray): A (2) x (num_objs - 1) array defining the bounding box within which to
            generate points initially.
        a (np.ndarray): A (num_hyperplanes) x (num_objs - 1) array of the coefficients of the hyperplanes defining the
            convex hull. Basically the first num_objs - 1 columns of hull.equations.
        b (np.ndarray): A (num_hyperplanes) array of the constants of the hyperplanes defining the convex hull.
            Basically the last column of hull.equations.

    Returns:
        np.ndarray: A (num_points) x (num_objs - 1) array of points within the convex hull defined by A and b.
    """
    num_dims_ = bounding_box.shape[1]
    points = np.zeros((num_points, num_dims_))

    eps = np.finfo(np.float32).eps

    counter = 0

    while counter < num_points:
        point = np.zeros(num_dims_)
        for i in range(num_dims_):
            # Generate a random point within the bounding box.
            # NOTE: numba's nopython mode does not support the np.random.Generator
            # API, so the legacy np.random.uniform must be used here.
            point[i] = np.random.uniform(bounding_box[0, i], bounding_box[1, i])  # noqa: NPY002
        if np.all(point @ a + b < eps):  # point @ A + b
            # If the point is inside the convex hull, add it to the list of points
            points[counter] = point
            counter += 1
    return points

rotate

rotate(initial_vector, rotated_vector, other_vectors)

Calculate the rotation matrix that rotates the initial_vector...

...to the rotated_vector. Apply that rotation on other_vectors and return. Uses Householder reflections twice to achieve this.

Source code in desdeo/tools/generateReferencePoints.py
def rotate(initial_vector, rotated_vector, other_vectors):
    """Calculate the rotation matrix that rotates the initial_vector...

    ...to the rotated_vector. Apply that rotation on other_vectors and return.
    Uses Householder reflections twice to achieve this.
    """
    init_vec_norm = normalize(initial_vector)
    rot_vec_norm = normalize(np.asarray(rotated_vector))
    middle_vec_norm = normalize(init_vec_norm + rot_vec_norm)
    first_reflector = init_vec_norm - middle_vec_norm
    second_reflector = middle_vec_norm - rot_vec_norm
    q1 = householder(first_reflector)
    q2 = householder(second_reflector)
    reflection_matrix = np.matmul(q2, q1)
    return np.matmul(other_vectors, np.transpose(reflection_matrix))

rotate_in

rotate_in(vertices: ndarray) -> np.ndarray

Project the vertices to a lower dimensional space.

First, the vertices are rotated such that the plane perpendicular to the ideal-nadir line becomes perpendicular to one of the axes. Essentially, (1,1,...,1) is rotated to (0,0,...,0,1). Then, the last dimension is dropped.

Parameters:

Name Type Description Default
vertices ndarray

The vertices to be projected.

required

Returns:

Type Description
ndarray

np.ndarray: The projected vertices.

Source code in desdeo/tools/generateReferencePoints.py
def rotate_in(vertices: np.ndarray) -> np.ndarray:
    """Project the vertices to a lower dimensional space.

    First, the vertices are rotated such that the plane perpendicular to the ideal-nadir line
    becomes perpendicular to one of the axes. Essentially, (1,1,...,1) is rotated to (0,0,...,0,1).
    Then, the last dimension is dropped.

    Args:
        vertices (np.ndarray): The vertices to be projected.

    Returns:
        np.ndarray: The projected vertices.
    """
    num_dims = len(vertices[0])
    rotated_vertices = rotate([1] * num_dims, ([0] * (num_dims - 1) + [1]), vertices)
    return rotated_vertices[:, :-1]

rotate_out

rotate_out(points: ndarray) -> np.ndarray

Undo the rotate_in operation.

Parameters:

Name Type Description Default
points ndarray

The points to be projected back.

required

Returns:

Type Description
ndarray

np.ndarray: The projected points.

Source code in desdeo/tools/generateReferencePoints.py
def rotate_out(points: np.ndarray) -> np.ndarray:
    """Undo the `rotate_in` operation.

    Args:
        points (np.ndarray): The points to be projected back.

    Returns:
        np.ndarray: The projected points.
    """
    points = np.atleast_2d(points)
    num_dims = points.shape[1]
    points_rotated = np.hstack((points, np.ones((len(points), 1))))
    points_rotated = rotate(([0] * (num_dims) + [1]), [1] * (num_dims + 1), points_rotated)
    # Move (along nadir-ideal direction) the plane of these points such that it passes through nadir
    return points_rotated + 1 - 1 / np.sqrt(num_dims + 1)

Non-dominated sorting

This module contains functions for non-dominated sorting of solutions.

dominates

dominates(x: ndarray, y: ndarray) -> bool

Returns true if x dominates y.

Parameters:

Name Type Description Default
x ndarray

First solution. Should be a 1-D array of numerics.

required
y ndarray

Second solution. Should be the same shape as x.

required

Returns:

Name Type Description
bool bool

True if x dominates y, false otherwise.

Source code in desdeo/tools/non_dominated_sorting.py
@njit()
def dominates(x: np.ndarray, y: np.ndarray) -> bool:
    """Returns true if x dominates y.

    Args:
        x (np.ndarray): First solution. Should be a 1-D array of numerics.
        y (np.ndarray): Second solution. Should be the same shape as x.

    Returns:
        bool: True if x dominates y, false otherwise.
    """
    dom = False
    for i in range(len(x)):
        if x[i] > y[i]:
            return False
        if x[i] < y[i]:
            dom = True
    return dom

fast_non_dominated_sort

fast_non_dominated_sort(data: ndarray) -> np.ndarray

Conduct fast non-dominated sorting on a population of solutions.

Parameters:

Name Type Description Default
data ndarray

2-D array of solutions, with each row being a single solution.

required

Returns:

Type Description
ndarray

np.ndarray: n x f boolean array. n is the number of solutions, f is the number of fronts. The value of an array element is true if the corresponding solution id (column) belongs in the corresponding front (row).

Source code in desdeo/tools/non_dominated_sorting.py
@njit()
def fast_non_dominated_sort(data: np.ndarray) -> np.ndarray:
    """Conduct fast non-dominated sorting on a population of solutions.

    Args:
        data (np.ndarray): 2-D array of solutions, with each row being a single solution.

    Returns:
        np.ndarray: n x f boolean array. n is the number of solutions, f is the number of fronts.
            The value of an array element is true if the corresponding solution id (column) belongs in
            the corresponding front (row).
    """
    num_solutions = len(data)
    indices = np.arange(num_solutions)
    taken = np.zeros(num_solutions, dtype=np.bool_)
    fronts = np.zeros((num_solutions, num_solutions), dtype=np.bool_)

    for i in indices:
        current_front = non_dominated(data[~taken])

        current_front_all = np.zeros(num_solutions, dtype=np.bool_)
        current_front_all[~taken] = current_front
        fronts[i] = current_front_all

        taken = taken + fronts[i]
        if taken.all():
            # if all the solutions have been sorted, stop
            break

    return fronts[: i + 1]

fast_non_dominated_sort_indices

fast_non_dominated_sort_indices(
    data: ndarray,
) -> list[list[int]]

Conduct fast non-dominated sorting on a population of solutions.

This function returns identical results as fast_non_dominated_sort, but in a different format. This function returns an array of solution indices for each front, packed in a list.

Parameters:

Name Type Description Default
data ndarray

2-D array of solutions, with each row being a single solution.

required

Returns:

Type Description
list[list[int]]

list[list[int]]: A list with f elements where f is the number of fronts in the data, arranged in ascending order. Each element is a list of the indices of solutions belonging to the corresponding front.

Source code in desdeo/tools/non_dominated_sorting.py
def fast_non_dominated_sort_indices(data: np.ndarray) -> list[list[int]]:
    """Conduct fast non-dominated sorting on a population of solutions.

    This function returns identical results as `fast_non_dominated_sort`, but in a different format.
    This function returns an array of solution indices for each front, packed in a list.

    Args:
        data (np.ndarray): 2-D array of solutions, with each row being a single solution.

    Returns:
        list[list[int]]: A list with f elements where f is the number of fronts in the data,
            arranged in ascending order. Each element is a list of the indices of solutions
            belonging to the corresponding front.
    """
    fronts = fast_non_dominated_sort(data)
    return [np.where(fronts[i])[0].tolist() for i in range(len(fronts))]

non_dominated

non_dominated(data: ndarray) -> np.ndarray

Finds the non-dominated front from a population of solutions.

Parameters:

Name Type Description Default
data ndarray

2-D array of solutions, with each row being a single solution.

required

Returns:

Type Description
ndarray

np.ndarray: Boolean array of same length as number of solutions (rows). The value is true if corresponding solution is non-dominated. False otherwise

Source code in desdeo/tools/non_dominated_sorting.py
@njit()
def non_dominated(data: np.ndarray) -> np.ndarray:
    """Finds the non-dominated front from a population of solutions.

    Args:
        data (np.ndarray): 2-D array of solutions, with each row being a single solution.

    Returns:
        np.ndarray: Boolean array of same length as number of solutions (rows). The value is
            true if corresponding solution is non-dominated. False otherwise
    """
    num_solutions = len(data)
    index = np.zeros(num_solutions, dtype=np.bool_)
    index[0] = True
    for i in range(1, num_solutions):
        index[i] = True
        for j in range(i):
            if not index[j]:
                continue
            if dominates(data[i], data[j]):
                index[j] = False
            elif dominates(data[j], data[i]):
                index[i] = False
                break
    return index

non_dominated_merge

non_dominated_merge(
    set1: ndarray, set2: ndarray
) -> tuple[np.ndarray, np.ndarray]

Merge two sets of non-dominated solutions.

This is a slightly more efficient way to merge two sets of solutions such that the resulting set only contains non-dominated solutions from the two sets. This function assumes that the two sets already only contain non-dominated solutions. I.e., each solution in each set is non-dominated with respect to all other solutions in the same set. However, the solutions in the two sets may not be non-dominated with respect to each other.

Parameters:

Name Type Description Default
set1 ndarray

2-D array of solutions, with each row being a single solution.

required
set2 ndarray

2-D array of solutions, with each row being a single solution.

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: A tuple of two mask arrays. The first mask array is for set1 and the second mask array is for set2. The value of an element in the mask array is True if the corresponding solution is non-dominated in the merged set. False otherwise.

Source code in desdeo/tools/non_dominated_sorting.py
@njit()
def non_dominated_merge(set1: np.ndarray, set2: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Merge two sets of non-dominated solutions.

    This is a slightly more efficient way to merge two sets of solutions such that the resulting
    set only contains non-dominated solutions from the two sets. This function assumes that the
    two sets already only contain non-dominated solutions. I.e., each solution in each set is non-dominated
    with respect to all other solutions in the same set. However, the solutions in the two sets may not be
    non-dominated with respect to each other.

    Args:
        set1 (np.ndarray): 2-D array of solutions, with each row being a single solution.
        set2 (np.ndarray): 2-D array of solutions, with each row being a single solution.

    Returns:
        tuple[np.ndarray, np.ndarray]: A tuple of two mask arrays. The first mask array is for set1 and the
            second mask array is for set2. The value of an element in the mask array is True if the corresponding
            solution is non-dominated in the merged set. False otherwise.
    """
    # Masks to keep track of which solutions are non-dominated. Default is all True.
    set1_mask = np.ones(len(set1), dtype=np.bool_)
    set2_mask = np.ones(len(set2), dtype=np.bool_)

    for i in range(len(set1)):
        for j in range(len(set2)):
            if dominates(set1[i], set2[j]):
                set2_mask[j] = False
            elif dominates(set2[j], set1[i]):
                set1_mask[i] = False

    return set1_mask, set2_mask

Iterative Pareto representer

Implements the Iterative Pareto Representer algorithm.

_DSS_with_pruning

_DSS_with_pruning(
    available: ndarray, taken: ndarray
) -> int

One-liner implementation of the DSS algorithm using scipy.

Source code in desdeo/tools/iterative_pareto_representer.py
def _DSS_with_pruning(  # noqa: N802
    available: np.ndarray,
    taken: np.ndarray,
) -> int:
    """One-liner implementation of the DSS algorithm using scipy."""
    assert len(available) > 0, "No reference points available."  # noqa: S101

    assert np.allclose(  # noqa: S101
        available.sum(axis=1), available.shape[1]
    ), "Reference points must lie on plane perpendicular to ideal-nadir line."

    assert np.allclose(  # noqa: S101
        taken.sum(axis=1), taken.shape[1]
    ), "Reference points must lie on plane perpendicular to ideal-nadir line."

    if taken is None or len(taken) == 0:
        return np.random.choice(available)  # noqa: NPY002

    distances = cdist(available, taken, metric="chebyshev").min(axis=1)

    return available[np.argmax(distances)]

_find_bad_RPs

_find_bad_RPs(
    reference_points_array: ndarray,
    eval_results: list[_EvaluatedPoint],
    thickness: float = 0.02,
) -> np.ndarray

Find the reference points that will lead to repeated evaluations according to the ASF pruning rule.

Source code in desdeo/tools/iterative_pareto_representer.py
def _find_bad_RPs(  # noqa: N802
    reference_points_array: np.ndarray, eval_results: list[_EvaluatedPoint], thickness: float = 0.02
) -> np.ndarray:
    """Find the reference points that will lead to repeated evaluations according to the ASF pruning rule."""
    mask = np.zeros(reference_points_array.shape[0], dtype=bool)

    def dict_to_numpy(x):
        return np.array(list(x.values()))

    for eval_result in eval_results:
        bad_indices, _, _ = find_bad_indicesREF(
            dict_to_numpy(eval_result.targets),
            dict_to_numpy(eval_result.reference_point),
            reference_points_array,
            thickness,
        )
        bad_indices = np.where(bad_indices)[0]
        mask[bad_indices] = True
    return mask

_project

_project(solutions)

Project the solution to the reference plane defined by the reference_point and the normal vector.

Source code in desdeo/tools/iterative_pareto_representer.py
def _project(solutions):
    """Project the solution to the reference plane defined by the reference_point and the normal vector."""
    reference_point = np.ones(solutions.shape[1])
    normal = reference_point / np.linalg.norm(reference_point)
    perp_dist = np.atleast_2d(np.inner(solutions - reference_point, normal)).T
    return solutions - perp_dist * normal

choose_reference_point

choose_reference_point(
    refp_array: ndarray,
    evaluated_points: list[_EvaluatedPoint] | None = None,
)

Choose the next reference point to evaluate using the Iterative Pareto Representer algorithm.

Parameters:

Name Type Description Default
refp_array ndarray

The reference points to choose from.

required
evaluated_points list[_EvaluatedPoint]

Already evaluated reference points and their targets. If None, a random reference point is chosen.

None
Source code in desdeo/tools/iterative_pareto_representer.py
def choose_reference_point(
    refp_array: np.ndarray,
    evaluated_points: list[_EvaluatedPoint] | None = None,
):
    """Choose the next reference point to evaluate using the Iterative Pareto Representer algorithm.

    Args:
        refp_array (np.ndarray): The reference points to choose from.
        evaluated_points (list[_EvaluatedPoint]): Already evaluated reference points and their targets.
            If None, a random reference point is chosen.
    """
    if evaluated_points is None or len(evaluated_points) == 0:
        return refp_array[np.random.choice(refp_array.shape[0])], None  # noqa: NPY002
    bad_points_mask = _find_bad_RPs(refp_array, evaluated_points)
    available_points_mask = ~bad_points_mask
    solution_projections = _project(np.array([list(eval_result.targets.values()) for eval_result in evaluated_points]))
    return _DSS_with_pruning(
        available=refp_array[available_points_mask],
        taken=np.vstack((solution_projections, refp_array[bad_points_mask])),
    ), bad_points_mask

Intersection

Utility methods to check if reference vectors intersect a bounding box.

find_bad_indicesREF

find_bad_indicesREF(
    solution, ref_point, reference_points, thickness
)

Return reference points flagged as bad via line-box intersection, with the box limits.

Source code in desdeo/tools/intersection.py
def find_bad_indicesREF(solution, ref_point, reference_points, thickness):  # noqa: N802
    """Return reference points flagged as bad via line-box intersection, with the box limits."""
    box_max, box_min = find_bad_limits(solution, ref_point)
    bad_points = line_box_intersection(box_min, box_max, reference_points, thickness)
    return bad_points, box_min, box_max

find_bad_limits

find_bad_limits(solution, ref_point, threshold=0.05)

Return the (max, min) box corners used to flag bad reference points around a solution.

Source code in desdeo/tools/intersection.py
def find_bad_limits(solution, ref_point, threshold=0.05):
    """Return the (max, min) box corners used to flag bad reference points around a solution."""
    # Find projections of solution on the ref direction
    k = solution - ref_point
    box_max = ref_point + k.max()
    box_min = solution
    return box_max, box_min - threshold / 2

line_box_intersection

line_box_intersection(
    box_min: ndarray,
    box_max: ndarray,
    reference_points: ndarray,
    thickness,
) -> np.ndarray

Find the reference directions that intersect the box defined by box_min and box_max.

Parameters:

Name Type Description Default
box_min ndarray

The infimum of the box.

required
box_max ndarray

The supremum of the box.

required
reference_points ndarray

The reference directions.

required
thickness float

The threshold for thickness. Defines the thickness of the box. The thickness is added to the box_min and subtracted from the box_max to define the box. The reference directions that intersect the box are marked as bad. The thickness is a hyperparameter that needs to be tuned. The default value is 0.05. Try out some values close to 0.05. Lower values will result in lesser number of reference points being marked as bad. Note that a value of zero does not imply that only reference directions that directly intersect the box are marked as bad. Floating point shenanigans (np.isclose) happen.

required

Returns:

Type Description
ndarray

np.ndarray: A boolean array of length num_points, where True indicates that the reference direction intersects the box.

Source code in desdeo/tools/intersection.py
def line_box_intersection(
    box_min: np.ndarray, box_max: np.ndarray, reference_points: np.ndarray, thickness
) -> np.ndarray:
    """Find the reference directions that intersect the box defined by box_min and box_max.

    Args:
        box_min (np.ndarray): The infimum of the box.
        box_max (np.ndarray): The supremum of the box.
        reference_points (np.ndarray): The reference directions.
        thickness (float): The threshold for thickness. Defines the thickness of the box. The thickness is added to
            the box_min and subtracted from the box_max to define the box. The reference directions that intersect
            the box are marked as bad. The thickness is a hyperparameter that needs to be tuned. The default value
            is 0.05.
            Try out some values close to 0.05. Lower values
            will result in lesser number of reference points being marked as bad. Note that a value of zero does not
            imply that only reference directions that directly intersect the box are marked as bad. Floating point
            shenanigans (np.isclose) happen.

    Returns:
        np.ndarray: A boolean array of length num_points, where True indicates that the reference direction intersects
            the box.
    """
    # Find the reference directions that intersect the box

    # Vector through the reference point = reference point + k * (nadir - ideal)

    k_bmin = box_min - reference_points
    k_bmax = box_max - reference_points

    # Find the reference directions that intersect the box
    k_min = np.min((k_bmax, k_bmin), axis=0).max(axis=1) - thickness / 2
    k_max = np.max((k_bmax, k_bmin), axis=0).min(axis=1) + thickness / 2

    return np.logical_or((k_max >= k_min), np.isclose(k_max, k_min))

Interaction schema

The schema to represent the interactions of the user.

Interaction

Bases: BaseModel

The tree-like structure to represent the interactions of the user.

Source code in desdeo/tools/interaction_schema.py
class Interaction(BaseModel):
    """The tree-like structure to represent the interactions of the user."""

    method_name: str = Field(description="The name of the method used for this iteration.")
    preference_information: dict = Field(description="The preference information given by the user for this iteration.")
    result: dict = Field(
        description="The result of the iteration."
    )  # In the database, this should be a foreign key to the result table
    next_interaction: list["Interaction"] = Field(
        description="The next interaction in the tree. This is a list of 'interactions' as "
        "the user can choose to go back to a previous iteration and change "
        "the preference information. If the user chooses to go back, just "
        "append the new interactions to the list in the order they were made."
    )  # In the database, this should be a foreign key to the interactions table

Visualizations

Provides some visualization functions.

scatter_plot_comparison

scatter_plot_comparison(
    *results: Any,
    x_key: str = "f_1",
    y_key: str = "f_2",
    z_key: str | None = None,
    reference_point: dict[str, float] | None = None,
    names: list[str] | None = None,
) -> go.Figure

Plots multiple sets of data in a scatter plot (2D or 3D depending on the data).

Parameters:

Name Type Description Default
*results Any

Variable number of result objects, each containing outputs with keys for x, y, and optionally z.

()
x_key str

Key for the x-axis data in the outputs.

'f_1'
y_key str

Key for the y-axis data in the outputs.

'f_2'
z_key str | None

Key for the z-axis data in the outputs (optional, for 3D plots).

None
reference_point dict[str, float] | None

A dictionary with keys matching x_key, y_key, and optionally z_key, representing the reference point to plot.

None
names list[str] | None

List of names for each data set to display in the legend.

None

Returns:

Type Description
Figure

go.Figure: A Plotly Figure containing the scatter plot.

Source code in desdeo/tools/visualizations.py
def scatter_plot_comparison(
    *results: Any,
    x_key: str = "f_1",
    y_key: str = "f_2",
    z_key: str | None = None,
    reference_point: dict[str, float] | None = None,
    names: list[str] | None = None,
) -> go.Figure:
    """Plots multiple sets of data in a scatter plot (2D or 3D depending on the data).

    Arguments:
        *results: Variable number of result objects, each containing outputs with keys for x, y, and optionally z.
        x_key: Key for the x-axis data in the outputs.
        y_key: Key for the y-axis data in the outputs.
        z_key: Key for the z-axis data in the outputs (optional, for 3D plots).
        reference_point: A dictionary with keys matching x_key, y_key, and optionally z_key,
            representing the reference point to plot.
        names: List of names for each data set to display in the legend.

    Returns:
        go.Figure: A Plotly Figure containing the scatter plot.
    """
    traces = []
    is_3d = z_key is not None and all(z_key in result.outputs for result in results)

    if names is None:
        names = [f"Dataset {i + 1}" for i in range(len(results))]

    for i, (result, name) in enumerate(zip(results, names, strict=False)):
        color = f"hsl({i * 360 / len(results)}, 100%, 50%)"  # Generate distinct colors
        if is_3d:
            trace = go.Scatter3d(
                x=result.outputs[x_key],
                y=result.outputs[y_key],
                z=result.outputs[z_key],
                mode="markers",
                marker={"size": 4, "color": color, "symbol": "circle"},
                name=name,
            )
        else:
            trace = go.Scatter(
                x=result.outputs[x_key],
                y=result.outputs[y_key],
                mode="markers",
                marker={"size": 8, "color": color, "symbol": "circle"},
                name=name,
            )
        traces.append(trace)

    if reference_point:
        if is_3d and z_key:
            ref_trace = go.Scatter3d(
                x=[reference_point[x_key]],
                y=[reference_point[y_key]],
                z=[reference_point[z_key]],
                mode="markers",
                marker={"size": 4, "color": "black", "symbol": "circle"},
                name="Reference Point",
            )
        else:
            ref_trace = go.Scatter(
                x=[reference_point[x_key]],
                y=[reference_point[y_key]],
                mode="markers",
                marker={"size": 8, "color": "black", "symbol": "circle"},
                name="Reference Point",
            )
        traces.append(ref_trace)

    return go.Figure(data=traces)

Publisher-Subscriber pattern

This module contains the classes for the publisher-subscriber (ish) pattern.

The pattern is used in the evolutionary algorithms to send messages between the different components. This allows the components to be decoupled and the messages to be sent between them without the components knowing about each other. The pattern closely resembles the publisher-subscriber pattern, with one key difference. The subscribers can also create messages and send them to the publisher, which then forwards the messages to the other subscribers.

The pattern is implemented with two classes: Subscriber and Publisher. The Subscriber class is an abstract class that must be inherited by the classes that want to receive (or send) messages. All evolutionary operators must inherit the Subscriber class. Some objects that may be interested in the messages, but otherwise unrelated to the evolutionary operators, may also inherit the Subscriber class. Examples of such objects are a logging class, an archive class, or a class that visualizes intermediate results.

The Publisher class is a class that stores the subscribers and forwards the messages to them. The Publisher class is not connected to the evolutionary algorithms and only serves as a message router. As mentioned earlier, the components do not know about each other, and the Publisher class is the only class that knows about all the connections in between components. The user of the evolutionary algorithms is responsible for creating the connections. However, the implementations of the operators do provide default, so called topics that the operator must subscribe to.

The way the pattern works is as follows. Each operator has a do method which is called by the evolutionary algorithm when the operator is to be executed. This method has some default arguments, depending upon the class of the operator. E.g., the do method of the mutation related classes may have a default arguments as offsprings and parents, where each is a tuple of decision variables, objectives, and constraints. However, some special mutation operator may require additional inputs. E.g., an adaptive mutation operator may require the current generation number as an input. To provide this additional input, we do not change the signature of the do method.

Instead, we let the mutation operator subscribe to a topic called, e.g., current_generation. The publisher, is then responsible for sending the current generation number to the mutation operator, whenever the generation number changes. The mutation operator can then update its internal state based on the received generation number.

To be able to send this information, the Publisher class has a method called notify. Operators can call this method to send messages to the subscribers. The idea is to do this at the end of the do method. That way, whenever any operator is executed, it can send messages to the other operators (which have subscribed to the topics).

Note that the operators do not know about the other operators. The subscribers do not know the origin of the messages. This decoupling allows for a more modular design and easier extensibility of the evolutionary algorithms.

Publisher

Class for a publisher that sends messages to subscribers.

The publisher is unconnected from the evolutionary algorithms and only serves as a message router. The subscribers can subscribe to different message keys and receive messages when the publisher receives a message with the corresponding key.

Source code in desdeo/tools/patterns.py
class Publisher:
    """Class for a publisher that sends messages to subscribers.

    The publisher is unconnected from the evolutionary algorithms and only serves as a message router. The subscribers
    can subscribe to different message keys and receive messages when the publisher receives a message with the
    corresponding key.
    """

    def __init__(self) -> None:
        """Initialize a blank publisher."""
        self.subscribers: dict[MessageTopics, list[Subscriber]] = {}
        self.global_subscribers: list[Subscriber] = []
        self.registered_topics: dict[MessageTopics, list[str]] = {}

    def subscribe(self, subscriber: Subscriber, topic: MessageTopics | Literal["ALL"]) -> None:
        """Store a subscriber for a given message key.

        Whenever the publisher receives a message with the given key, it will notify the subscriber. This method can
        be used to subscribe to multiple topics by calling it multiple times. Moreover, the user can force the
        subscriber to receive all messages by setting the topic to "ALL".

        Args:
            subscriber (Subscriber): the subscriber to notify.
            topic (str): the message topic (key in message dictionary) to subscribe to.
                If "ALL", the subscriber is notified of all messages.
        """
        if topic == "ALL":
            self.global_subscribers.append(subscriber)
            return
        if topic not in self.subscribers:
            self.subscribers[topic] = []
        self.subscribers[topic].append(subscriber)

    def auto_subscribe(self, subscriber: Subscriber) -> None:
        """Store a subscriber for multiple message keys. The subscriber must have the topics attribute.

        Whenever the publisher receives a message with the given key, it will notify the subscriber.

        Args:
            subscriber (Subscriber): the subscriber to notify.
        """
        for topic in subscriber.interested_topics:
            self.subscribe(subscriber, topic)

    def unsubscribe(self, subscriber: Subscriber, topic: MessageTopics | Literal["ALL"]) -> None:
        """Remove a subscriber from a given message key.

        Args:
            subscriber (Subscriber): the subscriber to remove.
            topic (str): the key of the message to unsubscribe from.
        """
        if topic == "ALL":
            self.global_subscribers.remove(subscriber)
            return
        if topic in self.subscribers:
            self.subscribers[topic].remove(subscriber)

    def unsubscribe_multiple(self, subscriber: Subscriber, topics: Sequence[MessageTopics | Literal["ALL"]]) -> None:
        """Remove a subscriber from multiple message keys.

        Args:
            subscriber (Subscriber): the subscriber to remove.
            topics (list[str]): the keys of the messages to unsubscribe from.
        """
        for topic in topics:
            self.unsubscribe(subscriber, topic)

    def force_unsubscribe(self, subscriber: Subscriber) -> None:
        """Remove a subscriber from all message keys.

        Args:
            subscriber (Subscriber): the subscriber to remove.
        """
        for topic in self.subscribers:
            if subscriber in self.subscribers[topic]:
                self.subscribers[topic].remove(subscriber)

    def register_topics(self, topics: list[MessageTopics], source: str) -> None:
        """Register topics provided to the publisher.

        Args:
            topics (list[MessageTopics]): the topics to register.
            source (str): the source of the topics.
        """
        for topic in topics:
            if topic not in self.registered_topics:
                self.registered_topics[topic] = [source]
            else:
                self.registered_topics[topic].append(source)

    def check_consistency(self) -> tuple[bool, dict[MessageTopics, list[str]]]:
        """Check if all subscribed topics have also been registered by a source.

        Returns:
            tuple[bool, dict[MessageTopics, list[str]]]: Returns a tuple. The first element is a bool. True if all
                subscribed topics have been registered by a source. False otherwise. The second element is a dictionary
                of unregistered topics that have been subscribed to.
        """
        unregistered_topics = {}
        for topic in self.subscribers:
            if topic not in self.registered_topics:
                unregistered_topics[topic] = [x.__class__.__name__ for x in self.subscribers[topic]]
        if unregistered_topics:
            return False, unregistered_topics
        return True, {}

    def relationship_map(self):
        """Make a diagram connecting sources to subscribers based on topics."""
        relationships = {}
        for topic in self.subscribers:
            for subscriber in self.subscribers[topic]:
                if topic.value not in relationships:
                    relationships[topic.value] = [(subscriber.__class__.__name__, self.registered_topics[topic])]
                else:
                    relationships[topic.value].append((subscriber.__class__.__name__, self.registered_topics[topic]))
        return relationships

    def notify(self, messages: Sequence[Message] | None) -> None:
        """Notify subscribers of the received message/messages.

        Args:
            messages (Sequence[BaseMessage]): the messages to send to the subscribers. Each message is a pydantic model
                with a topic, value, and a source.
        """
        if messages is None:
            return
        for message in messages:
            # Notify global subscribers
            for subscriber in self.global_subscribers:
                subscriber.update(message)
            # Notify subscribers of the given key
            if message.topic in self.subscribers:
                for subscriber in self.subscribers[message.topic]:
                    subscriber.update(message)

__init__

__init__() -> None

Initialize a blank publisher.

Source code in desdeo/tools/patterns.py
def __init__(self) -> None:
    """Initialize a blank publisher."""
    self.subscribers: dict[MessageTopics, list[Subscriber]] = {}
    self.global_subscribers: list[Subscriber] = []
    self.registered_topics: dict[MessageTopics, list[str]] = {}

auto_subscribe

auto_subscribe(subscriber: Subscriber) -> None

Store a subscriber for multiple message keys. The subscriber must have the topics attribute.

Whenever the publisher receives a message with the given key, it will notify the subscriber.

Parameters:

Name Type Description Default
subscriber Subscriber

the subscriber to notify.

required
Source code in desdeo/tools/patterns.py
def auto_subscribe(self, subscriber: Subscriber) -> None:
    """Store a subscriber for multiple message keys. The subscriber must have the topics attribute.

    Whenever the publisher receives a message with the given key, it will notify the subscriber.

    Args:
        subscriber (Subscriber): the subscriber to notify.
    """
    for topic in subscriber.interested_topics:
        self.subscribe(subscriber, topic)

check_consistency

check_consistency() -> tuple[
    bool, dict[MessageTopics, list[str]]
]

Check if all subscribed topics have also been registered by a source.

Returns:

Type Description
tuple[bool, dict[MessageTopics, list[str]]]

tuple[bool, dict[MessageTopics, list[str]]]: Returns a tuple. The first element is a bool. True if all subscribed topics have been registered by a source. False otherwise. The second element is a dictionary of unregistered topics that have been subscribed to.

Source code in desdeo/tools/patterns.py
def check_consistency(self) -> tuple[bool, dict[MessageTopics, list[str]]]:
    """Check if all subscribed topics have also been registered by a source.

    Returns:
        tuple[bool, dict[MessageTopics, list[str]]]: Returns a tuple. The first element is a bool. True if all
            subscribed topics have been registered by a source. False otherwise. The second element is a dictionary
            of unregistered topics that have been subscribed to.
    """
    unregistered_topics = {}
    for topic in self.subscribers:
        if topic not in self.registered_topics:
            unregistered_topics[topic] = [x.__class__.__name__ for x in self.subscribers[topic]]
    if unregistered_topics:
        return False, unregistered_topics
    return True, {}

force_unsubscribe

force_unsubscribe(subscriber: Subscriber) -> None

Remove a subscriber from all message keys.

Parameters:

Name Type Description Default
subscriber Subscriber

the subscriber to remove.

required
Source code in desdeo/tools/patterns.py
def force_unsubscribe(self, subscriber: Subscriber) -> None:
    """Remove a subscriber from all message keys.

    Args:
        subscriber (Subscriber): the subscriber to remove.
    """
    for topic in self.subscribers:
        if subscriber in self.subscribers[topic]:
            self.subscribers[topic].remove(subscriber)

notify

notify(messages: Sequence[Message] | None) -> None

Notify subscribers of the received message/messages.

Parameters:

Name Type Description Default
messages Sequence[BaseMessage]

the messages to send to the subscribers. Each message is a pydantic model with a topic, value, and a source.

required
Source code in desdeo/tools/patterns.py
def notify(self, messages: Sequence[Message] | None) -> None:
    """Notify subscribers of the received message/messages.

    Args:
        messages (Sequence[BaseMessage]): the messages to send to the subscribers. Each message is a pydantic model
            with a topic, value, and a source.
    """
    if messages is None:
        return
    for message in messages:
        # Notify global subscribers
        for subscriber in self.global_subscribers:
            subscriber.update(message)
        # Notify subscribers of the given key
        if message.topic in self.subscribers:
            for subscriber in self.subscribers[message.topic]:
                subscriber.update(message)

register_topics

register_topics(
    topics: list[MessageTopics], source: str
) -> None

Register topics provided to the publisher.

Parameters:

Name Type Description Default
topics list[MessageTopics]

the topics to register.

required
source str

the source of the topics.

required
Source code in desdeo/tools/patterns.py
def register_topics(self, topics: list[MessageTopics], source: str) -> None:
    """Register topics provided to the publisher.

    Args:
        topics (list[MessageTopics]): the topics to register.
        source (str): the source of the topics.
    """
    for topic in topics:
        if topic not in self.registered_topics:
            self.registered_topics[topic] = [source]
        else:
            self.registered_topics[topic].append(source)

relationship_map

relationship_map()

Make a diagram connecting sources to subscribers based on topics.

Source code in desdeo/tools/patterns.py
def relationship_map(self):
    """Make a diagram connecting sources to subscribers based on topics."""
    relationships = {}
    for topic in self.subscribers:
        for subscriber in self.subscribers[topic]:
            if topic.value not in relationships:
                relationships[topic.value] = [(subscriber.__class__.__name__, self.registered_topics[topic])]
            else:
                relationships[topic.value].append((subscriber.__class__.__name__, self.registered_topics[topic]))
    return relationships

subscribe

subscribe(
    subscriber: Subscriber,
    topic: MessageTopics | Literal["ALL"],
) -> None

Store a subscriber for a given message key.

Whenever the publisher receives a message with the given key, it will notify the subscriber. This method can be used to subscribe to multiple topics by calling it multiple times. Moreover, the user can force the subscriber to receive all messages by setting the topic to "ALL".

Parameters:

Name Type Description Default
subscriber Subscriber

the subscriber to notify.

required
topic str

the message topic (key in message dictionary) to subscribe to. If "ALL", the subscriber is notified of all messages.

required
Source code in desdeo/tools/patterns.py
def subscribe(self, subscriber: Subscriber, topic: MessageTopics | Literal["ALL"]) -> None:
    """Store a subscriber for a given message key.

    Whenever the publisher receives a message with the given key, it will notify the subscriber. This method can
    be used to subscribe to multiple topics by calling it multiple times. Moreover, the user can force the
    subscriber to receive all messages by setting the topic to "ALL".

    Args:
        subscriber (Subscriber): the subscriber to notify.
        topic (str): the message topic (key in message dictionary) to subscribe to.
            If "ALL", the subscriber is notified of all messages.
    """
    if topic == "ALL":
        self.global_subscribers.append(subscriber)
        return
    if topic not in self.subscribers:
        self.subscribers[topic] = []
    self.subscribers[topic].append(subscriber)

unsubscribe

unsubscribe(
    subscriber: Subscriber,
    topic: MessageTopics | Literal["ALL"],
) -> None

Remove a subscriber from a given message key.

Parameters:

Name Type Description Default
subscriber Subscriber

the subscriber to remove.

required
topic str

the key of the message to unsubscribe from.

required
Source code in desdeo/tools/patterns.py
def unsubscribe(self, subscriber: Subscriber, topic: MessageTopics | Literal["ALL"]) -> None:
    """Remove a subscriber from a given message key.

    Args:
        subscriber (Subscriber): the subscriber to remove.
        topic (str): the key of the message to unsubscribe from.
    """
    if topic == "ALL":
        self.global_subscribers.remove(subscriber)
        return
    if topic in self.subscribers:
        self.subscribers[topic].remove(subscriber)

unsubscribe_multiple

unsubscribe_multiple(
    subscriber: Subscriber,
    topics: Sequence[MessageTopics | Literal["ALL"]],
) -> None

Remove a subscriber from multiple message keys.

Parameters:

Name Type Description Default
subscriber Subscriber

the subscriber to remove.

required
topics list[str]

the keys of the messages to unsubscribe from.

required
Source code in desdeo/tools/patterns.py
def unsubscribe_multiple(self, subscriber: Subscriber, topics: Sequence[MessageTopics | Literal["ALL"]]) -> None:
    """Remove a subscriber from multiple message keys.

    Args:
        subscriber (Subscriber): the subscriber to remove.
        topics (list[str]): the keys of the messages to unsubscribe from.
    """
    for topic in topics:
        self.unsubscribe(subscriber, topic)

Subscriber

Bases: ABC

Base class for both subscriber and message sender.

These are used in the evolutionary algorithms to send messages between the different components. The pattern closely resembles the publisher-subscriber pattern, with one key difference. The subscribers can also create messages and send them to the publisher, which then forwards the messages to the other subscribers.

Source code in desdeo/tools/patterns.py
class Subscriber(ABC):
    """Base class for both subscriber and message sender.

    These are used in the evolutionary algorithms to send messages between the different components. The pattern
    closely resembles the publisher-subscriber pattern, with one key difference. The subscribers can also create
    messages and send them to the publisher, which then forwards the messages to the other subscribers.
    """

    @property
    @abstractmethod
    def interested_topics(self) -> Sequence[MessageTopics]:
        """Return the topics the subscriber is interested in."""

    @property
    @abstractmethod
    def provided_topics(self) -> dict[int, Sequence[MessageTopics]]:
        """Return the topics the subscriber provides to the publisher, grouped by verbosity level."""

    def __init__(
        self,
        publisher: "Publisher",
        verbosity: int,
    ) -> None:
        """Initialize a subscriber.

        Args:
            publisher (Callable): the publisher to send messages to.
            verbosity (int, optional): the verbosity level of the messages. A value of 0 means no messages at all.
        """
        if not isinstance(verbosity, int):
            raise TypeError("Verbosity must be an integer.")
        if verbosity < 0:
            raise ValueError("Verbosity must be a non-negative integer.")
        self.publisher = publisher
        self.verbosity: int = verbosity

    def notify(self) -> None:
        """Notify the publisher of changes in the subject.

        The contents of the message (a dictionary) are defined in the `state` method. The `state` method can return
        different messages depending on the verbosity level.
        """
        if self.verbosity not in AllowedMessagesAtVerbosity:
            raise ValueError(f"Verbosity level {self.verbosity} is not allowed.")
        if self.verbosity == 0:
            return

        state = self.state()
        if all(isinstance(x, AllowedMessagesAtVerbosity[self.verbosity]) for x in state):
            self.publisher.notify(messages=state)

    @abstractmethod
    def update(self, message: Message) -> None:
        """Update self as a result of messages from the publisher.

        Args:
            message (Message): the message from the publisher. Note that each message is a pydantic model with a topic,
                value, and a source.
        """

    @abstractmethod
    def state(self) -> Sequence[Message]:
        """Return the state of the subject. This is the list of messages to send to the publisher."""

interested_topics abstractmethod property

interested_topics: Sequence[MessageTopics]

Return the topics the subscriber is interested in.

provided_topics abstractmethod property

provided_topics: dict[int, Sequence[MessageTopics]]

Return the topics the subscriber provides to the publisher, grouped by verbosity level.

__init__

__init__(publisher: Publisher, verbosity: int) -> None

Initialize a subscriber.

Parameters:

Name Type Description Default
publisher Callable

the publisher to send messages to.

required
verbosity int

the verbosity level of the messages. A value of 0 means no messages at all.

required
Source code in desdeo/tools/patterns.py
def __init__(
    self,
    publisher: "Publisher",
    verbosity: int,
) -> None:
    """Initialize a subscriber.

    Args:
        publisher (Callable): the publisher to send messages to.
        verbosity (int, optional): the verbosity level of the messages. A value of 0 means no messages at all.
    """
    if not isinstance(verbosity, int):
        raise TypeError("Verbosity must be an integer.")
    if verbosity < 0:
        raise ValueError("Verbosity must be a non-negative integer.")
    self.publisher = publisher
    self.verbosity: int = verbosity

notify

notify() -> None

Notify the publisher of changes in the subject.

The contents of the message (a dictionary) are defined in the state method. The state method can return different messages depending on the verbosity level.

Source code in desdeo/tools/patterns.py
def notify(self) -> None:
    """Notify the publisher of changes in the subject.

    The contents of the message (a dictionary) are defined in the `state` method. The `state` method can return
    different messages depending on the verbosity level.
    """
    if self.verbosity not in AllowedMessagesAtVerbosity:
        raise ValueError(f"Verbosity level {self.verbosity} is not allowed.")
    if self.verbosity == 0:
        return

    state = self.state()
    if all(isinstance(x, AllowedMessagesAtVerbosity[self.verbosity]) for x in state):
        self.publisher.notify(messages=state)

state abstractmethod

state() -> Sequence[Message]

Return the state of the subject. This is the list of messages to send to the publisher.

Source code in desdeo/tools/patterns.py
@abstractmethod
def state(self) -> Sequence[Message]:
    """Return the state of the subject. This is the list of messages to send to the publisher."""

update abstractmethod

update(message: Message) -> None

Update self as a result of messages from the publisher.

Parameters:

Name Type Description Default
message Message

the message from the publisher. Note that each message is a pydantic model with a topic, value, and a source.

required
Source code in desdeo/tools/patterns.py
@abstractmethod
def update(self, message: Message) -> None:
    """Update self as a result of messages from the publisher.

    Args:
        message (Message): the message from the publisher. Note that each message is a pydantic model with a topic,
            value, and a source.
    """

createblanksubs

createblanksubs(
    interested_topics: Sequence[MessageTopics],
) -> type[Subscriber]

Create a blank subscriber for testing purposes.

Parameters:

Name Type Description Default
interested_topics list[MessageTopics]

the topics the subscriber is interested in.

required

Returns:

Type Description
type[Subscriber]

type[Subscriber]: the blank subscriber class.

Source code in desdeo/tools/patterns.py
def createblanksubs(interested_topics: Sequence[MessageTopics]) -> type["Subscriber"]:
    """Create a blank subscriber for testing purposes.

    Args:
        interested_topics (list[MessageTopics]): the topics the subscriber is interested in.

    Returns:
        type[Subscriber]: the blank subscriber class.
    """

    class BlankSubscriber(Subscriber):
        """A simple subscriber for testing purposes."""

        @property
        def interested_topics(self) -> Sequence[MessageTopics]:
            """Return the topics the subscriber is interested in."""
            return interested_topics

        @property
        def provided_topics(self) -> dict[int, Sequence[MessageTopics]]:
            """Return the topics the subscriber provides to the publisher, grouped by verbosity level."""
            return {0: []}

        def __init__(self, publisher: "Publisher", verbosity: int = 0) -> None:
            """Initialize a subscriber."""
            super().__init__(publisher, verbosity)
            self.messages_to_send: list[Message] = []
            self.messages_received: list[Message] = []

        def update(self, message: Message) -> None:
            """Update the internal state of the subscriber."""
            self.messages_received.append(message)

        def state(self) -> list[Message]:
            """Return the internal state of the subscriber."""
            return self.messages_to_send

    return BlankSubscriber

Message Topics

Defines the messaging protocol used by the various EMO operators.

Array2DMessage

Bases: BaseMessage

A message containing a 2D array value, such as a population or a set of objectives.

Source code in desdeo/tools/message.py
class Array2DMessage(BaseMessage):
    """A message containing a 2D array value, such as a population or a set of objectives."""

    value: list[list[float]] = Field(..., description="The array value of the message.")
    """ The array value of the message. """

value class-attribute instance-attribute

value: list[list[float]] = Field(
    ..., description="The array value of the message."
)

The array value of the message.

BaseMessage

Bases: BaseModel

A message containing an integer value.

Source code in desdeo/tools/message.py
class BaseMessage(BaseModel):
    """A message containing an integer value."""

    topic: MessageTopics = Field(..., description="The topic of the message.")
    """ The topic of the message. """
    source: str = Field(..., description="The source of the message.")
    """ The source of the message. """

source class-attribute instance-attribute

source: str = Field(
    ..., description="The source of the message."
)

The source of the message.

topic class-attribute instance-attribute

topic: MessageTopics = Field(
    ..., description="The topic of the message."
)

The topic of the message.

BoolMessage

Bases: BaseMessage

A message containing a boolean value.

Source code in desdeo/tools/message.py
class BoolMessage(BaseMessage):
    """A message containing a boolean value."""

    value: bool = Field(..., description="The boolean value of the message.")
    """ The boolean value of the message. """

value class-attribute instance-attribute

value: bool = Field(
    ..., description="The boolean value of the message."
)

The boolean value of the message.

CrossoverMessageTopics

Bases: Enum

Topics for messages related to crossover operators.

Source code in desdeo/tools/message.py
class CrossoverMessageTopics(Enum):
    """Topics for messages related to crossover operators."""

    TEST = "TEST"
    """ A message topic used only for testing the crossover operators. """
    XOVER_PROBABILITY = "XOVER_PROBABILITY"
    """ The current crossover probability. """
    XOVER_DISTRIBUTION = "XOVER_DISTRIBUTION"
    """ The current crossover distribution index. Primary used in the SBX crossover. """
    PARENTS = "PARENTS"
    """ The parents selected for crossover. """
    OFFSPRINGS = "OFFSPRINGS"
    """ The offsprings generated from the crossover. """
    ALPHA = "ALPHA"
    """ Alpha parameter used in crossover. """
    LAMBDA = "LAMBDA"
    """ Lambda parameter used in crossover. Primarily used in the bounded exponential xover. """

ALPHA class-attribute instance-attribute

ALPHA = 'ALPHA'

Alpha parameter used in crossover.

LAMBDA class-attribute instance-attribute

LAMBDA = 'LAMBDA'

Lambda parameter used in crossover. Primarily used in the bounded exponential xover.

OFFSPRINGS class-attribute instance-attribute

OFFSPRINGS = 'OFFSPRINGS'

The offsprings generated from the crossover.

PARENTS class-attribute instance-attribute

PARENTS = 'PARENTS'

The parents selected for crossover.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the crossover operators.

XOVER_DISTRIBUTION class-attribute instance-attribute

XOVER_DISTRIBUTION = 'XOVER_DISTRIBUTION'

The current crossover distribution index. Primary used in the SBX crossover.

XOVER_PROBABILITY class-attribute instance-attribute

XOVER_PROBABILITY = 'XOVER_PROBABILITY'

The current crossover probability.

DictMessage

Bases: BaseMessage

A message containing a dictionary value.

Source code in desdeo/tools/message.py
class DictMessage(BaseMessage):
    """A message containing a dictionary value."""

    value: dict[str, Any] = Field(..., description="The dictionary value of the message.")
    """ The dictionary value of the message. """

value class-attribute instance-attribute

value: dict[str, Any] = Field(
    ..., description="The dictionary value of the message."
)

The dictionary value of the message.

EvaluatorMessageTopics

Bases: Enum

Topics for messages related to evaluator operators.

Source code in desdeo/tools/message.py
class EvaluatorMessageTopics(Enum):
    """Topics for messages related to evaluator operators."""

    TEST = "TEST"
    """ A message topic used only for testing the evaluator operators. """
    POPULATION = "POPULATION"
    """ The population to evaluate. """
    OUTPUTS = "OUTPUTS"
    """ The outputs of the population. Contains objectives, targets, constraints. """
    OBJECTIVES = "OBJECTIVES"
    """ The true objective values of the population. """
    TARGETS = "TARGETS"
    """ The targets, i.e., objective values seen by the evolutionary operators."""
    CONSTRAINTS = "CONSTRAINTS"
    """ The constraints of the population. """
    VERBOSE_OUTPUTS = "VERBOSE_OUTPUTS"
    """ Same as POPULATION + OUTPUTS."""
    NEW_EVALUATIONS = "NEW_EVALUATIONS"
    """ The number of new evaluations. """

CONSTRAINTS class-attribute instance-attribute

CONSTRAINTS = 'CONSTRAINTS'

The constraints of the population.

NEW_EVALUATIONS class-attribute instance-attribute

NEW_EVALUATIONS = 'NEW_EVALUATIONS'

The number of new evaluations.

OBJECTIVES class-attribute instance-attribute

OBJECTIVES = 'OBJECTIVES'

The true objective values of the population.

OUTPUTS class-attribute instance-attribute

OUTPUTS = 'OUTPUTS'

The outputs of the population. Contains objectives, targets, constraints.

POPULATION class-attribute instance-attribute

POPULATION = 'POPULATION'

The population to evaluate.

TARGETS class-attribute instance-attribute

TARGETS = 'TARGETS'

The targets, i.e., objective values seen by the evolutionary operators.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the evaluator operators.

VERBOSE_OUTPUTS class-attribute instance-attribute

VERBOSE_OUTPUTS = 'VERBOSE_OUTPUTS'

Same as POPULATION + OUTPUTS.

FloatMessage

Bases: BaseMessage

A message containing a float value.

Source code in desdeo/tools/message.py
class FloatMessage(BaseMessage):
    """A message containing a float value."""

    value: float = Field(..., description="The float value of the message.")
    """ The float value of the message. """

value class-attribute instance-attribute

value: float = Field(
    ..., description="The float value of the message."
)

The float value of the message.

GeneratorMessageTopics

Bases: Enum

Topics for messages related to population generator operators.

Source code in desdeo/tools/message.py
class GeneratorMessageTopics(Enum):
    """Topics for messages related to population generator operators."""

    TEST = "TEST"
    """ A message topic used only for testing the evaluator operators. """
    POPULATION = "POPULATION"
    """ The population to evaluate. """
    OUTPUTS = "OUTPUTS"
    """ The outputs of the population generation. Contains objectives, targets, and constraints. """
    OBJECTIVES = "OBJECTIVES"
    """ The true objective values of the population. """
    TARGETS = "TARGETS"
    """ The targets, i.e., objective values seen by the evolutionary operators."""
    CONSTRAINTS = "CONSTRAINTS"
    """ The constraints of the population. """
    VERBOSE_OUTPUTS = "VERBOSE_OUTPUTS"
    """ Same as POPULATION + OUTPUTS. """
    NEW_EVALUATIONS = "NEW_EVALUATIONS"
    """ The number of new evaluations. """

CONSTRAINTS class-attribute instance-attribute

CONSTRAINTS = 'CONSTRAINTS'

The constraints of the population.

NEW_EVALUATIONS class-attribute instance-attribute

NEW_EVALUATIONS = 'NEW_EVALUATIONS'

The number of new evaluations.

OBJECTIVES class-attribute instance-attribute

OBJECTIVES = 'OBJECTIVES'

The true objective values of the population.

OUTPUTS class-attribute instance-attribute

OUTPUTS = 'OUTPUTS'

The outputs of the population generation. Contains objectives, targets, and constraints.

POPULATION class-attribute instance-attribute

POPULATION = 'POPULATION'

The population to evaluate.

TARGETS class-attribute instance-attribute

TARGETS = 'TARGETS'

The targets, i.e., objective values seen by the evolutionary operators.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the evaluator operators.

VERBOSE_OUTPUTS class-attribute instance-attribute

VERBOSE_OUTPUTS = 'VERBOSE_OUTPUTS'

Same as POPULATION + OUTPUTS.

GenericMessage

Bases: BaseMessage

A message containing a generic value.

Source code in desdeo/tools/message.py
class GenericMessage(BaseMessage):
    """A message containing a generic value."""

    value: Any = Field(..., description="The generic value of the message.")
    """ The generic value of the message. """

value class-attribute instance-attribute

value: Any = Field(
    ..., description="The generic value of the message."
)

The generic value of the message.

IntMessage

Bases: BaseMessage

A message containing an integer value.

Source code in desdeo/tools/message.py
class IntMessage(BaseMessage):
    """A message containing an integer value."""

    value: int = Field(..., description="The integer value of the message.")
    """ The integer value of the message. """

value class-attribute instance-attribute

value: int = Field(
    ..., description="The integer value of the message."
)

The integer value of the message.

MutationMessageTopics

Bases: Enum

Topics for messages related to mutation operators.

Source code in desdeo/tools/message.py
class MutationMessageTopics(Enum):
    """Topics for messages related to mutation operators."""

    TEST = "TEST"
    """ A message topic used only for testing the mutation operators. """
    MUTATION_PROBABILITY = "MUTATION_PROBABILITY"
    """ The current mutation probability. """
    MUTATION_DISTRIBUTION = "MUTATION_DISTRIBUTION"
    """ The current mutation distribution index. Primary used in the polynomial mutation. """
    OFFSPRING_ORIGINAL = "OFFSPRING_ORIGINAL"
    """ The original offsprings before mutation. """
    OFFSPRINGS = "OFFSPRINGS"
    """ The offsprings after mutation. """
    PARENTS = "PARENTS"
    """ The parents of the offsprings. """

MUTATION_DISTRIBUTION class-attribute instance-attribute

MUTATION_DISTRIBUTION = 'MUTATION_DISTRIBUTION'

The current mutation distribution index. Primary used in the polynomial mutation.

MUTATION_PROBABILITY class-attribute instance-attribute

MUTATION_PROBABILITY = 'MUTATION_PROBABILITY'

The current mutation probability.

OFFSPRINGS class-attribute instance-attribute

OFFSPRINGS = 'OFFSPRINGS'

The offsprings after mutation.

OFFSPRING_ORIGINAL class-attribute instance-attribute

OFFSPRING_ORIGINAL = 'OFFSPRING_ORIGINAL'

The original offsprings before mutation.

PARENTS class-attribute instance-attribute

PARENTS = 'PARENTS'

The parents of the offsprings.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the mutation operators.

NumpyArrayMessage

Bases: BaseMessage

A message containing a numpy array value.

Source code in desdeo/tools/message.py
class NumpyArrayMessage(BaseMessage):
    """A message containing a numpy array value."""

    value: np.ndarray = Field(..., description="The numpy array value of the message.")
    """ The numpy array value of the message. """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    @field_serializer("value")
    def _serialize_value(self, value: np.ndarray) -> list[list[float]]:
        return value.tolist()

value class-attribute instance-attribute

value: ndarray = Field(
    ..., description="The numpy array value of the message."
)

The numpy array value of the message.

PolarsDataFrameMessage

Bases: BaseMessage

A message containing a 2D array value, such as a population or a set of objectives.

Source code in desdeo/tools/message.py
class PolarsDataFrameMessage(BaseMessage):
    """A message containing a 2D array value, such as a population or a set of objectives."""

    value: DataFrame = Field(..., description="The array value of the message.")
    """ The array value of the message. """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    @field_serializer("value")
    def _serialize_value(self, value: DataFrame) -> dict[str, list[int | float]]:
        return value.to_dict(as_series=False)

value class-attribute instance-attribute

value: DataFrame = Field(
    ..., description="The array value of the message."
)

The array value of the message.

ReferenceVectorMessageTopics

Bases: Enum

Topics for messages related to the reference vectors.

Source code in desdeo/tools/message.py
class ReferenceVectorMessageTopics(Enum):
    """Topics for messages related to the reference vectors."""

    TEST = "TEST"

SelectorMessageTopics

Bases: Enum

Topics for messages related to selector operators.

Source code in desdeo/tools/message.py
class SelectorMessageTopics(Enum):
    """Topics for messages related to selector operators."""

    TEST = "TEST"
    """ A message topic used only for testing the selector operators. """
    STATE = "STATE"
    """ The state of the parameters of the selector. """
    INDIVIDUALS = "INDIVIDUALS"
    """ The individuals to select from. """
    OUTPUTS = "OUTPUTS"
    """ The outputs of the individuals. """
    CONSTRAINTS = "CONSTRAINTS"
    """ The constraints of the individuals. """
    SELECTED_INDIVIDUALS = "SELECTED_INDIVIDUALS"
    """ The individuals selected by the selector. """
    SELECTED_OUTPUTS = "SELECTED_OUTPUTS"
    """ The targets of the selected individuals. """
    SELECTED_FITNESS = "SELECTED_FITNESS"
    """ The fitness of the selected individuals. This is the fitness calculated by the selector, not the objectives."""
    SELECTED_VERBOSE_OUTPUTS = "SELECTED_VERBOSE_OUTPUTS"
    """ Same as SELECTED_OUTPUTS + SELECTED_INDIVIDUALS"""
    REFERENCE_VECTORS = "REFERENCE_VECTORS"
    """ The reference vectors used in the selection in decomposition-based EMO algorithms. """

CONSTRAINTS class-attribute instance-attribute

CONSTRAINTS = 'CONSTRAINTS'

The constraints of the individuals.

INDIVIDUALS class-attribute instance-attribute

INDIVIDUALS = 'INDIVIDUALS'

The individuals to select from.

OUTPUTS class-attribute instance-attribute

OUTPUTS = 'OUTPUTS'

The outputs of the individuals.

REFERENCE_VECTORS class-attribute instance-attribute

REFERENCE_VECTORS = 'REFERENCE_VECTORS'

The reference vectors used in the selection in decomposition-based EMO algorithms.

SELECTED_FITNESS class-attribute instance-attribute

SELECTED_FITNESS = 'SELECTED_FITNESS'

The fitness of the selected individuals. This is the fitness calculated by the selector, not the objectives.

SELECTED_INDIVIDUALS class-attribute instance-attribute

SELECTED_INDIVIDUALS = 'SELECTED_INDIVIDUALS'

The individuals selected by the selector.

SELECTED_OUTPUTS class-attribute instance-attribute

SELECTED_OUTPUTS = 'SELECTED_OUTPUTS'

The targets of the selected individuals.

SELECTED_VERBOSE_OUTPUTS class-attribute instance-attribute

SELECTED_VERBOSE_OUTPUTS = 'SELECTED_VERBOSE_OUTPUTS'

Same as SELECTED_OUTPUTS + SELECTED_INDIVIDUALS

STATE class-attribute instance-attribute

STATE = 'STATE'

The state of the parameters of the selector.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the selector operators.

StringMessage

Bases: BaseMessage

A message containing a string value.

Source code in desdeo/tools/message.py
class StringMessage(BaseMessage):
    """A message containing a string value."""

    value: str = Field(..., description="The string value of the message.")
    """ The string value of the message. """

value class-attribute instance-attribute

value: str = Field(
    ..., description="The string value of the message."
)

The string value of the message.

TerminatorMessageTopics

Bases: Enum

Topics for messages related to terminator operators.

Source code in desdeo/tools/message.py
class TerminatorMessageTopics(Enum):
    """Topics for messages related to terminator operators."""

    TEST = "TEST"
    """ A message topic used only for testing the terminator operators. """
    STATE = "STATE"
    """ The state of the parameters of the terminator. """
    TERMINATION = "TERMINATION"
    """ The value of the termination condition. """
    GENERATION = "GENERATION"
    """ The current generation number. """
    EVALUATION = "EVALUATION"
    """ The current number of evaluations. """
    MAX_GENERATIONS = "MAX_GENERATIONS"
    """ The maximum number of generations. """
    MAX_EVALUATIONS = "MAX_EVALUATIONS"
    """ The maximum number of evaluations. """

EVALUATION class-attribute instance-attribute

EVALUATION = 'EVALUATION'

The current number of evaluations.

GENERATION class-attribute instance-attribute

GENERATION = 'GENERATION'

The current generation number.

MAX_EVALUATIONS class-attribute instance-attribute

MAX_EVALUATIONS = 'MAX_EVALUATIONS'

The maximum number of evaluations.

MAX_GENERATIONS class-attribute instance-attribute

MAX_GENERATIONS = 'MAX_GENERATIONS'

The maximum number of generations.

STATE class-attribute instance-attribute

STATE = 'STATE'

The state of the parameters of the terminator.

TERMINATION class-attribute instance-attribute

TERMINATION = 'TERMINATION'

The value of the termination condition.

TEST class-attribute instance-attribute

TEST = 'TEST'

A message topic used only for testing the terminator operators.

Description generator

An utility function to generate descriptions related to UTOPIA matters.

generate_descriptions

generate_descriptions(
    mapjson: dict,
    sid: str,
    stand: str,
    holding: str,
    extension: str,
) -> dict

Generate human-readable stand descriptions for UTOPIA forest map features.

Source code in desdeo/tools/desc_gen.py
def generate_descriptions(mapjson: dict, sid: str, stand: str, holding: str, extension: str) -> dict:
    """Generate human-readable stand descriptions for UTOPIA forest map features."""
    descriptions = {}
    if holding:
        for feat in mapjson["features"]:
            if False:  # noqa: SIM108
                ext = f".{feat['properties'][extension]}"
            else:
                ext = ""
            descriptions[feat["properties"][sid]] = (
                f"Ala {feat['properties'][holding].split('-')[-1]} kuvio {feat['properties'][stand]}{ext}: "
            )
    else:
        for feat in mapjson["features"]:
            if False:  # noqa: SIM108
                ext = f".{feat['properties'][extension]}"
            else:
                ext = ""
            descriptions[feat["properties"][sid]] = f"Kuvio {feat['properties'][stand]}{ext}: "
    return descriptions

Utilities

General utilities related to solvers.

find_compatible_solvers

find_compatible_solvers(
    problem: Problem,
) -> list[BaseSolver]

Find solvers that are compatible with the problem that is being solved.

Parameters:

Name Type Description Default
problem Problem

The problem being solved.

required

Returns:

Type Description
list[BaseSolver]

list[BaseSolver]: A list of solvers that are compatible with the problem.

Source code in desdeo/tools/utils.py
def find_compatible_solvers(problem: Problem) -> list[BaseSolver]:
    """Find solvers that are compatible with the problem that is being solved.

    Args:
        problem (Problem): The problem being solved.

    Returns:
        list[BaseSolver]: A list of solvers that are compatible with the problem.
    """
    solvers = []

    # check for variable dimensions
    # This could be also done by just checking if all the variables are Variables instead of TensorVariables
    # as solvers at the moment do not care about the difference between 1D tensors and higher dimensions.
    # This is because the solvers that utilize the polars evaluator (the only evaluator that works with
    # scalars and 1D tensors and not higher dimensions) only support scalar valued variables at the moment.
    var_dim = variable_dimension_enumerate(problem)

    # check if problem has only data-based objectives
    all_data_based = all(objective.objective_type == ObjectiveTypeEnum.data_based for objective in problem.objectives)

    # check if problem has a discrete definition
    has_discrete = problem.discrete_representation is not None

    # check if problem is data-based
    if all_data_based and has_discrete and var_dim == VariableDimensionEnum.scalar:
        # problem has only data-based objectives and a discrete definition is available
        # return ProximalSolver as it is the only solver for data-based problems at the moment
        return [available_solvers["proximal"]["constructor"]]

    # check if the problem is differentiable and if it is mixed integer
    if (
        problem.is_twice_differentiable
        and shutil.which("bonmin")
        and problem.variable_domain
        in [
            VariableDomainTypeEnum.integer,
            VariableDomainTypeEnum.mixed,
        ]
    ):
        solvers.append(available_solvers["pyomo_bonmin"]["constructor"])  # bonmin has to be installed

    # check if the problem is differentiable and continuous
    if (
        problem.is_twice_differentiable
        and shutil.which("ipopt")
        and problem.variable_domain == VariableDomainTypeEnum.continuous
    ):
        solvers.append(available_solvers["pyomo_ipopt"]["constructor"])  # ipopt has to be installed

    # check if the problem is convex or log-log convex
    if check_cvxpy_suitability(problem):
        solvers.append(available_solvers["cvxpy"]["constructor"])

    # check if the problem is linear
    if problem.is_linear and check_gurobi_license():
        solvers.append(available_solvers["gurobipy"]["constructor"])
    if problem.is_linear and shutil.which("gurobi"):
        solvers.append(available_solvers["pyomo_gurobi"]["constructor"])  # gurobi has to be installed
    if problem.is_linear and shutil.which("cbc"):
        solvers.append(available_solvers["pyomo_cbc"]["constructor"])

    # check if problem's variables are all scalars
    if var_dim == VariableDimensionEnum.scalar:
        # nevergrad and scipy solvers work with all(?) problems with only scalar valued variables
        solvers.append(available_solvers["nevergrad"]["constructor"])
        solvers.append(available_solvers["scipy_minimize"]["constructor"])
        solvers.append(available_solvers["scipy_de"]["constructor"])
    return solvers

flip_maximized_objective_values

flip_maximized_objective_values(
    problem: Problem, objective_values: dict[str, float]
) -> dict[str, float]

Flips the objective values if the objective function is to be maximized.

Flips the objective values if the objective function is to be maximized by multiplying the values related to maximized objective functions by -1.

Parameters:

Name Type Description Default
problem Problem

the problem the objective values are related to.

required
objective_values dict[str, float]

the objective values to be flipped.

required

Returns:

Type Description
dict[str, float]

dict[str, float]: the flipped objective values.

Source code in desdeo/tools/utils.py
def flip_maximized_objective_values(problem: Problem, objective_values: dict[str, float]) -> dict[str, float]:
    """Flips the objective values if the objective function is to be maximized.

    Flips the objective values if the objective function is to be maximized by multiplying
    the values related to maximized objective functions by -1.

    Args:
        problem (Problem): the problem the objective values are related to.
        objective_values (dict[str, float]): the objective values to be flipped.

    Returns:
        dict[str, float]: the flipped objective values.
    """
    return {
        obj.symbol: objective_values[obj.symbol] * -1 if obj.maximize else objective_values[obj.symbol]
        for obj in problem.objectives
    }

get_corrected_ideal

get_corrected_ideal(
    problem: Problem,
) -> dict[str, float | None]

Compute the corrected ideal point depending if an objective function is to be maximized or not.

I.e., the ideal point element for objectives to be maximized will be multiplied by -1.

Parameters:

Name Type Description Default
problem Problem

the problem with the ideal point.

required

Raises:

Type Description
ValueError

some of the ideal point components have not been defined for some of the objectives.

Returns:

Type Description
dict[str, float | None]

list[float]: a list with the corrected ideal point. Will return None for missing elements.

Source code in desdeo/tools/utils.py
def get_corrected_ideal(problem: Problem) -> dict[str, float | None]:
    """Compute the corrected ideal point depending if an objective function is to be maximized or not.

    I.e., the ideal point element for objectives to be maximized will be multiplied by -1.

    Args:
        problem (Problem): the problem with the ideal point.

    Raises:
        ValueError: some of the ideal point components have not been defined
            for some of the objectives.

    Returns:
        list[float]: a list with the corrected ideal point. Will return None for missing elements.
    """
    # check that ideal points are actually defined
    if any(obj.ideal is None for obj in problem.objectives):
        msg = "Some of the objectives have not a defined ideal value."
        raise ValueError(msg)

    return {
        objective.symbol: objective.ideal if not objective.maximize else -objective.ideal
        for objective in problem.objectives
    }

get_corrected_ideal_and_nadir

get_corrected_ideal_and_nadir(
    problem: Problem,
) -> tuple[
    dict[str, float | None], dict[str, float | None] | None
]

Compute the corrected ideal and nadir points depending if an objective function is to be maximized or not.

I.e., the ideal and nadir point element for objectives to be maximized will be multiplied by -1.

Parameters:

Name Type Description Default
problem Problem

the problem with the ideal and nadir points.

required

Raises:

Type Description
ValueError

some of the ideal or nadir point components have not been defined for some of the objectives.

Returns:

Type Description
tuple[dict[str, float | None], dict[str, float | None] | None]

tuple[list[float], list[float]]: a list with the corrected ideal point and a list with the corrected nadir point. Will return None for missing elements.

Source code in desdeo/tools/utils.py
def get_corrected_ideal_and_nadir(problem: Problem) -> tuple[dict[str, float | None], dict[str, float | None] | None]:
    """Compute the corrected ideal and nadir points depending if an objective function is to be maximized or not.

    I.e., the ideal and nadir point element for objectives to be maximized will be multiplied by -1.

    Args:
        problem (Problem): the problem with the ideal and nadir points.

    Raises:
        ValueError: some of the ideal or nadir point components have not been defined
            for some of the objectives.

    Returns:
        tuple[list[float], list[float]]: a list with the corrected ideal point
            and a list with the corrected nadir point. Will return None for missing
            elements.
    """
    # check that ideal and nadir points are actually defined
    if any(obj.ideal is None for obj in problem.objectives) or any(obj.nadir is None for obj in problem.objectives):
        msg = "Some of the objectives have not a defined ideal or nadir value."
        raise ValueError(msg)

    ideal_point = {
        objective.symbol: objective.ideal if not objective.maximize else -objective.ideal
        for objective in problem.objectives
    }
    nadir_point = {
        objective.symbol: objective.nadir if not objective.maximize else -objective.nadir
        for objective in problem.objectives
    }

    return ideal_point, nadir_point

get_corrected_nadir

get_corrected_nadir(
    problem: Problem,
) -> dict[str, float | None]

Compute the corrected nadir point depending if an objective function is to be maximized or not.

I.e., the nadir point element for objectives to be maximized will be multiplied by -1.

Parameters:

Name Type Description Default
problem Problem

the problem with the nadir points.

required

Raises:

Type Description
ValueError

some of the nadir point components have not been defined for some of the objectives.

Returns:

Type Description
dict[str, float | None]

list[float]: a list with the corrected nadir point. Will return None for missing elements.

Source code in desdeo/tools/utils.py
def get_corrected_nadir(problem: Problem) -> dict[str, float | None]:
    """Compute the corrected nadir point depending if an objective function is to be maximized or not.

    I.e., the nadir point element for objectives to be maximized will be multiplied by -1.

    Args:
        problem (Problem): the problem with the nadir points.

    Raises:
        ValueError: some of the nadir point components have not been defined
            for some of the objectives.

    Returns:
        list[float]: a list with the corrected nadir point. Will return None for missing elements.
    """
    # check that nadir points are actually defined
    if any(obj.nadir is None for obj in problem.objectives):
        msg = "Some of the objectives have not a defined nadir value."
        raise ValueError(msg)

    return {
        objective.symbol: objective.nadir if not objective.maximize else -objective.nadir
        for objective in problem.objectives
    }

guess_best_solver

guess_best_solver(problem: Problem) -> BaseSolver

Given a problem, tries to guess the best solver to handle it.

Parameters:

Name Type Description Default
problem Problem

the problem being solved.

required
Note

Needs to be extended as new solvers are implemented.

Returns:

Name Type Description
BaseSolver BaseSolver

a solver class that uses BaseSolver as a base class

Source code in desdeo/tools/utils.py
def guess_best_solver(problem: Problem) -> BaseSolver:
    """Given a problem, tries to guess the best solver to handle it.

    Args:
        problem (Problem): the problem being solved.

    Note:
        Needs to be extended as new solvers are implemented.

    Returns:
        BaseSolver: a solver class that uses BaseSolver as a base class
    """
    # needs to be extended as new solver are implemented

    # check if problem has only data-based objectives
    all_data_based = all(objective.objective_type == ObjectiveTypeEnum.data_based for objective in problem.objectives)

    # check if problem has a discrete definition
    has_discrete = problem.discrete_representation is not None

    # TODO: when figured out what solver is best for problems with tensor variables: it seems that e.g. the
    # forest problems don't work with pyomo_cbc. So VERY MUCH a quick fix to get something working, as those types of
    # problems have been ok with gurobipy.

    # if True in [isinstance(variable, TensorVariable) for variable in problem.variables]:
    if False:
        if problem.is_linear and shutil.which("cbc"):
            return available_solvers["pyomo_cbc"]["constructor"]

        if problem.is_linear:
            return available_solvers["gurobipy"]["constructor"]

        # check if the problem is differentiable and if it is mixed integer
        if (
            problem.is_twice_differentiable
            and shutil.which("bonmin")
            and problem.variable_domain
            in [
                VariableDomainTypeEnum.integer,
                VariableDomainTypeEnum.mixed,
            ]
        ):
            return available_solvers["pyomo_bonmin"]["constructor"]

        # check if the problem is differentiable and continuous
        if (
            problem.is_twice_differentiable
            and shutil.which("ipopt")
            and problem.variable_domain == VariableDomainTypeEnum.continuous
        ):
            return available_solvers["pyomo_ipopt"]["constructor"]

    if all_data_based and has_discrete:
        # problem has only data-based objectives and a discrete definition is available
        # guess proximal solver is best
        return available_solvers["proximal"]["constructor"]

    # check if the problem is linear
    if problem.is_linear and check_gurobi_license():
        return available_solvers["gurobipy"]["constructor"]

    # check if the problem is convex or log-log convex
    if check_cvxpy_suitability(problem):
        return available_solvers["cvxpy"]["constructor"]

    # check if the problem is differentiable and if it is mixed integer
    if (
        problem.is_twice_differentiable
        and shutil.which("bonmin")
        and problem.variable_domain
        in [
            VariableDomainTypeEnum.integer,
            VariableDomainTypeEnum.mixed,
        ]
    ):
        return available_solvers["pyomo_bonmin"]["constructor"]

    # check if the problem is differentiable and continuous
    if (
        problem.is_twice_differentiable
        and shutil.which("ipopt")
        and problem.variable_domain == VariableDomainTypeEnum.continuous
    ):
        return available_solvers["pyomo_ipopt"]["constructor"]

    # else, guess nevergrad heuristics to be the best
    return available_solvers["nevergrad"]["constructor"]

payoff_table_method

payoff_table_method(
    problem: Problem, solver: BaseSolver = None
) -> tuple[dict[str, float], dict[str, float]]

Solves a representation for the ideal and nadir points for a multiobjective optimization problem.

Parameters:

Name Type Description Default
problem Problem

The problem for which the ideal and nadir are solved.

required
solver BaseSolver

The solver to be used in solving the points. Defaults to None.

None

Returns:

Type Description
tuple[dict[str, float], dict[str, float]]

tuple[dict[str, float], dict[str, float]]: The estimated ideal and nadir points.

Source code in desdeo/tools/utils.py
def payoff_table_method(problem: Problem, solver: BaseSolver = None) -> tuple[dict[str, float], dict[str, float]]:
    """Solves a representation for the ideal and nadir points for a multiobjective optimization problem.

    Args:
        problem (Problem): The problem for which the ideal and nadir are solved.
        solver (BaseSolver): The solver to be used in solving the points. Defaults to None.

    Returns:
        tuple[dict[str, float], dict[str, float]]: The estimated ideal and nadir points.
    """
    solver = solver if solver is not None else guess_best_solver(problem)
    solver = solver(problem)

    k = len(problem.objectives)
    po_table = np.zeros((k, k))

    for i in range(k):
        res = solver.solve(f"{problem.objectives[i].symbol}_min")
        for j in range(k):
            po_table[i][j] = res.optimal_objectives[problem.objectives[j].symbol]

    ideal = np.diag(po_table)
    nadir = []

    for i in range(k):
        if problem.objectives[i].maximize:
            nadir.append(np.min(po_table.T[i]))
        else:
            nadir.append(np.max(po_table.T[i]))
    return numpy_array_to_objective_dict(problem, ideal), numpy_array_to_objective_dict(problem, nadir)

repair

repair(
    lower_bounds: dict[str, float],
    upper_bounds: dict[str, float],
) -> Callable[[pl.DataFrame], pl.DataFrame]

Repairs the offspring by clipping the values to be within the specified bounds.

Useful in evolutionary algorithms where offspring may go out of bounds due to crossover or mutation operations. This also fills any NaN values with the mean of the lower and upper bounds for that variable. Certain operators are known to produce NaN values, e.g., the Bounded Exponential Crossover operator.

Parameters:

Name Type Description Default
lower_bounds dict[str, float]

The lower bounds for each variable.

required
upper_bounds dict[str, float]

The upper bounds for each variable.

required

Returns:

Type Description
Callable[[DataFrame], DataFrame]

Callable[[pl.DataFrame], pl.DataFrame]: A function that takes a DataFrame and returns a repaired DataFrame.

Source code in desdeo/tools/utils.py
def repair(lower_bounds: dict[str, float], upper_bounds: dict[str, float]) -> Callable[[pl.DataFrame], pl.DataFrame]:
    """Repairs the offspring by clipping the values to be within the specified bounds.

    Useful in evolutionary algorithms where offspring may go out of bounds due to crossover or mutation operations.
    This also fills any NaN values with the mean of the lower and upper bounds for that variable. Certain operators are
    known to produce NaN values, e.g., the Bounded Exponential Crossover operator.

    Args:
        lower_bounds (dict[str, float]): The lower bounds for each variable.
        upper_bounds (dict[str, float]): The upper bounds for each variable.

    Returns:
        Callable[[pl.DataFrame], pl.DataFrame]: A function that takes a DataFrame and returns a repaired DataFrame.
    """

    def actual_repair(offspring: pl.DataFrame) -> pl.DataFrame:
        for var in offspring.columns:
            mean = (upper_bounds[var] + lower_bounds[var]) / 2
            offspring = offspring.with_columns(
                pl.col(var).clip(lower_bound=lower_bounds[var], upper_bound=upper_bounds[var]).fill_nan(mean)
            )
        return offspring

    # The original code seemed too slow when offsprings were created one at a time?
    # This is just a quick fix.
    def fast_actual_repair(offspring: pl.DataFrame) -> pl.DataFrame:
        # Convert the DataFrame to a NumPy array for faster operations
        columns = upper_bounds.keys()
        offspring_np = offspring[list(columns)].to_numpy()

        # Create arrays for lower and upper bounds
        lower_bounds_np = np.array([lower_bounds[var] for var in offspring.columns])
        upper_bounds_np = np.array([upper_bounds[var] for var in offspring.columns])

        # Clip the values to be within the specified bounds
        offspring_np = np.clip(offspring_np, lower_bounds_np, upper_bounds_np)

        # Fill NaN values with the mean of the lower and upper bounds
        means = (upper_bounds_np + lower_bounds_np) / 2
        nan_mask = np.isnan(offspring_np)
        offspring_np[nan_mask] = np.take(means, np.where(nan_mask)[1])

        # Convert back to a DataFrame
        offspring[list(columns)] = pl.DataFrame(offspring_np, schema=columns)
        return offspring

    return fast_actual_repair

Generics

Defines generic classes, functions, and objects utilized in the tools module.

BaseSolver

Bases: ABC

Defines a schema for a solver base class.

Source code in desdeo/tools/generics.py
class BaseSolver(ABC):
    """Defines a schema for a solver base class."""

    evaluator: object
    problem: Problem

    def __init__(self, problem: Problem, options: dict[str, Any] | None = None):
        """Initializer for the persistent solver.

        Args:
            problem (Problem): The problem for the solver.
            options (dict[str,any]): Dictionary of parameters to set.
                What these should be depends on the solver used.
        """
        self.problem = problem

    @abstractmethod
    def solve(self, target: str) -> SolverResults:
        """Solves the current problem with the specified target.

        Args:
            target (str): a str representing the symbol of the target function.

        Returns:
            SolverResults: The results of the solver
        """

__init__

__init__(
    problem: Problem, options: dict[str, Any] | None = None
)

Initializer for the persistent solver.

Parameters:

Name Type Description Default
problem Problem

The problem for the solver.

required
options dict[str, any]

Dictionary of parameters to set. What these should be depends on the solver used.

None
Source code in desdeo/tools/generics.py
def __init__(self, problem: Problem, options: dict[str, Any] | None = None):
    """Initializer for the persistent solver.

    Args:
        problem (Problem): The problem for the solver.
        options (dict[str,any]): Dictionary of parameters to set.
            What these should be depends on the solver used.
    """
    self.problem = problem

solve abstractmethod

solve(target: str) -> SolverResults

Solves the current problem with the specified target.

Parameters:

Name Type Description Default
target str

a str representing the symbol of the target function.

required

Returns:

Name Type Description
SolverResults SolverResults

The results of the solver

Source code in desdeo/tools/generics.py
@abstractmethod
def solve(self, target: str) -> SolverResults:
    """Solves the current problem with the specified target.

    Args:
        target (str): a str representing the symbol of the target function.

    Returns:
        SolverResults: The results of the solver
    """

EMOResult

Bases: BaseModel

Defines a schema for a dataclass to store the results of an EMO method.

Source code in desdeo/tools/generics.py
class EMOResult(BaseModel):
    """Defines a schema for a dataclass to store the results of an EMO method."""

    model_config = ConfigDict(arbitrary_types_allowed=True, use_attribute_docstrings=True)

    optimal_variables: pl.DataFrame = Field()
    """The decision vectors of the final population."""
    optimal_outputs: pl.DataFrame = Field()
    """The objective vectors, constraint vectors, extra_funcs, and targets of the final population."""

    @field_serializer("optimal_variables")
    def _serialize_optimal_variables(self, value: pl.DataFrame) -> dict[str, list[int | float]]:
        return value.to_dict(as_series=False)

    @field_serializer("optimal_outputs")
    def _serialize_optimal_outputs(self, value: pl.DataFrame) -> dict[str, list[int | float]]:
        return value.to_dict(as_series=False)

optimal_outputs class-attribute instance-attribute

optimal_outputs: DataFrame = Field()

The objective vectors, constraint vectors, extra_funcs, and targets of the final population.

optimal_variables class-attribute instance-attribute

optimal_variables: DataFrame = Field()

The decision vectors of the final population.

PersistentSolver

Defines a schema for a persistent solver class.

Can be used when reinitializing the solver every time the problem is changed is not practical.

Source code in desdeo/tools/generics.py
class PersistentSolver:
    """Defines a schema for a persistent solver class.

    Can be used when reinitializing the solver every time the problem is changed is not practical.
    """

    evaluator: object
    problem: Problem

    def __init__(self, problem: Problem, options: dict[str, Any] | None = None):
        """Initializer for the persistent solver.

        Args:
            problem (Problem): The problem for the solver.
            options (dict[str,any]): Dictionary of parameters to set.
                What these should be depends on the solver used.
        """
        self.problem = problem

    def add_constraint(self, constraint: Constraint | list[Constraint]):
        """Add a constraint expression to the solver.

        Args:
            constraint (Constraint): the constraint function expression.
        """

    def add_objective(self, objective: Objective):
        """Adds an objective function expression to the solver.

        Args:
            objective (Objective): an objective function expression to be added.
        """

    def add_scalarization_function(self, scalarization: ScalarizationFunction):
        """Adds a scalrization expression to the solver.

        Args:
            scalarization (ScalarizationFunction): A scalarization function to be added.
        """

    def add_variable(self, variable: Variable):
        """Add a variable to the solver.

        Args:
            variable (Variable): The definition of the variable to be added.
        """

    def remove_constraint(self, symbol: str):
        """Removes a constraint from the solver.

        Args:
            symbol (str): a str representing the symbol of the constraint to be removed.
        """

    def remove_variable(self, symbol: str):
        """Removes a variable from the model.

        Args:
            symbol (str): a str representing the symbol of the variable to be removed.
        """

    def solve(self, target: str) -> SolverResults | None:
        """Solves the current problem with the specified target.

        Args:
            target (str): a str representing the symbol of the target function.

        Returns:
            SolverResults: The results of the solver
        """

__init__

__init__(
    problem: Problem, options: dict[str, Any] | None = None
)

Initializer for the persistent solver.

Parameters:

Name Type Description Default
problem Problem

The problem for the solver.

required
options dict[str, any]

Dictionary of parameters to set. What these should be depends on the solver used.

None
Source code in desdeo/tools/generics.py
def __init__(self, problem: Problem, options: dict[str, Any] | None = None):
    """Initializer for the persistent solver.

    Args:
        problem (Problem): The problem for the solver.
        options (dict[str,any]): Dictionary of parameters to set.
            What these should be depends on the solver used.
    """
    self.problem = problem

add_constraint

add_constraint(constraint: Constraint | list[Constraint])

Add a constraint expression to the solver.

Parameters:

Name Type Description Default
constraint Constraint

the constraint function expression.

required
Source code in desdeo/tools/generics.py
def add_constraint(self, constraint: Constraint | list[Constraint]):
    """Add a constraint expression to the solver.

    Args:
        constraint (Constraint): the constraint function expression.
    """

add_objective

add_objective(objective: Objective)

Adds an objective function expression to the solver.

Parameters:

Name Type Description Default
objective Objective

an objective function expression to be added.

required
Source code in desdeo/tools/generics.py
def add_objective(self, objective: Objective):
    """Adds an objective function expression to the solver.

    Args:
        objective (Objective): an objective function expression to be added.
    """

add_scalarization_function

add_scalarization_function(
    scalarization: ScalarizationFunction,
)

Adds a scalrization expression to the solver.

Parameters:

Name Type Description Default
scalarization ScalarizationFunction

A scalarization function to be added.

required
Source code in desdeo/tools/generics.py
def add_scalarization_function(self, scalarization: ScalarizationFunction):
    """Adds a scalrization expression to the solver.

    Args:
        scalarization (ScalarizationFunction): A scalarization function to be added.
    """

add_variable

add_variable(variable: Variable)

Add a variable to the solver.

Parameters:

Name Type Description Default
variable Variable

The definition of the variable to be added.

required
Source code in desdeo/tools/generics.py
def add_variable(self, variable: Variable):
    """Add a variable to the solver.

    Args:
        variable (Variable): The definition of the variable to be added.
    """

remove_constraint

remove_constraint(symbol: str)

Removes a constraint from the solver.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the constraint to be removed.

required
Source code in desdeo/tools/generics.py
def remove_constraint(self, symbol: str):
    """Removes a constraint from the solver.

    Args:
        symbol (str): a str representing the symbol of the constraint to be removed.
    """

remove_variable

remove_variable(symbol: str)

Removes a variable from the model.

Parameters:

Name Type Description Default
symbol str

a str representing the symbol of the variable to be removed.

required
Source code in desdeo/tools/generics.py
def remove_variable(self, symbol: str):
    """Removes a variable from the model.

    Args:
        symbol (str): a str representing the symbol of the variable to be removed.
    """

solve

solve(target: str) -> SolverResults | None

Solves the current problem with the specified target.

Parameters:

Name Type Description Default
target str

a str representing the symbol of the target function.

required

Returns:

Name Type Description
SolverResults SolverResults | None

The results of the solver

Source code in desdeo/tools/generics.py
def solve(self, target: str) -> SolverResults | None:
    """Solves the current problem with the specified target.

    Args:
        target (str): a str representing the symbol of the target function.

    Returns:
        SolverResults: The results of the solver
    """

SolverError

Bases: Exception

Raised when an error with a solver is encountered.

Source code in desdeo/tools/generics.py
class SolverError(Exception):
    """Raised when an error with a solver is encountered."""

SolverResults

Bases: BaseModel

Defines a schema for a dataclass to store the results of a solver.

Source code in desdeo/tools/generics.py
class SolverResults(BaseModel):
    """Defines a schema for a dataclass to store the results of a solver."""

    optimal_variables: dict[str, int | float | list] = Field(description="The optimal decision variables found.")
    optimal_objectives: dict[str, float | list[float]] = Field(
        description="The objective function values corresponding to the optimal decision variables found."
    )
    constraint_values: dict[str, float | int | list[float] | list] | None | Any = Field(
        description=(
            "The constraint values of the problem. A negative value means the constraint is respected, "
            "a positive one means it has been breached."
        ),
        default=None,
    )
    extra_func_values: dict[str, float | list[float]] | None = Field(
        description=("The extra function values of the problem."), default=None
    )
    scalarization_values: dict[str, float | list[float]] | None = Field(
        description=("The scalarization function values of the problem."), default=None
    )
    lagrange_multipliers: dict[str, float | list[float]] | None = Field(
        description="The Lagrange multipliers of the problem.", default=None
    )
    success: bool = Field(description="A boolean flag indicating whether the optimization was successful or not.")
    message: str = Field(description="Description of the cause of termination.")

SCORE Bands

Use the auto_SCORE function to generate the SCORE bands visualization.

This module contains the functions which generate SCORE bands visualizations. It also contains functions to calculate the order and positions of the objective axes, as well as a heatmap of correlation matrix.

To run the SCORE bands visualization, use the score_json function to generate the data for the visualization, and then use the plot_score function to generate the figure. You can also pass the result of score_json to other frontends for visualization.

CustomClusterOptions

Bases: BaseModel

Options for custom clustering provided by the user.

Source code in desdeo/tools/score_bands.py
class CustomClusterOptions(BaseModel):
    """Options for custom clustering provided by the user."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    name: str = Field(default="Custom")
    """Custom user-provided clusters."""
    clusters: list[int]
    """List of cluster IDs (one for each solution) indicating the cluster to which each solution belongs."""

clusters instance-attribute

clusters: list[int]

List of cluster IDs (one for each solution) indicating the cluster to which each solution belongs.

name class-attribute instance-attribute

name: str = Field(default='Custom')

Custom user-provided clusters.

DBSCANOptions

Bases: BaseModel

Options for DBSCAN clustering algorithm.

Source code in desdeo/tools/score_bands.py
class DBSCANOptions(BaseModel):
    """Options for DBSCAN clustering algorithm."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    name: str = Field(default="DBSCAN")
    """DBSCAN clustering algorithm."""

name class-attribute instance-attribute

name: str = Field(default='DBSCAN')

DBSCAN clustering algorithm.

DimensionClusterOptions

Bases: BaseModel

Options for clustering by one of the objectives/decision variables.

Source code in desdeo/tools/score_bands.py
class DimensionClusterOptions(BaseModel):
    """Options for clustering by one of the objectives/decision variables."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    name: str = Field(default="DimensionCluster")
    """Clustering by one of the dimensions."""
    dimension_name: str
    """Dimension to use for clustering."""
    n_clusters: int = Field(default=5)
    """Number of clusters to use. Defaults to 5."""
    kind: Literal["EqualWidth", "EqualFrequency"] = Field(default="EqualWidth")
    """Kind of clustering to use. Either "EqualWidth", which divides the dimension range into equal width intervals,
        or "EqualFrequency", which divides the dimension values into intervals with equal number of solutions.
        Defaults to "EqualWidth"."""

dimension_name instance-attribute

dimension_name: str

Dimension to use for clustering.

kind class-attribute instance-attribute

kind: Literal["EqualWidth", "EqualFrequency"] = Field(
    default="EqualWidth"
)

Kind of clustering to use. Either "EqualWidth", which divides the dimension range into equal width intervals, or "EqualFrequency", which divides the dimension values into intervals with equal number of solutions. Defaults to "EqualWidth".

n_clusters class-attribute instance-attribute

n_clusters: int = Field(default=5)

Number of clusters to use. Defaults to 5.

name class-attribute instance-attribute

name: str = Field(default='DimensionCluster')

Clustering by one of the dimensions.

DistanceFormula

Bases: int, Enum

Distance formulas supported by SCORE bands. See the paper for details.

Source code in desdeo/tools/score_bands.py
class DistanceFormula(int, Enum):
    """Distance formulas supported by SCORE bands. See the paper for details."""

    FORMULA_1 = 1
    FORMULA_2 = 2

GMMOptions

Bases: BaseModel

Options for Gaussian Mixture Model clustering algorithm.

Source code in desdeo/tools/score_bands.py
class GMMOptions(BaseModel):
    """Options for Gaussian Mixture Model clustering algorithm."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    name: str = Field(default="GMM")
    """Gaussian Mixture Model clustering algorithm."""
    scoring_method: Literal["BIC", "silhouette"] = Field(default="silhouette")
    """Scoring method to use for GMM. Either "BIC" or "silhouette". Defaults to "silhouette".
        This option determines how the number of clusters is chosen."""

name class-attribute instance-attribute

name: str = Field(default='GMM')

Gaussian Mixture Model clustering algorithm.

scoring_method class-attribute instance-attribute

scoring_method: Literal["BIC", "silhouette"] = Field(
    default="silhouette"
)

Scoring method to use for GMM. Either "BIC" or "silhouette". Defaults to "silhouette". This option determines how the number of clusters is chosen.

KMeansOptions

Bases: BaseModel

Options for KMeans clustering algorithm.

Source code in desdeo/tools/score_bands.py
class KMeansOptions(BaseModel):
    """Options for KMeans clustering algorithm."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    name: str = Field(default="KMeans")
    """KMeans clustering algorithm."""
    n_clusters: int = Field(default=5)
    """Number of clusters to use. Defaults to 5."""

n_clusters class-attribute instance-attribute

n_clusters: int = Field(default=5)

Number of clusters to use. Defaults to 5.

name class-attribute instance-attribute

name: str = Field(default='KMeans')

KMeans clustering algorithm.

SCOREBandsConfig

Bases: BaseModel

Configuration options for SCORE bands visualization.

Source code in desdeo/tools/score_bands.py
class SCOREBandsConfig(BaseModel):
    """Configuration options for SCORE bands visualization."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    dimensions: list[str] | None = Field(default=None)
    """List of variable/objective names (i.e., column names in the data) to include in the visualization.
        If None, all columns in the data are used. Defaults to None."""
    descriptive_names: dict[str, str] | None = Field(default=None)
    """Optional dictionary mapping dimensions to descriptive names for display in the visualization.
        If None, the original dimension names are used. Defaults to None."""
    units: dict[str, str] | None = Field(default=None)
    """Optional dictionary mapping dimensions to their units for display in the visualization.
        If None, no units are displayed. Defaults to None."""
    axis_positions: dict[str, float] | None = Field(default=None)
    """Dictionary mapping objective names to their positions on the axes in the SCORE bands visualization. The first
        objective is at position 0.0, and the last objective is at position 1.0. Use this option if you want to
        manually set the axis positions. If None, the axis positions are calculated automatically based on correlations.
        Defaults to None."""
    axis_colours: dict[str, str] | None = Field(default=None)
    """Optional dictionary to set the colour of the axes corresponding to each objective. The keys should be the
        same as in the 'dimensions' field. The values should be a valid plotly color string. Defaults to None.

        Valid plotly color strings include:
            - A hex string (e.g. '#ff0000')
            - An rgb/rgba string (e.g. 'rgb(255,0,0)')
            - An hsl/hsla string (e.g. 'hsl(0,100%,50%)')
            - An hsv/hsva string (e.g. 'hsv(0,100%,100%)')
            - A named CSS color: see https://plotly.com/python/css-colors/ for a list
    """
    highlight_cluster: int | None = Field(default=None)
    """Cluster ID to highlight in the visualization. If None, no cluster is highlighted. Defaults to None.
        If a cluster ID is provided, the corresponding cluster is highlighted in the visualization by having a
        pattern fill in the band.
    """
    clustering_algorithm: ClusteringOptions = Field(
        default=DBSCANOptions(),
    )
    """
    Clustering algorithm to use. Currently supports one of `ClusteringOptions`.
    """
    distance_formula: DistanceFormula = Field(default=DistanceFormula.FORMULA_1)
    """Distance formula to use. The value should be 1 or 2. Check the paper for details. Defaults to 1."""
    distance_parameter: float = Field(default=0.05)
    """Change the relative distances between the objective axes. Increase this value if objectives are placed too close
        together. Decrease this value if the objectives are equidistant in a problem with objective clusters. Defaults
        to 0.05."""
    use_absolute_correlations: bool = Field(default=False)
    """Whether to use absolute value of the correlation to calculate the placement of axes. Defaults to False."""
    include_solutions: bool = Field(default=False)
    """Whether to include individual solutions. Defaults to False. If True, the size of the resulting figure may be
        very large for datasets with many solutions. Moreover, the individual traces are hidden by default, but can be
        viewed interactively in the figure."""
    include_medians: bool = Field(default=False)
    """Whether to include cluster medians. Defaults to False. If True, the median traces are hidden by default, but
        can be viewed interactively in the figure."""
    interval_size: float = Field(default=0.95)
    """The size (as a fraction) of the interval to use for the bands. Defaults to 0.95, meaning that 95% of the
    middle solutions in a cluster will be included in the band. The rest will be considered outliers."""
    scales: dict[str, tuple[float, float]] | None = Field(default=None)
    """Optional dictionary specifying the min and max values for each objective. The keys should be the
        objective names (i.e., column names in the data), and the values should be tuples of (min, max).
        If not provided, the min and max will be calculated from the data."""

axis_colours class-attribute instance-attribute

axis_colours: dict[str, str] | None = Field(default=None)

Optional dictionary to set the colour of the axes corresponding to each objective. The keys should be the same as in the 'dimensions' field. The values should be a valid plotly color string. Defaults to None.

Valid plotly color strings include
  • A hex string (e.g. '#ff0000')
  • An rgb/rgba string (e.g. 'rgb(255,0,0)')
  • An hsl/hsla string (e.g. 'hsl(0,100%,50%)')
  • An hsv/hsva string (e.g. 'hsv(0,100%,100%)')
  • A named CSS color: see https://plotly.com/python/css-colors/ for a list

axis_positions class-attribute instance-attribute

axis_positions: dict[str, float] | None = Field(
    default=None
)

Dictionary mapping objective names to their positions on the axes in the SCORE bands visualization. The first objective is at position 0.0, and the last objective is at position 1.0. Use this option if you want to manually set the axis positions. If None, the axis positions are calculated automatically based on correlations. Defaults to None.

clustering_algorithm class-attribute instance-attribute

clustering_algorithm: ClusteringOptions = Field(
    default=DBSCANOptions()
)

Clustering algorithm to use. Currently supports one of ClusteringOptions.

descriptive_names class-attribute instance-attribute

descriptive_names: dict[str, str] | None = Field(
    default=None
)

Optional dictionary mapping dimensions to descriptive names for display in the visualization. If None, the original dimension names are used. Defaults to None.

dimensions class-attribute instance-attribute

dimensions: list[str] | None = Field(default=None)

List of variable/objective names (i.e., column names in the data) to include in the visualization. If None, all columns in the data are used. Defaults to None.

distance_formula class-attribute instance-attribute

distance_formula: DistanceFormula = Field(default=FORMULA_1)

Distance formula to use. The value should be 1 or 2. Check the paper for details. Defaults to 1.

distance_parameter class-attribute instance-attribute

distance_parameter: float = Field(default=0.05)

Change the relative distances between the objective axes. Increase this value if objectives are placed too close together. Decrease this value if the objectives are equidistant in a problem with objective clusters. Defaults to 0.05.

highlight_cluster class-attribute instance-attribute

highlight_cluster: int | None = Field(default=None)

Cluster ID to highlight in the visualization. If None, no cluster is highlighted. Defaults to None. If a cluster ID is provided, the corresponding cluster is highlighted in the visualization by having a pattern fill in the band.

include_medians class-attribute instance-attribute

include_medians: bool = Field(default=False)

Whether to include cluster medians. Defaults to False. If True, the median traces are hidden by default, but can be viewed interactively in the figure.

include_solutions class-attribute instance-attribute

include_solutions: bool = Field(default=False)

Whether to include individual solutions. Defaults to False. If True, the size of the resulting figure may be very large for datasets with many solutions. Moreover, the individual traces are hidden by default, but can be viewed interactively in the figure.

interval_size class-attribute instance-attribute

interval_size: float = Field(default=0.95)

The size (as a fraction) of the interval to use for the bands. Defaults to 0.95, meaning that 95% of the middle solutions in a cluster will be included in the band. The rest will be considered outliers.

scales class-attribute instance-attribute

scales: dict[str, tuple[float, float]] | None = Field(
    default=None
)

Optional dictionary specifying the min and max values for each objective. The keys should be the objective names (i.e., column names in the data), and the values should be tuples of (min, max). If not provided, the min and max will be calculated from the data.

units class-attribute instance-attribute

units: dict[str, str] | None = Field(default=None)

Optional dictionary mapping dimensions to their units for display in the visualization. If None, no units are displayed. Defaults to None.

use_absolute_correlations class-attribute instance-attribute

use_absolute_correlations: bool = Field(default=False)

Whether to use absolute value of the correlation to calculate the placement of axes. Defaults to False.

SCOREBandsResult

Bases: BaseModel

Pydantic/JSON model for representing SCORE Bands.

Source code in desdeo/tools/score_bands.py
class SCOREBandsResult(BaseModel):
    """Pydantic/JSON model for representing SCORE Bands."""

    model_config = ConfigDict(use_attribute_docstrings=True)

    options: SCOREBandsConfig
    """Configuration options used to generate the SCORE bands."""
    ordered_dimensions: list[str]
    """List of variable/objective names (i.e., column names in the data).
        Ordered according to their placement in the SCORE bands visualization."""
    clusters: list[int]
    """List of cluster IDs (one for each solution) indicating the cluster to which each solution belongs."""
    cluster_names: dict[int, str] | None = Field(default=None)
    """Optional dictionary mapping cluster IDs to descriptive names for display in the visualization.
        If None, the cluster IDs themselves are used as names. Defaults to None."""
    cluster_hover_info: dict[int, str] | None = Field(default=None)
    """Optional dictionary mapping cluster IDs to hover information for display in the visualization.
        If None, no additional hover information is displayed. Defaults to None."""
    axis_positions: dict[str, float]
    """Dictionary mapping objective names to their positions on the axes in the SCORE bands visualization. The first
        objective is at position 0.0, and the last objective is at position 1.0."""
    bands: dict[int, dict[str, tuple[float, float]]]
    """Dictionary mapping cluster IDs to dictionaries of objective names and their corresponding band
        extremes (min, max)."""
    medians: dict[int, dict[str, float]]
    """Dictionary mapping cluster IDs to dictionaries of objective names and their corresponding median values."""
    cardinalities: dict[int, int]
    """Dictionary mapping cluster IDs to the number of solutions in each cluster."""

axis_positions instance-attribute

axis_positions: dict[str, float]

Dictionary mapping objective names to their positions on the axes in the SCORE bands visualization. The first objective is at position 0.0, and the last objective is at position 1.0.

bands instance-attribute

bands: dict[int, dict[str, tuple[float, float]]]

Dictionary mapping cluster IDs to dictionaries of objective names and their corresponding band extremes (min, max).

cardinalities instance-attribute

cardinalities: dict[int, int]

Dictionary mapping cluster IDs to the number of solutions in each cluster.

cluster_hover_info class-attribute instance-attribute

cluster_hover_info: dict[int, str] | None = Field(
    default=None
)

Optional dictionary mapping cluster IDs to hover information for display in the visualization. If None, no additional hover information is displayed. Defaults to None.

cluster_names class-attribute instance-attribute

cluster_names: dict[int, str] | None = Field(default=None)

Optional dictionary mapping cluster IDs to descriptive names for display in the visualization. If None, the cluster IDs themselves are used as names. Defaults to None.

clusters instance-attribute

clusters: list[int]

List of cluster IDs (one for each solution) indicating the cluster to which each solution belongs.

medians instance-attribute

medians: dict[int, dict[str, float]]

Dictionary mapping cluster IDs to dictionaries of objective names and their corresponding median values.

options instance-attribute

options: SCOREBandsConfig

Configuration options used to generate the SCORE bands.

ordered_dimensions instance-attribute

ordered_dimensions: list[str]

List of variable/objective names (i.e., column names in the data). Ordered according to their placement in the SCORE bands visualization.

_DBSCANClustering

_DBSCANClustering(data: DataFrame) -> np.ndarray

Cluster the data using DBSCAN with silhouette scoring to choose eps.

Source code in desdeo/tools/score_bands.py
def _DBSCANClustering(data: pl.DataFrame) -> np.ndarray:  # noqa: N802
    """Cluster the data using DBSCAN with silhouette scoring to choose eps."""
    x = StandardScaler().fit_transform(data.to_numpy())
    eps_options = np.linspace(0.01, 1, 20)
    best_score = -np.inf
    best_labels = np.ones(len(data))
    for eps_option in eps_options:
        db = DBSCAN(eps=eps_option, min_samples=10, metric="cosine").fit(x)
        core_samples_mask = np.zeros_like(db.labels_, dtype=bool)
        core_samples_mask[db.core_sample_indices_] = True
        labels = db.labels_
        try:
            score = silhouette_score(x, labels, metric="cosine")
        except ValueError:
            score = -np.inf
        if score > best_score:
            best_score = score
            best_labels = labels
    # print((best_score, chosen_eps))
    return best_labels

_gaussianmixtureclusteringwithBIC

_gaussianmixtureclusteringwithBIC(
    data: DataFrame,
) -> np.ndarray

Cluster the data using Gaussian Mixture Model with BIC scoring.

Source code in desdeo/tools/score_bands.py
def _gaussianmixtureclusteringwithBIC(data: pl.DataFrame) -> np.ndarray:  # noqa: N802
    """Cluster the data using Gaussian Mixture Model with BIC scoring."""
    data_copy = data.to_numpy()
    data_copy = StandardScaler().fit_transform(data_copy)
    lowest_bic = np.inf
    bic = []
    n_components_range = range(1, min(11, len(data_copy)))
    cv_types: list[Literal["full", "tied", "diag", "spherical"]] = ["spherical", "tied", "diag", "full"]
    for cv_type in cv_types:
        for n_components in n_components_range:
            # Fit a Gaussian mixture with EM
            gmm = GaussianMixture(n_components=n_components, covariance_type=cv_type)
            gmm.fit(data_copy)
            bic.append(gmm.score(data_copy))
            # bic.append(gmm.bic(data))
            if bic[-1] < lowest_bic:
                lowest_bic = bic[-1]
                best_gmm = gmm

    return best_gmm.predict(data_copy)

_gaussianmixtureclusteringwithsilhouette

_gaussianmixtureclusteringwithsilhouette(
    data: DataFrame,
) -> np.ndarray

Cluster the data using Gaussian Mixture Model with silhouette scoring.

Source code in desdeo/tools/score_bands.py
def _gaussianmixtureclusteringwithsilhouette(data: pl.DataFrame) -> np.ndarray:
    """Cluster the data using Gaussian Mixture Model with silhouette scoring."""
    x = StandardScaler().fit_transform(data.to_numpy())
    best_score = -np.inf
    best_labels = np.ones(len(data))
    n_components_range = range(1, min(11, len(data)))
    cv_types: list[Literal["full", "tied", "diag", "spherical"]] = ["spherical", "tied", "diag", "full"]
    for cv_type in cv_types:
        for n_components in n_components_range:
            # Fit a Gaussian mixture with EM
            gmm = GaussianMixture(n_components=n_components, covariance_type=cv_type)
            labels = gmm.fit_predict(x)
            try:
                score = silhouette_score(x, labels, metric="cosine")
            except ValueError:
                score = -np.inf
            if score > best_score:
                best_score = score
                best_labels = labels
    # print(best_score)
    return best_labels

annotated_heatmap

annotated_heatmap(
    correlation_matrix: ndarray,
    col_names: list,
    order: list | ndarray,
) -> go.Figure

Create a heatmap of the correlation matrix. Probably should be named something else.

Parameters:

Name Type Description Default
correlation_matrix ndarray

2-D square array of correlation values between pairs of objectives.

required
col_names List

Objective names.

required
order Union[List, ndarray]

Order in which the objectives are shown in SCORE bands.

required

Returns:

Type Description
Figure

go.Figure: The heatmap

Source code in desdeo/tools/score_bands.py
def annotated_heatmap(correlation_matrix: np.ndarray, col_names: list, order: list | np.ndarray) -> go.Figure:
    """Create a heatmap of the correlation matrix. Probably should be named something else.

    Args:
        correlation_matrix (np.ndarray): 2-D square array of correlation values between pairs of objectives.
        col_names (List): Objective names.
        order (Union[List, np.ndarray]): Order in which the objectives are shown in SCORE bands.

    Returns:
        go.Figure: The heatmap
    """
    corr = pl.DataFrame(correlation_matrix, index=col_names, columns=col_names)
    corr = corr[col_names[order]].loc[col_names[order[::-1]]]
    corr = np.rint(corr * 100) / 100  # Take upto two significant figures only to make heatmap readable.
    fig = ff.create_annotated_heatmap(
        corr.to_numpy(),
        x=list(corr.columns),
        y=list(corr.index),
        annotation_text=corr.astype(str).to_numpy(),
    )
    fig.update_layout(title="Pearson correlation coefficients")
    return fig

calculate_axes_positions

calculate_axes_positions(
    dimension_order: list[int],
    corr: ndarray,
    dist_parameter: float,
    distance_formula: DistanceFormula = DistanceFormula.FORMULA_1,
) -> np.ndarray

Calculate the position of the axes for the SCORE bands visualization based on correlations.

Parameters:

Name Type Description Default
dimension_order list[int]

Order of the variables to be plotted.

required
corr ndarray

Correlation (pearson) matrix.

required
dist_parameter float

Change the relative distances between the axes. Increase this value if the axes are placed too close together. Decrease this value if the axes are equidistant.

required
distance_formula DistanceFormula

The value should be 1 or 2. Check the paper for details. Defaults to DistanceFormula.FORMULA_1.

FORMULA_1

Returns:

Type Description
ndarray

np.ndarray: Positions of the axes in the range [0, 1].

Source code in desdeo/tools/score_bands.py
def calculate_axes_positions(
    dimension_order: list[int],
    corr: np.ndarray,
    dist_parameter: float,
    distance_formula: DistanceFormula = DistanceFormula.FORMULA_1,
) -> np.ndarray:
    """Calculate the position of the axes for the SCORE bands visualization based on correlations.

    Args:
        dimension_order (list[int]): Order of the variables to be plotted.
        corr (np.ndarray): Correlation (pearson) matrix.
        dist_parameter (float): Change the relative distances between the axes. Increase this value if the axes are
            placed too close together. Decrease this value if the axes are equidistant.
        distance_formula (DistanceFormula, optional): The value should be 1 or 2. Check the paper for details.
            Defaults to DistanceFormula.FORMULA_1.

    Returns:
        np.ndarray: Positions of the axes in the range [0, 1].
    """
    # axes positions
    order = np.asarray(list(itertools.pairwise(dimension_order)))
    axis_len = corr[order[:, 0], order[:, 1]]
    if distance_formula == DistanceFormula.FORMULA_1:
        axis_len = 1 - axis_len
    elif distance_formula == DistanceFormula.FORMULA_2:
        axis_len = 1 / (np.abs(axis_len) + 1)  #  Reciprocal for reverse
    else:
        # Should never reach here
        raise ValueError("distance_formula should be either 1 or 2 (int)")
    axis_len = axis_len + dist_parameter
    axis_len = axis_len / sum(axis_len)
    return np.cumsum(np.append(0, axis_len))

cluster

cluster(
    data: DataFrame, options: ClusteringOptions
) -> np.ndarray

Cluster the data using the specified clustering algorithm and options.

Source code in desdeo/tools/score_bands.py
def cluster(data: pl.DataFrame, options: ClusteringOptions) -> np.ndarray:
    """Cluster the data using the specified clustering algorithm and options."""
    if isinstance(options, DimensionClusterOptions):
        return cluster_by_dimension(data, options)
    if isinstance(options, KMeansOptions):
        x = StandardScaler().fit_transform(data.to_numpy())
        kmeans = KMeans(n_clusters=options.n_clusters, random_state=0).fit(x)
        return kmeans.labels_
    if isinstance(options, DBSCANOptions):
        return _DBSCANClustering(data)
    if isinstance(options, GMMOptions):
        if options.scoring_method == "silhouette":
            return _gaussianmixtureclusteringwithsilhouette(data)
        if options.scoring_method == "BIC":
            return _gaussianmixtureclusteringwithBIC(data)
    if isinstance(options, CustomClusterOptions):
        if len(options.clusters) != len(data):
            raise ValueError("Length of custom clusters must match number of solutions in data.")
        return np.array(options.clusters)
    raise ValueError(f"Unknown clustering algorithm: {options}")

cluster_by_dimension

cluster_by_dimension(
    data: DataFrame, options: DimensionClusterOptions
) -> np.ndarray

Cluster the data by a specific dimension.

Source code in desdeo/tools/score_bands.py
def cluster_by_dimension(data: pl.DataFrame, options: DimensionClusterOptions) -> np.ndarray:
    """Cluster the data by a specific dimension."""
    if options.dimension_name not in data.columns:
        raise ValueError(f"Objective '{options.dimension_name}' not found in data.")

    # Select the dimension column for clustering
    dimension = data[options.dimension_name]

    # Perform clustering based on the specified method
    if options.kind == "EqualWidth":
        min_val: float = dimension.min()
        max_val: float = dimension.max()
        SMALL_VALUE = 1e-8  # noqa: N806
        thresholds = np.linspace(
            min_val * (1 - SMALL_VALUE),  # Ensure the minimum value is included in the first cluster
            max_val * (1 + SMALL_VALUE),  # Ensure the maximum value is included in the last cluster
            options.n_clusters + 1,
        )
        return np.digitize(dimension.to_numpy(), thresholds)  # Cluster IDs start at 1
    if options.kind == "EqualFrequency":
        levels: list[float] = [dimension.quantile(i / options.n_clusters) for i in range(1, options.n_clusters)]
        thresholds = [-np.inf, *levels, np.inf]
        return np.digitize(dimension.to_numpy(), thresholds)  # Cluster IDs start at 1
    raise ValueError(f"Unknown clustering kind: {options.kind}")

order_dimensions

order_dimensions(
    data: DataFrame, use_absolute_corr: bool = False
) -> tuple[np.ndarray, list[int]]

Calculate the order of objectives.

Also returns the correlation matrix.

Parameters:

Name Type Description Default
data DataFrame

Data to be visualized.

required
use_absolute_corr bool

Use absolute value of the correlation to calculate order. Defaults to False.

False

Returns:

Name Type Description
tuple tuple[ndarray, list[int]]

The first element is the correlation matrix. The second element is the order of the objectives.

Source code in desdeo/tools/score_bands.py
def order_dimensions(data: pl.DataFrame, use_absolute_corr: bool = False) -> tuple[np.ndarray, list[int]]:
    """Calculate the order of objectives.

    Also returns the correlation matrix.

    Args:
        data (pl.DataFrame): Data to be visualized.
        use_absolute_corr (bool, optional): Use absolute value of the correlation to calculate order. Defaults to False.

    Returns:
        tuple: The first element is the correlation matrix. The second element is the order of the objectives.
    """
    # Calculating correlations
    # corr = spearmanr(data).correlation  # Pearson's coeff is better than Spearmann's, in some cases
    corr = np.asarray(
        [
            [pearsonr(data.to_numpy()[:, i], data.to_numpy()[:, j])[0] for j in range(len(data.columns))]
            for i in range(len(data.columns))
        ]
    )
    # axes order: solving TSP
    distances = corr
    if use_absolute_corr:
        distances = np.abs(distances)
    obj_order = solve_tsp(-distances)
    return corr, obj_order

plot_score

plot_score(
    data: DataFrame, result: SCOREBandsResult
) -> go.Figure

Generate the SCORE Bands figure from the SCOREBandsResult data.

Parameters:

Name Type Description Default
data DataFrame

Dataframe of objective values. The column names should be the objective names. Each row should be an objective vector.

required
result SCOREBandsResult

The result containing all relevant data for the SCORE bands visualization.

required

Returns:

Type Description
Figure

go.Figure: The SCORE bands plot.

Source code in desdeo/tools/score_bands.py
def plot_score(data: pl.DataFrame, result: SCOREBandsResult) -> go.Figure:
    """Generate the SCORE Bands figure from the SCOREBandsResult data.

    Args:
        data (pl.DataFrame): Dataframe of objective values. The column names should be the objective names. Each row
            should be an objective vector.
        result (SCOREBandsResult): The result containing all relevant data for the SCORE bands visualization.

    Returns:
        go.Figure: The SCORE bands plot.
    """
    # some constants for the figure, can be made configurable in the future if needed
    num_ticks = 6  # number of ticks to show on each axis
    max_dist_percent = 0.02  # Maximum distance between equidistant tick position vs a rounded tick position.
    column_names = result.ordered_dimensions

    clusters = np.sort(np.unique(result.clusters))

    cluster_th = 8  # max number of clusters to use 'Accent' color map with, otherwise use 'tab20'
    colorscale = (
        cm.get_cmap("Accent", len(clusters)) if len(clusters) <= cluster_th else cm.get_cmap("tab20", len(clusters))
    )
    if result.options.scales is None:
        raise ValueError("Scales must be provided in the SCOREBandsResult to plot the figure.")

    # Original scaling (not used in final version, but keeping for reference)
    # scaled_data = data.select((pl.all() - pl.all().min()) / (pl.all().max() - pl.all().min()))
    # Scaling with respect to the provided scales, which may not aling with the actual min and max in the data
    # (e.g. if user wants to use fixed scales across multiple visualizations)
    scaled_data = data.with_columns(
        [
            (pl.col(col) - result.options.scales[col][0])
            / (result.options.scales[col][1] - result.options.scales[col][0])
            for col in column_names
        ]
    )
    fig = go.Figure()
    fig.update_xaxes(showticklabels=False, showgrid=False, zeroline=False)
    fig.update_yaxes(showticklabels=False, showgrid=False, zeroline=False)
    fig.update_layout(plot_bgcolor="rgba(0,0,0,0)")

    cluster_column_name = "cluster"
    # Avoid overwriting existing column just in case data has a 'cluster' column
    if cluster_column_name in scaled_data.columns:
        cluster_column_name = "cluster_id"
    scaled_data = scaled_data.with_columns(pl.Series(cluster_column_name, result.clusters))

    if result.options.descriptive_names is None:
        descriptive_names = {name: name for name in column_names}
    else:
        descriptive_names = result.options.descriptive_names
    units = dict.fromkeys(column_names, "") if result.options.units is None else result.options.units

    # Add axes
    for _, col_name in enumerate(column_names):
        # check if axis_colours is provided, otherwise use black
        current_axis_colour = "black"
        if result.options.axis_colours is not None and col_name in result.options.axis_colours:
            current_axis_colour = result.options.axis_colours[col_name]
        # Calculate "nice" tick positions and values
        label_text, heights = smart_axis_tick_placement(
            axis_min=result.options.scales[col_name][0],
            axis_max=result.options.scales[col_name][1],
            num_ticks=num_ticks,
            max_dist_percent=max_dist_percent,
        )
        # Axis lines
        fig.add_scatter(
            x=[result.axis_positions[col_name]] * num_ticks,
            y=heights,
            text=label_text,
            textposition="middle left",
            mode="markers+lines+text",
            line={"color": current_axis_colour},
            showlegend=False,
            hoverinfo="skip",
            zorder=100,
        )
        # Objective Name
        name = descriptive_names[col_name]
        splits = name.split()
        # If the name is too long, add a line break at the second space
        if len(splits) > 2:  # noqa: PLR2004
            splits[2] = "<br>" + splits[2]
        name = " ".join(splits)
        fig.add_scatter(
            x=[result.axis_positions[col_name]],
            y=[1.20],
            text=f"{name}",
            textfont={"size": 20},
            mode="text",
            showlegend=False,
        )
        # Units
        fig.add_scatter(
            x=[result.axis_positions[col_name]],
            y=[1.10],
            text=f"{units[col_name]}",
            textfont={"size": 12},
            mode="text",
            showlegend=False,
        )
    # Add bands
    for cluster_id in sorted(result.bands.keys()):
        r, g, b, a = colorscale(cluster_id - 1)  # Needed as cluster numbering starts at 1
        a = 0.6
        highlight = None
        if result.options.highlight_cluster is not None and cluster_id == result.options.highlight_cluster:
            highlight = {"shape": "x"}
        hovertext = (
            result.cluster_hover_info.get(cluster_id, f"Cluster {cluster_id}")
            if result.cluster_hover_info is not None
            else f"Cluster {cluster_id}"
        )
        color_bands = f"rgba({r}, {g}, {b}, {a})"
        color_solutions = f"rgba({r}, {g}, {b}, 0.5)"
        # color_soln = f"rgba({r}, {g}, {b}, {a})"

        lows = [
            (result.bands[cluster_id][col_name][0] - result.options.scales[col_name][0])
            / (result.options.scales[col_name][1] - result.options.scales[col_name][0])
            for col_name in column_names
        ]
        highs = [
            (result.bands[cluster_id][col_name][1] - result.options.scales[col_name][0])
            / (result.options.scales[col_name][1] - result.options.scales[col_name][0])
            for col_name in column_names
        ]
        medians = [
            (result.medians[cluster_id][col_name] - result.options.scales[col_name][0])
            / (result.options.scales[col_name][1] - result.options.scales[col_name][0])
            for col_name in column_names
        ]

        current_cluster_name = "Cluster " + str(cluster_id)
        if result.cluster_names is not None and cluster_id in result.cluster_names:
            current_cluster_name = result.cluster_names[cluster_id]
        fig.add_scatter(
            x=[result.axis_positions[col_name] for col_name in column_names],
            y=lows,
            line={"color": color_bands},
            name=f"{int(100 * result.options.interval_size)}% band: {current_cluster_name}",
            mode="lines",
            legendgroup=f"{int(100 * result.options.interval_size)}% band: {current_cluster_name}",
            showlegend=True,
            line_shape="spline",
            hovertext=hovertext,
            hoverinfo="text",
        )
        # upper bound of the band
        fig.add_scatter(
            x=[result.axis_positions[col_name] for col_name in column_names],
            y=highs,
            line={"color": color_bands},
            name=f"{current_cluster_name}",
            fillcolor=color_bands,
            mode="lines",
            fillpattern=go.scatter.Fillpattern(highlight),
            legendgroup=f"{int(100 * result.options.interval_size)}% band: {current_cluster_name}",
            showlegend=False,
            line_shape="spline",
            fill="tonexty",
            hovertext=hovertext,
            hoverinfo="text",
        )

        if result.options.include_medians:
            # median
            fig.add_scatter(
                x=[result.axis_positions[col_name] for col_name in column_names],
                y=medians,
                line={"color": color_bands},
                name=f"Median: {current_cluster_name}",
                mode="lines+markers",
                marker={"line": {"color": "Black", "width": 2}},
                legendgroup=f"Median: {current_cluster_name}",
                showlegend=True,
            )
        # Drawing each solution as a single trace (like how it was done in the past) can make the figure very heavy
        # and make interactions (showing/hiding traces) very laggy.
        # Thus here, we draw all solutions in a cluster as a single trace. Basically we make a huge zig-zag trace for
        # each cluster. E.g. imagine two solutions with obj vals (1, 2, 3) and (4, 5, 6) on three objectives. These
        # become the y values in the parallel coordinates plot in this order (1, 2, 3, 6, 5, 4). Subsets of these points
        # which belong to the same solution are given the same hovertext
        if result.options.include_solutions:
            cluster_solutions = scaled_data.filter(pl.col(cluster_column_name) == cluster_id).select(column_names)

            x = []
            y = []
            ax_pos = [result.axis_positions[col_name] for col_name in column_names]
            hovertexts = []
            rev_ax_pos = ax_pos[::-1]
            for i, row in enumerate(cluster_solutions.iter_rows()):
                if i % 2 == 0:
                    x = x + ax_pos
                    y = y + list(row)
                else:
                    x = x + rev_ax_pos
                    y = y + list(row)[::-1]
                # Scale values back to original scale for hovertext
                hovertext = [
                    (
                        f"<b>{col}</b>: "
                        f"{
                            (
                                val * (result.options.scales[col][1] - result.options.scales[col][0])
                                + result.options.scales[col][0]
                            ):.2f} <br>"
                    )
                    for col, val in zip(column_names, row, strict=True)
                ]
                hovertext = "".join(hovertext)
                hovertext = [hovertext] * len(column_names)
                hovertexts = hovertexts + hovertext
            fig.add_scatter(
                x=x,
                y=y,
                line={"color": color_solutions, "width": 1},
                name=f"{result.cardinalities[cluster_id]} Solutions: {current_cluster_name}",
                mode="lines",
                legendgroup=f"{result.cardinalities[cluster_id]} Solutions: {current_cluster_name}",
                hovertext=hovertexts,
                hoverinfo="text",
                hoveron="points+fills",
                showlegend=True,
            )
    fig.update_layout(font_size=18)
    fig.update_layout(legend={"orientation": "h", "yanchor": "top"})
    return fig

score_json

score_json(
    data: DataFrame, options: SCOREBandsConfig
) -> SCOREBandsResult

Generate the SCORE Bands data for a given dataset and configuration options.

Parameters:

Name Type Description Default
data DataFrame

Dataframe of variable (decision or objective) values. The column names should be the names of the variables to be plotted. Each row should be a solution.

required
options SCOREBandsConfig

Configuration options for generating the SCORE bands.

required

Returns:

Name Type Description
SCOREBandsResult SCOREBandsResult

The result containing all relevant data for the SCORE bands visualization.

Source code in desdeo/tools/score_bands.py
def score_json(data: pl.DataFrame, options: SCOREBandsConfig) -> SCOREBandsResult:
    """Generate the SCORE Bands data for a given dataset and configuration options.

    Args:
        data (pl.DataFrame): Dataframe of variable (decision or objective) values.
            The column names should be the names of the variables to be plotted. Each row should be a solution.

        options (SCOREBandsConfig): Configuration options for generating the SCORE bands.

    Returns:
        SCOREBandsResult: The result containing all relevant data for the SCORE bands visualization.
    """
    options = deepcopy(options)
    # Calculating correlations and axes positions
    if options.dimensions is None:
        options.dimensions = data.columns
    data_copy = data.select([pl.col(col) for col in options.dimensions])

    if options.axis_positions is None:
        corr, dimension_order = order_dimensions(data_copy, use_absolute_corr=options.use_absolute_correlations)

        axis_dist = calculate_axes_positions(
            dimension_order,
            corr,
            dist_parameter=options.distance_parameter,
            distance_formula=options.distance_formula,
        )

        ordered_dimension_names = [data_copy.columns[i] for i in dimension_order]
        axis_positions = {name: axis_dist[i] for i, name in enumerate(ordered_dimension_names)}
    else:
        axis_positions = options.axis_positions
        ordered_dimension_names = sorted(axis_positions.keys(), key=axis_positions.get)

    clusters = cluster(data_copy, options.clustering_algorithm)

    if min(clusters) <= 0:
        clusters = clusters - np.min(clusters) + 1  # translate minimum to 1.

    # some sanity check: check if all cluster IDs are contiguous integers starting at 1, ending at number of clusters
    unique_clusters = np.unique(clusters)
    max_cluster_id = max(clusters)
    if not all(i in unique_clusters for i in range(1, max_cluster_id + 1)):
        warn(
            """Cluster IDs are not contiguous integers starting at 1.
            This may cause issues with the color mapping in the visualization.""",
            category=UserWarning,
            stacklevel=2,
        )

    cluster_column_name = "cluster"
    if cluster_column_name in data_copy.columns:
        cluster_column_name = "cluster_id"

    data_copy = data_copy.with_columns(pl.Series(cluster_column_name, clusters))
    grouped = data_copy.group_by(cluster_column_name)
    min_percentile = (1 - options.interval_size) / 2
    max_percentile = 1 - min_percentile
    mins = grouped.quantile(min_percentile)
    maxs = grouped.quantile(max_percentile)
    medians = grouped.median()
    frequencies = grouped.len()
    bands_dict = {
        cluster_id: {
            col_name: (
                mins.filter(pl.col(cluster_column_name) == cluster_id)[col_name][0],
                maxs.filter(pl.col(cluster_column_name) == cluster_id)[col_name][0],
            )
            for col_name in ordered_dimension_names
        }
        for cluster_id in mins[cluster_column_name].to_list()
    }
    medians_dict = {
        cluster_id: {
            col_name: medians.filter(pl.col(cluster_column_name) == cluster_id)[col_name][0]
            for col_name in ordered_dimension_names
        }
        for cluster_id in medians[cluster_column_name].to_list()
    }
    frequencies_dict = {
        cluster_id: frequencies.filter(pl.col(cluster_column_name) == cluster_id)["len"][0]
        for cluster_id in frequencies[cluster_column_name].to_list()
    }

    if options.scales is None:
        scales: dict[str, tuple[float, float]] = {
            dimension: (data_copy[dimension].min(), data_copy[dimension].max()) for dimension in ordered_dimension_names
        }
        options.scales = scales
    return SCOREBandsResult(
        options=options,
        ordered_dimensions=ordered_dimension_names,
        clusters=clusters.tolist(),
        axis_positions=axis_positions,
        bands=bands_dict,
        medians=medians_dict,
        cardinalities=frequencies_dict,
    )

smart_axis_tick_placement

smart_axis_tick_placement(
    axis_min: float,
    axis_max: float,
    num_ticks: int,
    max_dist_percent: float,
) -> tuple[list[str], list[float]]

Calculate smart tick placement for the axes in the SCORE bands visualization.

Source code in desdeo/tools/score_bands.py
def smart_axis_tick_placement(
    axis_min: float, axis_max: float, num_ticks: int, max_dist_percent: float
) -> tuple[list[str], list[float]]:
    """Calculate smart tick placement for the axes in the SCORE bands visualization."""
    min_label_delta = 100
    label_text = np.linspace(axis_min, axis_max, num_ticks)
    heights = [0.0] * num_ticks
    max_distance = (axis_max - axis_min) * max_dist_percent

    # Get a range of candidate tick values around each label_text value
    # and choose the one with the least number of significant digits
    for j in range(len(label_text)):
        if j == 0 or j == len(label_text) - 1:
            # skip rounding for the first and last labels to ensure they are exactly the min and max values
            continue
        label_max = label_text[j] + max_distance
        label_min = label_text[j] - max_distance
        # Find the closest "nice" number to label_text[j] within the range [label_min, label_max]
        # i.e., least number of significant digits
        # while still being within the max distance from the original label text value.
        label_delta = label_max - label_min
        # All calculations are done in integers so we multiply the label_min and label_max by a power of 10
        # such that the label_delta is at least 100.
        multiplier = 1
        while label_delta * multiplier < min_label_delta:
            multiplier *= 10
        label_min = label_min * multiplier
        label_max = label_max * multiplier
        if label_max > 0:
            changing_point = label_max
            stable_point = label_min
        else:
            changing_point = label_min
            stable_point = label_max
        # Remove the least significant digits until the changing_point is too far from the original label_text value
        # then reverse one step back to get the final label value. This gives us a tick label within the max distance
        # from the original label_text value, but with as few significant digits as possible for better readability.
        divider = 1
        while True:
            divider *= 10
            if abs(int(changing_point / divider) * divider) < abs(stable_point):
                break
        divider = divider / 10  # reverse one step back
        label_text[j] = int(changing_point / divider) * divider / multiplier
        # Get the correct height for the label_text[j] value based on the original axis_min and axis_max values
        heights[j] = (label_text[j] - axis_min) / (axis_max - axis_min)
    heights[-1] = 1.0  # Ensure the last label is exactly at the max scale value
    label_text = [f"{label:.6g}" for label in label_text]
    return label_text, heights