Skip to content

Fitness and Constraints

In multi-objective optimization, fitness functions assign a numerical score to each candidate solution based on how well it achieves the objectives, while constraint functions measure any violations of problem constraints (e.g., inequalities or equalities).

In moors/pymoors, both fitness and constraint functions are implemented as vectorized operations on ndarrays at the population level. That is, instead of evaluating one individual at a time, they accept a 2D array of shape (num_individuals, num_vars) and so that all individuals in the current population are evaluated simultaneously.

Fitness

Fitness is a numerical measure of how well a candidate solution meets the optimization objectives. It assigns each individual a score that guides selection and reproduction in evolutionary algorithms.

In moors, the way to define objective functions for optimization is through a ndarray. Currently, the only dtype supported is f64, we're planning to relax this in the future. It means that when working with a different dtype, such as binary, its values must be trated as f64 (in this case as 0.0 and 1.0).

An example is given below

use ndarray::{Array2, Axis, stack};

/// DTLZ2 for 3 objectives (m = 3) with k = 0 (so num_vars = m−1 = 2):
/// f1 = cos(π/2 ⋅ x0) ⋅ cos(π/2 ⋅ x1)
/// f2 = cos(π/2 ⋅ x0) ⋅ sin(π/2 ⋅ x1)
/// f3 = sin(π/2 ⋅ x0)
fn fitness_dtlz2_3obj(genes: &Array2<f64>) -> Array2<f64> {
    let half_pi = std::f64::consts::PI / 2.0;
    let x0 = genes.column(0).mapv(|v| v * half_pi);
    let x1 = genes.column(1).mapv(|v| v * half_pi);

    let c0 = x0.mapv(f64::cos);
    let s0 = x0.mapv(f64::sin);
    let c1 = x1.mapv(f64::cos);
    let s1 = x1.mapv(f64::sin);

    let f1 = &c0 * &c1;
    let f2 = &c0 * &s1;
    let f3 = s0;

    stack(Axis(1), &[f1.view(), f2.view(), f3.view()]).expect("stack failed")
}

This funcion has the signature (genes: &Array2<f64>) -> Array2<f64> and is a valid function for any moors multi-objective optimization algorithm, such as Nsga2, Nsga3 , etc. Note that this function is poblational, meaning that the whole population is evaluated

A function for a single objective optimization problem has the signature (genes: &Array2<f64>) -> Array1<f64> and is valid for any moors single optimization algorithm. An example is given below

use ndarray::{Array1, Array2, Axix};

/// Simple minimization of 1 - (x**2 + y**2 + z**2)
fn fitness_sphere(population: &Array2<f64>) -> Array1<f64> {
    // For each row [x, y, z], compute 1 - x^2 + y^2 + z^2
    population.map_axis(Axis(1), |row| 1.0 - row.dot(&row))
}

In pymoors, the way to define objective functions for optimization is through a numpy. Currently, the only dtype supported is float, we're planning to relax this in the future. It means that when working with a different dtype, such as binary, its values must be trated as float (in this case as 0.0 and 1.0).

This population-level evaluation is very important—it allows the algorithm to efficiently process and compare many individuals at once. When writing your fitness function, make sure it is vectorized and returns one row per individual, where each row contains the evaluated objective values.

Below is an example fitness function:

import numpy as np

from pymoors.typing import TwoDArray

def fitness_dtlz2_3obj(genes: TwoDArray) -> TwoDArray:
    """
    DTLZ2 for 3 objectives (m = 3) with k = 0 (so num_vars = m−1 = 2):
    f1 = cos(π/2 ⋅ x0) ⋅ cos(π/2 ⋅ x1)
    f2 = cos(π/2 ⋅ x0) ⋅ sin(π/2 ⋅ x1)
    f3 = sin(π/2 ⋅ x0)
    """
    half_pi = np.pi / 2.0
    x0 = genes[:, 0] * half_pi
    x1 = genes[:, 1] * half_pi

    c0 = np.cos(x0)
    s0 = np.sin(x0)
    c1 = np.cos(x1)
    s1 = np.sin(x1)

    f1 = c0 * c1
    f2 = c0 * s1
    f3 = s0

    return np.stack([f1, f2, f3], axis=1)

Constraints

Feasibility is the key concept in constraints. This is very important in optimization, an individual is called feasible if and only if it satisfies all the constraints the problem defines. In moors/pymoors as in many other optimization frameworks, constraints allowed are evaluated as <= 0.0. In genetic algorithms, there are different ways to incorporate feasibility in the search for optimal solutions. In this framework, the guiding philosophy is: feasibility dominates everything, meaning that a feasible individual is always preferred over an infeasible one.

Inequality Constraints

In moors/pymoors as mentioned, any output from a constraint function is evaluated as less than or equal to zero. If this condition is met, the individual is considered feasible. For constraints that are naturally expressed as greater than zero, the user should modify the function by multiplying it by -1, as shown in the following example

use ndarray::{Array1, Array2};

fn constraints_sphere_lower_than_zero(population: &Array2<f64>) -> Array1<f64> {
    // For each row [x, y, z], compute x^2 + y^2 + z^2 - 1 <= 0
    population.map_axis(Axis(1), |row| row.dot(&row)) - 1.0
}

fn constraints_sphere_greather_than_zero(population: &Array2<f64>) -> Array1<f64> {
    // For each row [x, y, z], compute x^2 + y^2 + z^2 - 1 => 0
    1.0 - population.map_axis(Axis(1), |row| row.dot(&row))
}
import numpy as np

from pymoors import Constraints


def constraints_sphere_lower_than_zero(population: np.ndarray) -> np.ndarray:
    """
    For each individual (row) in the population, compute x^2 + y^2 + z^2 - 1.
    Constraint is satisfied when this value is ≤ 0.
    """
    # population shape: (n_individuals, n_dimensions)
    sum_sq = np.sum(population**2, axis=1)
    return sum_sq - 1.0

def constraints_sphere_greater_than_zero(population: np.ndarray) -> np.ndarray:
    """
    For each individual (row) in the population, compute 1 - (x^2 + y^2 + z^2).
    Constraint is satisfied when this value is ≥ 0.
    """
    sum_sq = np.sum(population**2, axis=1)
    return 1.0 - sum_sq


constraints = Constraints(ineq = [constraints_sphere_lower_than_zero, constraints_sphere_greater_than_zero])

constraints as plain numpy function

In pymoors, you can pass to the algorithm a callable, in this scenario you must ensure that the return array is always 2D, even if you work with just one constraint.

Equality Constraints

As is many other frameworks, the known epsilon technique must be used to force \(g(x) = 0\), select a tolerance \(\epsilon\) and then transform \(g\) into an inquality constraint

\[g_{\text{ineq}}(x) = \bigl|g(x)\bigr| - \varepsilon \;\le\; 0.\]

An example is given below

use ndarray::{Array2, Array1, Axis};

const EPSILON: f64 = 1e-6;

/// Returns an Array2 of shape (n, 2) containing two constraints for each row [x, y]:
/// - Column 0: |x + y - 1| - EPSILON ≤ 0 (equality with ε-tolerance)
/// - Column 1: x² + y² - 1.0 ≤ 0 (unit circle inequality)
fn constraints(genes: &Array2<f64>) -> Array2<f64> {
    // Constraint 1: |x + y - 1| - EPSILON
    let eq = genes.map_axis(Axis(1), |row| (row[0] + row[1] - 1.0).abs() - EPSILON);
    // Constraint 2: x^2 + y^2 - 1
    let ineq = genes.map_axis(Axis(1), |row| row[0].powi(2) + row[1].powi(2) - 1.0);
    // Stack into two columns
    stack(Axis(1), &[eq.view(), ineq.view()]).unwrap()
}

This example ilustrates 2 constraints where one of them is an equality constraint.

There is a helper macro moors::impl_constraints_fn that will build the expected constraints for us, trying to simplify at most is possible the boliparte code

use ndarray::{Array2, Array1, Axis};

use moors::impl_constraints_fn;

/// Equality constraint x + y = 1
fn constraints_eq(genes: &Array2<f64>) -> Array1<f64> {
    genes.map_axis(Axis(1), |row| row[0] + row[1] - 1.0)
}

/// Inequality constraint: x² + y² - 1 ≤ 0
fn constraints_ineq(genes: &Array2<f64>) -> Array1<f64> {
    genes.map_axis(Axis(1), |row| row[0].powi(2) + row[1].powi(2) - 1.0)
}

impl_constraints_fn!(
    MyConstraints,
    ineq = [constraints_ineq],
    eq   = [constraints_eq],
);

This macro generates a new struct MyConstraints than can be passed to any algorithm, you can pass multiple inequality/equality constraints to the macro impl_constraints_fn(MyConstraints, ineq =[g1, g2, ...], eq = [h1, h2, ..]). This macro will use the epsilon technique internally using a fixed tolerance of 1e-6, the last in the near future will be seteable by the user.

import numpy as np

from pymoors.typing import TwoDArray

EPSILON = 1e-6

def constraints(genes: TwoDArray) -> TwoDArray:
    """
    Compute two constraints for each row [x, y] in genes:
      - Column 0: |x + y - 1| - EPSILON ≤ 0  (equality with ε-tolerance)
      - Column 1: x² + y² - 1.0 ≤ 0         (unit circle inequality)
    Returns an array of shape (n, 2).
    """
    # genes is expected to be shape (n_individuals, 2)
    x = genes[:, 0]
    y = genes[:, 1]

    # Constraint 1: |x + y - 1| - EPSILON
    eq = np.abs(x + y - 1.0) - EPSILON

    # Constraint 2: x^2 + y^2 - 1
    ineq = x**2 + y**2 - 1.0

    return np.stack((eq, ineq), axis=1)

This example ilustrates 2 constraints where one of them is an equality constraint. The pymoors.Constraints class lets us skip having to manually implement the epsilon technique; we can simply do

from pymoors import Constraints
from pymoors.typing import TwoDArray, OneDArray

EPSILON = 1e-6

def eq_constr(genes: TwoDArray) -> OneDArray:
    return genes[:, 0] + genes[:, 1] - 1

def ineq_constr(genes: TwoDArray) -> OneDArray:
    return genes[:, 0]**2 + genes[:, 1]**2 - 1

constraints = Constraints(eq = [eq_constr], ineq = [ineq_constr])

Lower and Upper Bounds

Also this macro has two optional arguments lower_bound and upper_bound that will make each gene bounded by those values.

use ndarray::{Array2, Array1, Axis};

use moors::impl_constraints_fn;

/// Equality constraint x + y = 1
fn constraints_eq(genes: &Array2<f64>) -> Array1<f64> {
    genes.map_axis(Axis(1), |row| row[0] + row[1] - 1.0)
}

impl_constraints_fn!(
    MyBoundedConstraints,
    eq   = [constraints_eq],
    lower_bound = -1.0,
    upper_bound = 1.0
);

ConstraintsFn trait

Internally, constraints as an argument to genetic algorithms is actually any type that implements ConstraintsFn. The impl_constraints_fn macro creates a struct that implements this trait. The types Fn(&Array2<f64>) -> Array1<f64> and Fn(&Array2<f64>) -> Array2<f64> automatically implement this trait.

Also this class has two optional arguments lower_bound and upper_bound that will make each gene bounded by those values.

import numpy as np

from pymoors import Constraints

EPSILON = 1e-6

def eq_constr(genes: np.ndarray):
    return genes[:, 0] + genes[:, 1] - 1

constraints = Constraints(eq = [eq_constr], lower_bound = 0.0, upper_bound = 1.0)