Source code for saealib.problem.constraint

"""Constraint classes and handlers."""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

import numpy as np

if TYPE_CHECKING:
    from saealib.population import Population


[docs] class InequalityConstraint: """ A single inequality constraint: g(x) <= threshold. Parameters ---------- func : callable Constraint function g(x). Should return a scalar float. threshold : float, optional Right-hand side of the constraint g(x) <= threshold. Default: 0.0. """
[docs] def __init__(self, func: Callable[..., Any], threshold: float = 0.0): self.func = func self.threshold = threshold
[docs] def evaluate(self, x: np.ndarray) -> float: """Return the raw constraint value g(x).""" return float(self.func(x))
[docs] def violation_from_value(self, g: float) -> float: """ Return the violation for an already-evaluated raw value g(x). This is the single source of truth for the per-constraint violation formula: :meth:`violation`, :meth:`evaluate_with_violation`, and :class:`StaticToleranceHandler` all delegate to it. Subclasses (e.g. :class:`EqualityConstraint`) override this one method to change how a raw value maps to a violation, without re-evaluating ``func``. Parameters ---------- g : float Raw constraint value g(x). Returns ------- float Constraint violation: max(0, g - threshold). """ return max(0.0, g - self.threshold)
[docs] def violation(self, x: np.ndarray) -> float: """Return constraint violation: max(0, g(x) - threshold).""" return self.violation_from_value(self.evaluate(x))
[docs] def evaluate_with_violation(self, x: np.ndarray) -> tuple[float, float]: """ Return the raw value and violation with a single function evaluation. Returns ------- g : float Raw constraint value g(x). cv : float Constraint violation: max(0, g(x) - threshold). """ g = self.evaluate(x) return g, self.violation_from_value(g)
[docs] def gradient(self, x: np.ndarray) -> np.ndarray | None: """ Return the gradient of g(x) with respect to x, if available. Override this in a subclass to enable gradient-based constraint handlers (e.g. gradient-based repair). The default returns ``None``, signalling that no analytical Jacobian is provided. Parameters ---------- x : np.ndarray The solution at which to evaluate the gradient. shape = (dim, ) Returns ------- np.ndarray or None Gradient vector with shape = (dim, ), or ``None`` when no gradient is available. """ return None
[docs] class EqualityConstraint(InequalityConstraint): """ A single equality constraint: h(x) = 0, satisfied within a tolerance. The violation is ``max(0, |h(x)| - tolerance)``, reusing the inequality violation path so that mixed inequality/equality problems aggregate through the same :class:`ConstraintHandler`. Only :meth:`violation_from_value` is overridden; :meth:`violation` and :meth:`evaluate_with_violation` inherit the equality behavior automatically. Parameters ---------- func : callable Constraint function h(x). Should return a scalar float. tolerance : float, optional Feasibility tolerance for ``|h(x)| <= tolerance``. Default: 1e-6. Set ``tolerance=0.0`` to defer the feasibility threshold to a handler such as ``EpsilonConstraintHandler``, where the threshold is managed externally rather than baked into the constraint. Notes ----- Provide an analytical Jacobian by overriding :meth:`gradient` to return ``∇h(x)``; this enables gradient-based constraint handlers (e.g. repair). """
[docs] def __init__(self, func: Callable[..., Any], tolerance: float = 1e-6): super().__init__(func, threshold=0.0) self.tolerance = tolerance
[docs] def violation_from_value(self, g: float) -> float: """Return equality violation: max(0, abs(h(x)) - tolerance).""" return max(0.0, abs(g) - self.tolerance)
[docs] class ConstraintHandler(ABC): """ Pluggable constraint-processing strategy. A ``ConstraintHandler`` exposes the constraint-handling lifecycle as a set of overridable hooks, decoupling the per-constraint violation formula and the aggregation method from the core :class:`Problem`. This lets research code swap in alternative strategies (e.g. ε-constraint, penalty functions, gradient-based repair, augmented Lagrangian) without forking core classes. Lifecycle:: Ask -> [repair(x, constraints, lb, ub)] -> evaluate f, g -> [compute_cv(constraints, x, g)] -> cv -> [augment_objective(f, constraints, x, g)] -> f' Tell -> Comparator(f', cv) with eps_cv = feasibility_threshold Generation end -> [on_generation_end(gen, population)] Only :meth:`compute_cv` is abstract; the remaining hooks have sensible defaults so that subclasses implement just what they need. """
[docs] def repair( self, x: np.ndarray, constraints: list[InequalityConstraint], lb: np.ndarray, ub: np.ndarray, **kwargs, ) -> np.ndarray: """ Repair a design vector before evaluation. Called by the algorithm after each variation operator (crossover / mutation) and before objective evaluation. The default clips ``x`` to ``[lb, ub]``, reproducing the behaviour of ``repair_clipping``. Subclasses override this to add domain-constraint repair (e.g. a Newton step toward an equality-constraint manifold) on top of—or instead of—bounds clipping. Parameters ---------- x : np.ndarray The design vector to repair. shape = (dim, ) constraints : list[InequalityConstraint] The problem's domain constraints. lb : np.ndarray Lower bounds. shape = (dim, ) ub : np.ndarray Upper bounds. shape = (dim, ) **kwargs Reserved for future use (e.g. parent solution for reflection repair). Returns ------- np.ndarray The repaired design vector. shape = (dim, ) """ return np.clip(x, lb, ub)
[docs] @abstractmethod def compute_cv( self, constraints: list[InequalityConstraint], x: np.ndarray, g: np.ndarray, ) -> float: """ Aggregate raw constraint values into a scalar constraint violation. Parameters ---------- constraints : list[InequalityConstraint] The problem's inequality constraints, aligned with ``g``. x : np.ndarray The evaluated design vector. shape = (dim, ) g : np.ndarray Raw constraint values g(x). shape = (n_constraints, ) Returns ------- float Aggregate constraint violation (``cv``); ``0.0`` means feasible. """ ...
[docs] def augment_objective( self, f: np.ndarray, constraints: list[InequalityConstraint], x: np.ndarray, g: np.ndarray, ) -> np.ndarray: """ Transform objective values using constraint information. Used by penalty-based or augmented-Lagrangian strategies. The default is the identity (objectives are returned unchanged). Parameters ---------- f : np.ndarray Raw objective values. shape = (n_obj, ) constraints : list[InequalityConstraint] The problem's inequality constraints, aligned with ``g``. x : np.ndarray The evaluated design vector. shape = (dim, ) g : np.ndarray Raw constraint values g(x). shape = (n_constraints, ) Returns ------- np.ndarray The (possibly) augmented objective values. shape = (n_obj, ) """ return f
@property def feasibility_threshold(self) -> float: """Constraint-violation threshold below which a solution is feasible.""" return 1e-6
[docs] def on_generation_end(self, gen: int, population: Population) -> None: """ Run end-of-generation bookkeeping. Used by adaptive strategies (e.g. ε-level control) that update their internal state between generations. The default is a no-op. Parameters ---------- gen : int The generation index that just finished. population : Population The current population. """
[docs] class StaticToleranceHandler(ConstraintHandler): """ Default constraint handler reproducing the static-tolerance behavior. The constraint violation is the sum of per-constraint violations ``sum(c_i.violation_from_value(g_i))`` and the feasibility threshold is a fixed ``eps_cv``. Each constraint maps its own raw value to a violation, so inequality (``max(0, g - threshold)``) and equality (``max(0, |h| - tolerance)``) constraints can be mixed freely. Objectives are not augmented. Parameters ---------- eps_cv : float, optional Feasibility threshold returned by :attr:`feasibility_threshold`. Default: 1e-6. """
[docs] def __init__(self, eps_cv: float = 1e-6): self._eps_cv = eps_cv
[docs] def compute_cv( self, constraints: list[InequalityConstraint], x: np.ndarray, g: np.ndarray, ) -> float: """Return the sum of per-constraint ``c_i.violation_from_value(g_i)``.""" cv = 0.0 for gi, c in zip(g, constraints): cv += c.violation_from_value(float(gi)) return cv
@property def feasibility_threshold(self) -> float: """Fixed feasibility threshold ``eps_cv``.""" return self._eps_cv
[docs] class EpsilonConstraintHandler(ConstraintHandler): """ Dynamic ε-constraint handler. The feasibility threshold ε starts at ``schedule(0)`` and is updated to ``schedule(gen)`` at the end of each generation via :meth:`on_generation_end`. As ε decreases toward 0 the population is gradually driven into the feasible region. Each call to :meth:`on_generation_end` sets ``comparator.eps_cv = feasibility_threshold`` via the runner, so the comparator's feasibility criterion tightens in sync with ε. Parameters ---------- schedule : callable Maps generation index (int) → current ε value (float). ``schedule(0)`` is the initial ε; it should decrease toward 0 over generations. Notes ----- :meth:`compute_cv` aggregates violations as: - *Equality* constraints: ``|h(x)|`` (raw absolute value; the ``EqualityConstraint.tolerance`` field is intentionally bypassed so that ε controls the feasibility threshold globally). - *Inequality* constraints: ``max(0, g(x) - threshold) ** 2`` (squared positive part; the constraint's ``threshold`` still applies). This follows the constraint-violation formulation summarized by Mezura-Montes & Coello Coello (2011): inequality violations are squared, equality violations are the unsquared absolute value. The cited survey states the formulation for constraint values that have been normalised beforehand; this handler aggregates the raw ``g``/``h`` values without that normalisation, so constraints on very different scales will not contribute evenly to ``cv``. The ε-constrained method itself originates from Takahama & Sakai (2006). The aggregation described above is the one given by the Mezura-Montes & Coello Coello (2011) survey, which attributes the method to them; it is not Takahama & Sakai's own, which raises the inequality and equality terms to one shared exponent rather than squaring only the inequalities. References ---------- :cite:`mezuramontes2011epsilon`: Mezura-Montes, E., & Coello Coello, C. A. (2011). Constraint-handling in nature-inspired numerical optimization: Past, present and future. *Swarm and Evolutionary Computation*, 1(4), 173-194. """
[docs] def __init__(self, schedule: Callable[[int], float]): self._schedule = schedule self._eps: float = float(schedule(0))
[docs] def compute_cv( self, constraints: list[InequalityConstraint], x: np.ndarray, g: np.ndarray, ) -> float: """Return ``|h|`` for equality and ``max(0, g-threshold)**2`` for inequality.""" cv = 0.0 for gi, c in zip(g, constraints): if isinstance(c, EqualityConstraint): cv += abs(float(gi)) else: cv += max(0.0, float(gi) - c.threshold) ** 2 return cv
@property def feasibility_threshold(self) -> float: """Current ε; the comparator treats ``cv <= ε`` as feasible.""" return self._eps
[docs] def on_generation_end(self, gen: int, population: Population) -> None: """Update internal ε to ``schedule(gen)``.""" self._eps = float(self._schedule(gen))
def _numerical_gradient( c: InequalityConstraint, x: np.ndarray, g0: float ) -> np.ndarray: """ Forward-difference approximation of ``c``'s gradient at ``x``. Step size ``sqrt(eps_machine) * max(1, |x_i|)`` follows the standard forward-difference rule of thumb (balances truncation vs. rounding error) scaled to the magnitude of each coordinate, since the paper only specifies "a small positive scalar" without a concrete value. """ step_scale = np.sqrt(np.finfo(float).eps) grad = np.empty_like(x) for i in range(x.shape[0]): step = step_scale * max(1.0, abs(x[i])) x_perturbed = x.copy() x_perturbed[i] += step grad[i] = (c.evaluate(x_perturbed) - g0) / step return grad
[docs] class GradientRepairHandler(ConstraintHandler): """ Gradient-based constraint repair handler. Implements the repair procedure of Chootinan & Chen (2006): each call to :meth:`repair` runs up to ``max_iter`` outer iterations. In each iteration, the set of currently-violated constraints is determined (equality: ``|h(x)| > tolerance``; inequality: ``g(x) > threshold``), their raw values and gradients are stacked into ``ΔV`` and the Jacobian ``J``, and a single simultaneous update is applied:: Δx = pinv(J) @ ΔV x <- x + Δx using the Moore-Penrose pseudoinverse, since ``J`` is generally not square. Satisfied constraints contribute no row to ``J``/``ΔV`` for that iteration, per the paper's remark that "there is no need to adjust ... non-violated constraints." Iteration stops early once no constraint is violated, or once ``max(|Δx|) < epsilon``. ``ΔV`` follows the cited repair procedure. For an inequality constraint (single upper bound ``threshold`` only, per :class:`InequalityConstraint`), ``ΔV_i = threshold - g(x)``. For an :class:`EqualityConstraint` (``h(x) = 0`` by construction, i.e. the target value is already embedded as 0), ``ΔV_i = -h(x)``; both are the paper's "target minus current value." The paper's worked example uses ``ΔV = c - h(x) = 1``, which is the sign that the printed equality row does not literally show but the numeric example requires). When :meth:`~InequalityConstraint.gradient` returns ``None`` for a violated constraint, :func:`_numerical_gradient` supplies a forward-difference approximation instead of skipping it — this is what lets the handler now repair both equality and inequality constraints regardless of whether an analytical Jacobian is available. After all iterations the result is clipped to ``[lb, ub]`` so bounds feasibility is always guaranteed. Parameters ---------- max_iter : int, optional Maximum number of outer iterations per call. Default: 1. epsilon : float, optional Convergence threshold: iteration stops early once ``max(abs(Δx)) < epsilon``. Default: 1e-6. References ---------- :cite:`chootinan2006gradientrepair`: Chootinan, P., & Chen, A. (2006). Constraint handling in genetic algorithms using a gradient-based repair method. *Computers & Operations Research*, 33(8), 2263-2281. """
[docs] def __init__(self, max_iter: int = 1, epsilon: float = 1e-6): self.max_iter = max_iter self.epsilon = epsilon
[docs] def repair( self, x: np.ndarray, constraints: list[InequalityConstraint], lb: np.ndarray, ub: np.ndarray, **kwargs, ) -> np.ndarray: """Apply simultaneous pseudoinverse updates toward feasibility, then clip.""" x = x.copy() for _ in range(self.max_iter): rows = [] delta_v = [] for c in constraints: value = c.evaluate(x) if isinstance(c, EqualityConstraint): if abs(value) <= c.tolerance: continue dv = -value else: if value <= c.threshold: continue dv = c.threshold - value grad = c.gradient(x) if grad is None: grad = _numerical_gradient(c, x, value) rows.append(grad) delta_v.append(dv) if not rows: break jacobian = np.stack(rows) dx = np.linalg.pinv(jacobian) @ np.array(delta_v) x = x + dx if np.max(np.abs(dx)) < self.epsilon: break return np.clip(x, lb, ub)
[docs] def compute_cv( self, constraints: list[InequalityConstraint], x: np.ndarray, g: np.ndarray, ) -> float: """Return sum of per-constraint violations (same as StaticToleranceHandler).""" cv = 0.0 for gi, c in zip(g, constraints): cv += c.violation_from_value(float(gi)) return cv
[docs] def linear_epsilon_schedule(eps0: float, n_gen: int) -> Callable[[int], float]: """ Return a linear ε schedule: ``eps0`` at gen 0, ``0.0`` at gen ``n_gen``. Parameters ---------- eps0 : float Initial ε value (gen 0). n_gen : int Generation at which ε reaches 0. Returns ------- callable ``schedule(gen) -> float`` """ def _schedule(gen: int) -> float: return max(0.0, eps0 * (1.0 - gen / n_gen)) return _schedule
[docs] def exponential_epsilon_schedule(eps0: float, decay: float) -> Callable[[int], float]: """ Return an exponential ε schedule: ``eps0 * decay**gen``. Parameters ---------- eps0 : float Initial ε value (gen 0). decay : float Multiplicative decay factor per generation (0 < decay < 1). Returns ------- callable ``schedule(gen) -> float`` """ def _schedule(gen: int) -> float: return eps0 * (decay**gen) return _schedule