Skip to content

pysne.problems.base

back to reference

from abc import ABC, abstractmethod
import numpy as np
from pysne.utils import is_in_domain, objective_function, filter_unique_roots, create_continuous_bounds, sort_unique_roots

class BaseProblem(ABC):
    """
    Abstract Base Class for all optimization problems in pysne.
    """

    @property
    @abstractmethod
    def name(self):
        """Name of the problem (must be defined by subclasses)."""
        pass

    @property
    def optima_type(self):
        """Type of optimization: 'max', 'min', or 'both'."""
        return "both"

    @abstractmethod
    def g_func(self, x):
        """
        Primary objective function.
        Accepts input x and returns the fitness value.
        """
        pass

    def __init__(self):
        domain_info = self.get_info()
        if isinstance(domain_info, tuple) and len(domain_info) == 2:
            self.domain = domain_info[0]
        else:
            self.domain = domain_info
        self.n_var = len(self.domain)
        self.equations = None

    @abstractmethod
    def get_info(self):
        """
        Returns the problem configuration as a tuple (domain, params) or just domain.
        """
        pass

    @abstractmethod
    def evaluate_fitness(self, x):
        """Each problem type defines its own fitness evaluation method."""
        pass

    @abstractmethod
    def select_final_optimal(self, candidates):
        """Each problem type defines its own method for filtering final optimal solutions."""
        pass


class SNEProblem(BaseProblem):
    """Base class for Systems of Nonlinear Equations with residual-based filtering (1.0 - f < eps)."""
    problem_type = 'SNE'

    def __init__(self):
        super().__init__()
        self.equations = self.get_equations()

    def get_equations(self):
        return []

    def g_func(self, x):
        return objective_function(x, self.equations)

    def evaluate_fitness(self, x):
        return self.g_func(x)

    def select_final_roots(self, candidates):
        domain, params = self.get_info()
        epsilon = params.get('epsilon', 1e-7)
        delta = params.get('delta', 0.01)

        accurate_candidates = []
        for cand in candidates:
            if not is_in_domain(cand, domain):
                continue

            f_val = self.evaluate_fitness(cand)
            if 1.0 - f_val < epsilon:
                accurate_candidates.append((cand, f_val))

        return filter_unique_roots(accurate_candidates, delta)

    def select_final_optimal(self, candidates):
        """Alias: SNE problems use the term 'roots'."""
        return self.select_final_roots(candidates)


class MultimodalProblem(BaseProblem):
    """Base class for multimodal optimization problems."""
    problem_type = 'Multimodal'

    def evaluate_fitness(self, x):
        return self.g_func(x)

    def select_final_optimal(self, candidates):
        domain, params = self.get_info()
        delta = params.get('delta', 0.1)
        epsilon = params.get('epsilon', 1e-7)
        gamma = params.get('gamma', None)

        # Calculate F_star for multimodal global filter
        F_star = 0
        if candidates is not None and len(candidates) > 0:
            evals = [self.evaluate_fitness(c) for c in candidates if is_in_domain(c, domain)]
            if evals:
                F_star = max(evals)

        accurate_candidates = []
        for cand in candidates:
            if not is_in_domain(cand, domain):
                continue

            f_val = self.evaluate_fitness(cand)

            if gamma is not None and gamma != -float('inf') and F_star > 0:
                if f_val <= (1.0 - epsilon) * F_star:
                    continue

            # Neighbor filter
            is_peak = True
            pert_step = epsilon

            for i in range(len(cand)):
                step = np.zeros_like(cand)
                step[i] = pert_step

                nb_minus = cand - step
                if is_in_domain(nb_minus, domain) and self.evaluate_fitness(nb_minus) > f_val:
                    is_peak = False
                    break

                nb_plus = cand + step
                if is_in_domain(nb_plus, domain) and self.evaluate_fitness(nb_plus) > f_val:
                    is_peak = False
                    break

            if is_peak:
                accurate_candidates.append((cand, f_val))

        return filter_unique_roots(accurate_candidates, delta)

class DiophantineProblem(BaseProblem):
    """Base class for Diophantine (integer) equation problems."""
    problem_type = 'Diophantine'

    def __init__(self):
        # Supports two subclass styles:
        #  (a) override get_info() directly -> (integer_domain, params), same as SNEProblem
        #  (b) override get_integer_domain() + get_params() only
        if type(self).get_info is not DiophantineProblem.get_info:
            raw_domain, self.raw_params = type(self).get_info(self)
        else:
            raw_domain = self.get_integer_domain()
            self.raw_params = self.get_params()

        self.integer_domain = raw_domain
        self._continuous_domain = create_continuous_bounds(raw_domain)
        super().__init__()
        self.equations = self.get_equations()
        self.domain = self._continuous_domain

    def get_integer_domain(self):
        return self.integer_domain

    def get_equations(self):
        return []

    def get_params(self):
        return self.raw_params if hasattr(self, 'raw_params') else {}

    def get_info(self):
        domain = self._continuous_domain
        params = self.get_params()
        return domain, params

    def g_func(self, x):
        # By default for Diophantine we evaluate using rounded integer values
        q = np.round(x).astype(object)
        return objective_function(q, self.equations)

    def evaluate_fitness(self, x):
        q = np.round(x).astype(object)
        if not is_in_domain(q, self.integer_domain):
            return 0.0
        return objective_function(q, self.equations)

    def select_final_roots(self, candidates):
        domain, params = self.get_info()
        epsilon = params.get('epsilon', 1e-7)
        delta = params.get('delta', 0.5)

        symmetric_names = {
            "DiophantineProblem3a", "DiophantineProblem3b", 
            "DiophantineProblem4_4", "DiophantineProblem4_5", 
            "DiophantineProblem4_6", "DiophantineProblem4_7", 
            "DiophantineProblem4_8", "DiophantineProblem4_9", 
            "DiophantineProblem4_10"
        }
        sort_solutions = params.get('sort_solutions', self.__class__.__name__ in symmetric_names)


        accurate_candidates = []
        seen = set()
        for cand in candidates:
            q_cand = np.round(cand)
            q_cand_int = q_cand.astype(int)
            q_tuple = tuple(q_cand_int)

            if q_tuple in seen:
                continue
            seen.add(q_tuple)

            if not is_in_domain(q_cand_int, self.integer_domain):
                continue

            f_val = self.evaluate_fitness(q_cand_int)
            if 1.0 - f_val <= epsilon:
                accurate_candidates.append((q_cand_int.astype(float), f_val))

        roots = filter_unique_roots(accurate_candidates, delta)
        if len(roots) > 0:
            roots = sort_unique_roots(roots, sort=sort_solutions)
            roots = np.array(roots)

        return roots

    def select_final_optimal(self, candidates):
        """Alias: Diophantine problems use the term 'roots'."""
        return self.select_final_roots(candidates)

class MinimizedProblem(MultimodalProblem):
    """
    Wrapper class to invert the fitness of an existing problem for minimization search.
    """
    def __init__(self, original_prob):
        self.original = original_prob
        self.domain = original_prob.domain
        self.n_var = original_prob.n_var
        self.equations = original_prob.equations
        self.problem_type = getattr(original_prob, 'problem_type', 'Multimodal')

    @property
    def name(self):
        return f"{self.original.name} (Minimized)"

    def get_info(self):
        return self.original.get_info()

    def g_func(self, x):
        return -self.original.g_func(x)

    def evaluate_fitness(self, x):
        return -self.original.evaluate_fitness(x)

    def select_final_optimal(self, candidates):
        original_class = self.original.__class__
        if hasattr(self.original, 'select_final_optimal') and original_class.select_final_optimal != MultimodalProblem.select_final_optimal:
            domain, params = self.get_info()
            delta = params.get('delta', 0.5)
            accurate_candidates = []
            for cand in candidates:
                if is_in_domain(cand, domain):
                    accurate_candidates.append((cand, self.evaluate_fitness(cand)))
            return filter_unique_roots(accurate_candidates, delta)
        else:
            return super().select_final_optimal(candidates)