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