diff --git a/aisp/__init__.py b/aisp/__init__.py index 1363c0f..1fa7127 100644 --- a/aisp/__init__.py +++ b/aisp/__init__.py @@ -26,12 +26,17 @@ https://ais-package.github.io/docs/intro """ -from importlib.metadata import version +from importlib.metadata import version, PackageNotFoundError from . import csa from . import ina from . import nsa +try: + __version__ = version('aisp') +except PackageNotFoundError: + __version__ = '0.dev' + __author__ = "AISP Development Team" -__version__ = version('aisp') + __all__ = ["csa", "nsa", "ina"] diff --git a/aisp/csa/_ai_recognition_sys.py b/aisp/csa/_ai_recognition_sys.py index 6c16ca7..259f4c9 100644 --- a/aisp/csa/_ai_recognition_sys.py +++ b/aisp/csa/_ai_recognition_sys.py @@ -3,6 +3,7 @@ from __future__ import annotations import random +from numbers import Real from operator import attrgetter from typing import List, Optional, Dict, Tuple, Any, Union @@ -18,7 +19,6 @@ from ..utils.distance import hamming, compute_metric_distance, get_metric_code from ..utils.multiclass import predict_knn_affinity from ..utils.random import set_seed_numba -from ..utils.sanitizers import sanitize_param, sanitize_seed, sanitize_choice from ..utils.types import FeatureType, MetricType from ..utils.validation import ( detect_vector_data_type, @@ -26,6 +26,12 @@ check_shape_match, check_feature_dimension, check_binary_array, + positive, + is_type, + between, + choice, + optional, + validate_parameters, ) @@ -111,6 +117,22 @@ class AIRS(BaseClassifier): [0 1] """ + @validate_parameters( + n_resources=(is_type(Real), positive), + rate_clonal=(is_type(int), positive), + rate_mc_init=(is_type(Real), between(0, 1)), + rate_hypermutation=(is_type(Real), positive), + affinity_threshold_scalar=(is_type(Real), positive), + k=(is_type(int), positive), + max_iters=(is_type(int), positive), + resource_amplified=(is_type(Real), positive), + metric=( + is_type(str), + choice(["manhattan", "minkowski", "euclidean"]) + ), + seed=optional((is_type(int), positive)), + p=(is_type(Real), positive), + ) def __init__( self, n_resources: float = 10, @@ -125,33 +147,23 @@ def __init__( seed: Optional[int] = None, p: float = 2.0, ) -> None: - self.n_resources: float = sanitize_param(n_resources, 10, lambda x: x >= 1) - self.rate_mc_init: float = sanitize_param( - rate_mc_init, 0.2, lambda x: 0 < x <= 1 - ) - self.rate_clonal: int = sanitize_param(rate_clonal, 10, lambda x: x > 0) - self.rate_hypermutation: float = sanitize_param( - rate_hypermutation, 0.75, lambda x: x > 0 - ) - self.affinity_threshold_scalar: float = sanitize_param( - affinity_threshold_scalar, 0.75, lambda x: x > 0 - ) - self.resource_amplified: float = sanitize_param( - resource_amplified, 1, lambda x: x > 1 - ) - self.k: int = sanitize_param(k, 3, lambda x: x > 0) - self.max_iters: int = sanitize_param(max_iters, 100, lambda x: x > 0) - self.seed: Optional[int] = sanitize_seed(seed) + self.n_resources: float = n_resources + self.rate_mc_init: float = rate_mc_init + self.rate_clonal: int = rate_clonal + self.rate_hypermutation: float = rate_hypermutation + self.affinity_threshold_scalar: float = affinity_threshold_scalar + self.resource_amplified: float = resource_amplified + self.k: int = k + self.max_iters: int = max_iters + self.metric = metric + self.seed: Optional[int] = seed if self.seed is not None: np.random.seed(self.seed) set_seed_numba(self.seed) + self.p: float = p self._feature_type: FeatureType = "continuous-features" - self.metric = sanitize_choice(metric, ["manhattan", "minkowski"], "euclidean") - - self.p: float = p - self._cells_memory: Optional[Dict[str | int, list[BCell]]] = None self._all_class_cell_vectors: Optional[List[Tuple[Any, np.ndarray]]] = None self.affinity_threshold: float = 0.0 diff --git a/aisp/csa/_clonalg.py b/aisp/csa/_clonalg.py index a32fb52..cc846f6 100644 --- a/aisp/csa/_clonalg.py +++ b/aisp/csa/_clonalg.py @@ -3,6 +3,7 @@ from __future__ import annotations import heapq +from numbers import Real from typing import Optional, Callable, Dict, Literal, List import numpy as np @@ -19,8 +20,9 @@ from ..base.immune.populations import generate_random_antibodies from ..utils.display import ProgressTable from ..utils.random import set_seed_numba -from ..utils.sanitizers import sanitize_seed, sanitize_param, sanitize_bounds +from ..utils.sanitizers import sanitize_bounds from ..utils.types import FeatureTypeAll +from ..utils.validation import validate_parameters, is_type, positive, choice, optional class Clonalg(BaseOptimizer): @@ -106,6 +108,22 @@ class Clonalg(BaseOptimizer): Best cost: 0.02623036956750724 """ + @validate_parameters( + problem_size=(is_type(int), positive), + N=(is_type(int), positive), + rate_clonal=(is_type(int), positive), + rate_hypermutation=(is_type(Real), positive), + n_diversity_injection=(is_type(int), positive), + selection_size=(is_type(int), positive), + feature_type=( + is_type(str), + choice([ + "binary-features", "continuous-features", "ranged-features", "permutation-features" + ]) + ), + mode=(is_type(str), choice(["min", "max"])), + seed=optional((is_type(int), positive)) + ) def __init__( self, problem_size: int, @@ -121,34 +139,21 @@ def __init__( seed: Optional[int] = None, ): super().__init__() - self.problem_size = sanitize_param(problem_size, 1, lambda x: x > 0) - self.N: int = sanitize_param(N, 50, lambda x: x > 0) - self.rate_clonal: int = sanitize_param(rate_clonal, 10, lambda x: x > 0) - self.rate_hypermutation: np.float64 = np.float64( - sanitize_param( - rate_hypermutation, 1.0, lambda x: x > 0 - ) - ) - self.n_diversity_injection: int = sanitize_param( - n_diversity_injection, 5, lambda x: x > 0 - ) - self.selection_size: int = sanitize_param( - selection_size, 5, lambda x: x > 0 - ) + self.problem_size = problem_size + self.N: int = N + self.rate_clonal: int = rate_clonal + self.rate_hypermutation: float = rate_hypermutation + self.n_diversity_injection: int = n_diversity_injection + self.selection_size: int = selection_size self._affinity_function = affinity_function self.feature_type: FeatureTypeAll = feature_type + self.mode: Literal["min", "max"] = mode + self.seed: Optional[int] = seed self._bounds: Optional[Dict] = None self._bounds_extend_cache: Optional[np.ndarray] = None self.bounds = bounds - self.mode: Literal["min", "max"] = sanitize_param( - mode, - "min", - lambda x: x == "max" - ) - - self.seed: Optional[int] = sanitize_seed(seed) if self.seed is not None: np.random.seed(self.seed) set_seed_numba(self.seed) diff --git a/aisp/ina/_ai_network.py b/aisp/ina/_ai_network.py index 39ff917..23ca5c8 100644 --- a/aisp/ina/_ai_network.py +++ b/aisp/ina/_ai_network.py @@ -2,6 +2,7 @@ from __future__ import annotations +from numbers import Real from typing import Optional, Dict, List, Tuple, Union import numpy as np @@ -22,13 +23,12 @@ from ..utils.distance import hamming, compute_metric_distance, get_metric_code from ..utils.multiclass import predict_knn_affinity from ..utils.random import set_seed_numba -from ..utils.sanitizers import sanitize_choice, sanitize_param, sanitize_seed from ..utils.types import FeatureType, MetricType from ..utils.validation import ( detect_vector_data_type, check_array_type, check_feature_dimension, - check_binary_array, + check_binary_array, validate_parameters, is_type, positive, choice, optional, non_negative, ) @@ -124,11 +124,27 @@ class AiNet(BaseClusterer): [0 1] """ + @validate_parameters( + N=(is_type(int), positive), + n_clone=(is_type(int), positive), + top_clonal_memory_size=optional((is_type(int), positive)), + affinity_threshold=(is_type(Real), positive), + suppression_threshold=(is_type(Real), positive), + mst_inconsistency_factor=(is_type(Real), non_negative), + max_iterations=(is_type(int), positive), + k=(is_type(int), positive), + metric=( + is_type(str), + choice(["manhattan", "minkowski", "euclidean"]) + ), + seed=optional((is_type(int), positive)), + p=(is_type(Real), positive), + ) def __init__( self, N: int = 50, n_clone: int = 10, - top_clonal_memory_size: int = 5, + top_clonal_memory_size: Optional[int] = 5, n_diversity_injection: int = 5, affinity_threshold: float = 0.5, suppression_threshold: float = 0.5, @@ -140,39 +156,23 @@ def __init__( use_mst_clustering: bool = True, p: float = 2.0 ): - self.N: int = sanitize_param(N, 50, lambda x: x > 0) - self.n_clone: int = sanitize_param(n_clone, 10, lambda x: x > 0) - - self.top_clonal_memory_size: Optional[int] = None - if top_clonal_memory_size is not None: - self.top_clonal_memory_size = sanitize_param( - top_clonal_memory_size, 5, lambda x: x > 0 - ) - - self.n_diversity_injection: int = sanitize_param( - n_diversity_injection, 5, lambda x: x > 0 - ) - self.affinity_threshold: float = sanitize_param( - affinity_threshold, 0.5, lambda x: x > 0 - ) - self.suppression_threshold: float = sanitize_param( - suppression_threshold, 0.5, lambda x: x > 0 - ) - self.mst_inconsistency_factor: float = sanitize_param( - mst_inconsistency_factor, 2, lambda x: x >= 0 - ) - self.max_iterations: int = sanitize_param(max_iterations, 10, lambda x: x > 0) - self.k: int = sanitize_param(k, 1, lambda x: x > 0) - self.seed: Optional[int] = sanitize_seed(seed) + self.N: int = N + self.n_clone: int = n_clone + self.top_clonal_memory_size: Optional[int] = top_clonal_memory_size + self.n_diversity_injection: int = n_diversity_injection + self.affinity_threshold: float = affinity_threshold + self.suppression_threshold: float = suppression_threshold + self.mst_inconsistency_factor: float = mst_inconsistency_factor + self.max_iterations: int = max_iterations + self.k: int = k + self.seed: Optional[int] = seed self.use_mst_clustering: bool = use_mst_clustering if self.seed is not None: np.random.seed(self.seed) set_seed_numba(self.seed) self._feature_type: FeatureType = "continuous-features" - self.metric: str = sanitize_choice( - metric, ["euclidean", "manhattan", "minkowski"], "euclidean" - ) + self.metric: str = metric self.p: float = p self._metric_params = {} diff --git a/aisp/nsa/_binary_negative_selection.py b/aisp/nsa/_binary_negative_selection.py index 1791a6b..8ee8d55 100644 --- a/aisp/nsa/_binary_negative_selection.py +++ b/aisp/nsa/_binary_negative_selection.py @@ -2,6 +2,7 @@ from __future__ import annotations +from numbers import Real from typing import Dict, Literal, Optional, Union import numpy as np @@ -11,12 +12,12 @@ from ..base import BaseClassifier from ..exceptions import MaxDiscardsReachedError, ModelNotFittedError from ..utils.display import ProgressBar -from ..utils.sanitizers import sanitize_seed, sanitize_param +from ..utils.random import set_seed_numba from ..utils.validation import ( check_array_type, check_shape_match, check_binary_array, - check_feature_dimension, + check_feature_dimension, validate_parameters, positive, choice, between, optional, is_type, ) @@ -96,6 +97,16 @@ class BNSA(BaseClassifier): ['non-self' 'self'] """ + @validate_parameters( + N=(is_type(int), positive), + aff_thresh=(is_type(Real), between(0, 1)), + max_discards=(is_type(int), positive), + seed=optional((is_type(int), positive)), + no_label_sample_selection=( + is_type(str), + choice(["max_average_difference", "max_nearest_difference"]) + ), + ) def __init__( self, N: int = 100, @@ -106,25 +117,22 @@ def __init__( "max_average_difference", "max_nearest_difference" ] = "max_average_difference", ): - self.N: int = sanitize_param(N, 100, lambda x: x > 0) - self.aff_thresh: float = sanitize_param(aff_thresh, 0.1, lambda x: 0 < x < 1) - self.max_discards: int = sanitize_param(max_discards, 1000, lambda x: x > 0) + self.N: int = N + self.aff_thresh: float = aff_thresh + self.max_discards: int = max_discards - self.seed: Optional[int] = sanitize_seed(seed) + self.seed: Optional[int] = seed - if self.seed is not None: - np.random.seed(seed) - - self.no_label_sample_selection: str = sanitize_param( - no_label_sample_selection, - "max_average_difference", - lambda x: x == "max_nearest_difference", - ) + self.no_label_sample_selection: str = no_label_sample_selection self.classes: Optional[npt.NDArray] = None self._detectors: Optional[dict] = None self._detectors_stack: Optional[npt.NDArray] = None + if self.seed is not None: + np.random.seed(seed) + set_seed_numba(self.seed) + @property def detectors(self) -> Optional[Dict[str | int, npt.NDArray[np.bool_]]]: """Return the trained detectors, organized by class.""" diff --git a/aisp/nsa/_negative_selection.py b/aisp/nsa/_negative_selection.py index 448c30f..9075145 100644 --- a/aisp/nsa/_negative_selection.py +++ b/aisp/nsa/_negative_selection.py @@ -2,6 +2,7 @@ from __future__ import annotations +from numbers import Real from typing import Dict, Literal, Optional, Union, List import numpy as np @@ -18,12 +19,16 @@ compute_metric_distance, ) from ..utils.random import set_seed_numba -from ..utils.sanitizers import sanitize_seed, sanitize_choice, sanitize_param from ..utils.validation import ( check_array_type, check_shape_match, check_feature_dimension, check_value_range, + validate_parameters, + positive, + is_type, + optional, + choice, ) @@ -130,6 +135,23 @@ class RNSA(BaseClassifier): ['non-self' 'non-self' 'non-self' 'non-self' 'non-self'] """ + @validate_parameters( + N=(is_type(int), positive), + r=(is_type(Real), positive), + r_s=(is_type(Real), positive), + k=(is_type(int), positive), + metric=( + is_type(str), + choice(["manhattan", "minkowski", "euclidean"]) + ), + max_discards=(is_type(int), positive), + seed=optional((is_type(int), positive)), + algorithm=( + is_type(str), + choice(["default-NSA", "V-detector"]) + ), + p=(is_type(Real), positive), + ) def __init__( self, N: int = 100, @@ -144,22 +166,14 @@ def __init__( non_self_label: str = 'non-self', cell_bounds: bool = False ): - self.metric: str = sanitize_choice( - metric, ["manhattan", "minkowski"], "euclidean" - ) - self.seed: Optional[int] = sanitize_seed(seed) - if self.seed is not None: - np.random.seed(seed) - set_seed_numba(self.seed) - self.k: int = sanitize_param(k, 1, lambda x: x > 1) - self.N: int = sanitize_param(N, 100, lambda x: x >= 1) - self.r: float = sanitize_param(r, 0.05, lambda x: x > 0) - self.r_s: float = sanitize_param(r_s, 0.0001, lambda x: x > 0) - self.algorithm: str = sanitize_param( - algorithm, "default-NSA", lambda x: x == "V-detector" - ) - self.max_discards: int = sanitize_param(max_discards, 1000, lambda x: x > 0) - + self.metric: str = metric + self.seed: Optional[int] = seed + self.k: int = k + self.N: int = N + self.r: float = r + self.r_s: float = r_s + self.algorithm: str = algorithm + self.max_discards: int = max_discards self.p: float = p self.cell_bounds: bool = cell_bounds self.non_self_label: str = non_self_label @@ -167,6 +181,10 @@ def __init__( self._detectors: Optional[Dict[str | int, list[Detector]]] = None self.classes: Optional[npt.NDArray] = None + if self.seed is not None: + np.random.seed(seed) + set_seed_numba(self.seed) + @property def detectors(self) -> Optional[Dict[str | int, list[Detector]]]: """Returns the trained detectors, organized by class.""" diff --git a/aisp/utils/validation.py b/aisp/utils/validation.py index dfb11ec..6b1da36 100644 --- a/aisp/utils/validation.py +++ b/aisp/utils/validation.py @@ -1,4 +1,8 @@ """Contains functions responsible for validating data types.""" +import inspect +from functools import wraps +from numbers import Real +from typing import Collection, Callable, Any import numpy as np import numpy.typing as npt @@ -160,3 +164,110 @@ def check_value_range( raise ValueError( f"{name} must contain oly values within [{min_value}, {max_value}]." ) + + +def _validation_error(name, expected, value): + raise ValueError( + f"Invalid value for {name!r}: expected {expected}, got {value!r}." + ) + + +def positive(value: Real, name: str): + """Valida se o valor é positivo.""" + if value <= 0: + _validation_error(name, "must be positive", value) + return value + + +def non_negative(value: Real, name): + """Valida se o valor é maior ou igual a zero.""" + if value < 0: + _validation_error(name, "must be non-negative", value) + return value + + +def between(low: Real, high: Real): + """Cria um validador que verifica se um valor pertence a um intervalo.""" + def validator(value: Real, name: str): + """Valida se o valor está no intervalo definido.""" + if not low <= value <= high: + _validation_error(name, f"a value in [{low}, {high}]", value) + return value + + return validator + + +def choice(choices: Collection): + """Cria um validador que verifica se o valor pertence a um grupo.""" + def validator(value, name: str): + """Valida se o valor esta no grupo de valores permitidos.""" + if value not in choices: + _validation_error(name, f"one of {tuple(choices)}", value) + return value + + return validator + + +def optional(validator: Callable[[Any, str], Any] | Collection[Callable[[Any, str], Any]]): + """Torna um validadores opcionais.""" + def validate(value, name: str): + """Aplica a validação quando o valor não é None.""" + if value is None: + return None + if isinstance(validator, (list, tuple)): + for fn in validator: + value = fn(value, name) + + return value + + return validator(value, name) + + return validate + + +def is_type(expected_type): + """Cria um validador que verifica o tipo.""" + def validate(value, name: str): + """Valida se o valor possuir o tipo esperado.""" + if not isinstance(value, expected_type): + if isinstance(expected_type, (tuple, list)): + expected = ', '.join(t.__name__ for t in expected_type) + else: + expected = expected_type.__name__ + raise TypeError(f"{name} must be of type {expected} got {type(value).__name__}") + + return value + + return validate + + +def validate_parameters(**validators): + """Cria um decorador para validar automaticamente os parâmetros de uma classe.""" + def decorator(func): + """Decora uma função para validação.""" + sig = inspect.signature(func) + valid_params = sig.parameters.keys() + unknown = {k: v for k, v in validators.items() if k not in valid_params} + if unknown: + raise TypeError(f"{func.__name__} has no parameter: {','.join(unknown)}") + + @wraps(func) + def wrapper(*args, **kwargs): + """Aplica as validações configuradas.""" + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + + for name, validator in validators.items(): + value = bound.arguments[name] + if isinstance(validator, (list, tuple)): + for fn in validator: + value = fn(value, name) + else: + value = validator(value, name) + bound.arguments[name] = value + + return func(*bound.args, **bound.kwargs) + + return wrapper + + return decorator