From 1f37e4a18b8637ebcb6a5db22642d13c5568cd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Severin=20Paul=20H=C3=B6fer?= <84280965+zzril@users.noreply.github.com> Date: Tue, 11 Jul 2023 11:25:02 +0200 Subject: [PATCH] feat: new error class `OutOfBoundsError` (#438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #262. ### Summary of Changes * Introduced a new generic error class `OutOfBoundsError` that can be used to signal that a value is outside its expected range. * Updated Image to use the new error. * Updated Discretizer to use the new error. * Updated ML models with hyperparameters to use the new error. --------- Co-authored-by: Alexander Gréus Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com> Co-authored-by: Lars Reimann --- src/safeds/data/image/containers/_image.py | 7 +- .../tabular/transformation/_discretizer.py | 12 +- src/safeds/exceptions/__init__.py | 12 + src/safeds/exceptions/_generic.py | 281 ++++++++++++++++++ .../ml/classical/classification/_ada_boost.py | 15 +- .../classification/_gradient_boosting.py | 11 +- .../classification/_k_nearest_neighbors.py | 10 +- .../classification/_random_forest.py | 7 +- .../classification/_support_vector_machine.py | 7 +- .../ml/classical/regression/_ada_boost.py | 15 +- .../regression/_elastic_net_regression.py | 12 +- .../regression/_gradient_boosting.py | 11 +- .../regression/_k_nearest_neighbors.py | 10 +- .../classical/regression/_lasso_regression.py | 5 +- .../ml/classical/regression/_random_forest.py | 7 +- .../classical/regression/_ridge_regression.py | 5 +- .../regression/_support_vector_machine.py | 7 +- .../data/image/containers/test_image.py | 7 +- .../transformation/test_discretizer.py | 4 +- tests/safeds/exceptions/__init__.py | 0 .../exceptions/test_out_of_bounds_error.py | 153 ++++++++++ .../classification/test_ada_boost.py | 21 +- .../classification/test_gradient_boosting.py | 21 +- .../test_k_nearest_neighbors.py | 11 +- .../classification/test_random_forest.py | 11 +- .../test_support_vector_machine.py | 18 +- .../ml/classical/regression/test_ada_boost.py | 21 +- .../regression/test_elastic_net_regression.py | 25 +- .../regression/test_gradient_boosting.py | 21 +- .../regression/test_k_nearest_neighbors.py | 11 +- .../regression/test_lasso_regression.py | 8 +- .../regression/test_random_forest.py | 11 +- .../regression/test_ridge_regression.py | 8 +- .../regression/test_support_vector_machine.py | 18 +- 34 files changed, 673 insertions(+), 130 deletions(-) create mode 100644 src/safeds/exceptions/_generic.py create mode 100644 tests/safeds/exceptions/__init__.py create mode 100644 tests/safeds/exceptions/test_out_of_bounds_error.py diff --git a/src/safeds/data/image/containers/_image.py b/src/safeds/data/image/containers/_image.py index 79289083f..d3a6ce120 100644 --- a/src/safeds/data/image/containers/_image.py +++ b/src/safeds/data/image/containers/_image.py @@ -12,6 +12,7 @@ from PIL.Image import open as open_image from safeds.data.image.typing import ImageFormat +from safeds.exceptions import ClosedBound, OutOfBoundsError class Image: @@ -293,7 +294,7 @@ def adjust_brightness(self, factor: float) -> Image: The Image with adjusted brightness. """ if factor < 0: - raise ValueError("Brightness factor has to be 0 or bigger") + raise OutOfBoundsError(factor, name="factor", lower_bound=ClosedBound(0)) elif factor == 1: warnings.warn( "Brightness adjustment factor is 1.0, this will not make changes to the image.", @@ -322,7 +323,7 @@ def adjust_contrast(self, factor: float) -> Image: New image with adjusted contrast. """ if factor < 0: - raise ValueError("Contrast factor has to be 0 or bigger") + raise OutOfBoundsError(factor, name="factor", lower_bound=ClosedBound(0)) elif factor == 1: warnings.warn( "Contrast adjustment factor is 1.0, this will not make changes to the image.", @@ -352,7 +353,7 @@ def adjust_color_balance(self, factor: float) -> Image: The new, adjusted image. """ if factor < 0: - raise ValueError("Color factor has to be 0 or bigger.") + raise OutOfBoundsError(factor, name="factor", lower_bound=ClosedBound(0)) elif factor == 1: warnings.warn( "Color adjustment factor is 1.0, this will not make changes to the image.", diff --git a/src/safeds/data/tabular/transformation/_discretizer.py b/src/safeds/data/tabular/transformation/_discretizer.py index 38a6cf0f0..581130c55 100644 --- a/src/safeds/data/tabular/transformation/_discretizer.py +++ b/src/safeds/data/tabular/transformation/_discretizer.py @@ -4,7 +4,13 @@ from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation._table_transformer import TableTransformer -from safeds.exceptions import NonNumericColumnError, TransformerNotFittedError, UnknownColumnNameError +from safeds.exceptions import ( + ClosedBound, + NonNumericColumnError, + OutOfBoundsError, + TransformerNotFittedError, + UnknownColumnNameError, +) class Discretizer(TableTransformer): @@ -18,7 +24,7 @@ class Discretizer(TableTransformer): Raises ------ - ValueError + OutOfBoundsError If the given number_of_bins is less than 2. """ @@ -27,7 +33,7 @@ def __init__(self, number_of_bins: float = 5): self._wrapped_transformer: sk_KBinsDiscretizer | None = None if number_of_bins < 2: - raise ValueError("Parameter 'number_of_bins' must be >= 2.") + raise OutOfBoundsError(number_of_bins, name="number_of_bins", lower_bound=ClosedBound(2)) self._number_of_bins = number_of_bins def fit(self, table: Table, column_names: list[str] | None) -> Discretizer: diff --git a/src/safeds/exceptions/__init__.py b/src/safeds/exceptions/__init__.py index cf9b10066..021736287 100644 --- a/src/safeds/exceptions/__init__.py +++ b/src/safeds/exceptions/__init__.py @@ -15,6 +15,12 @@ ValueNotPresentWhenFittedError, WrongFileExtensionError, ) +from safeds.exceptions._generic import ( + Bound, + ClosedBound, + OpenBound, + OutOfBoundsError, +) from safeds.exceptions._ml import ( DatasetContainsTargetError, DatasetMissesDataError, @@ -26,6 +32,8 @@ ) __all__ = [ + # Generic exceptions + "OutOfBoundsError", # Data exceptions "ColumnIsTargetError", "ColumnLengthMismatchError", @@ -48,4 +56,8 @@ "ModelNotFittedError", "PredictionError", "UntaggedTableError", + # Other + "Bound", + "ClosedBound", + "OpenBound", ] diff --git a/src/safeds/exceptions/_generic.py b/src/safeds/exceptions/_generic.py new file mode 100644 index 000000000..b874a4f30 --- /dev/null +++ b/src/safeds/exceptions/_generic.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from numpy import isinf, isnan + + +class OutOfBoundsError(ValueError): + """ + A generic exception that can be used to signal that a (float) value is outside its expected range. + + Parameters + ---------- + actual: float + The actual value that is outside its expected range. + name: str | None + The name of the offending variable. + lower_bound: Bound | None + The lower bound of the expected range. Use None if there is no lower Bound. + upper_bound: Bound | None + The upper bound of the expected range. Use None if there is no upper Bound. + """ + + def __init__( + self, + actual: float, + *, + name: str | None = None, + lower_bound: Bound | None = None, + upper_bound: Bound | None = None, + ): + """ + Initialize an OutOfBoundsError. + + Parameters + ---------- + actual: float + The actual value that is outside its expected range. + name: str | None + The name of the offending variable. + lower_bound: Bound | None + The lower bound of the expected range. Use None if there is no lower Bound. + upper_bound: Bound | None + The upper bound of the expected range. Use None if there is no upper Bound. + + Raises + ------ + ValueError + * If one of the given Bounds is +/-inf. (For infinite Bounds, pass None instead.) + * If one of the given Bounds is nan. + * If upper_bound < lower_bound. + * If actual does not lie outside the given interval. + * If actual is not a real number. + """ + # Validate bound parameters: + if lower_bound is None and upper_bound is None: + raise ValueError("Illegal interval: Attempting to raise OutOfBoundsError, but no bounds given.") + if (lower_bound is not None and isinf(lower_bound.value)) or ( + upper_bound is not None and isinf(upper_bound.value) + ): + raise ValueError("Illegal interval: Lower and upper bounds must be real numbers, or None if unbounded.") + # Validate actual parameter: + if isinf(actual) or isnan(actual): + raise ValueError("Attempting to raise OutOfBoundsError with actual value not being a real number.") + # Use local variables with stricter types to help static analysis: + _lower_bound: Bound = lower_bound if lower_bound is not None else OpenBound(float("-inf")) + _upper_bound: Bound = upper_bound if upper_bound is not None else OpenBound(float("inf")) + # Check bounds: + if _upper_bound.value < _lower_bound.value: + raise ValueError( + ( + f"Illegal interval: Attempting to raise OutOfBoundsError, but given upper bound {_upper_bound} is " + f"actually less than given lower bound {_lower_bound}." + ), + ) + # Check that actual is indeed outside the interval: + elif _lower_bound._check_lower_bound(actual) and _upper_bound._check_upper_bound(actual): + raise ValueError( + ( + f"Illegal interval: Attempting to raise OutOfBoundsError, but value {actual} is not actually" + f" outside given interval {_lower_bound._str_lower_bound()}, {_upper_bound._str_upper_bound()}." + ), + ) + # Raise the actual exception: + full_variable_name = actual if name is None else f"{name} (={actual})" + super().__init__( + f"{full_variable_name} is not inside {_lower_bound._str_lower_bound()}, {_upper_bound._str_upper_bound()}.", + ) + + +class Bound(ABC): + """ + Abstract base class for (lower or upper) Bounds on a float value. + + Parameters + ---------- + value: float + The value of the Bound. + """ + + def __init__(self, value: float): + """ + Initialize a Bound. + + Parameters + ---------- + value: float + The value of the Bound. + + Raises + ------ + ValueError + If value is nan or if value is +/-inf and the Bound type does not allow for infinite Bounds. + """ + if isnan(value): + raise ValueError("Bound must be a real number, not nan.") + self._value = value + + def __str__(self) -> str: + """Get a string representation of the concrete value of the Bound.""" + return str(self.value) + + @property + def value(self) -> float: + """Get the concrete value of the Bound.""" + return self._value + + @abstractmethod + def _str_lower_bound(self) -> str: + """Get a string representation of the Bound as the lower Bound of an interval.""" + + @abstractmethod + def _str_upper_bound(self) -> str: + """Get a string representation of the Bound as the upper Bound of an interval.""" + + @abstractmethod + def _check_lower_bound(self, actual: float) -> bool: + """ + Check that a value does not exceed the Bound on the lower side. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + + @abstractmethod + def _check_upper_bound(self, actual: float) -> bool: + """ + Check that a value does not exceed the Bound on the upper side. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + + +class ClosedBound(Bound): + """ + A closed Bound, i.e. the value on the border belongs to the range. + + Parameters + ---------- + value: float + The value of the Bound. + """ + + def __init__(self, value: float): + """ + Initialize a ClosedBound. + + Parameters + ---------- + value: float + The value of the ClosedBound. + + Raises + ------ + ValueError + If value is nan or if value is +/-inf. + """ + if value == float("-inf") or value == float("inf"): + raise ValueError("ClosedBound must be a real number, not +/-inf.") + super().__init__(value) + + def _str_lower_bound(self) -> str: + """Get a string representation of the ClosedBound as the lower Bound of an interval.""" + return f"[{self}" + + def _str_upper_bound(self) -> str: + """Get a string representation of the ClosedBound as the upper Bound of an interval.""" + return f"{self}]" + + def _check_lower_bound(self, actual: float) -> bool: + """ + Check that a value is not strictly lower than the ClosedBound. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + return actual >= self.value + + def _check_upper_bound(self, actual: float) -> bool: + """ + Check that a value is not strictly higher than the ClosedBound. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + return actual <= self.value + + +class OpenBound(Bound): + """ + An open Bound, i.e. the value on the border does not belong to the range. + + Parameters + ---------- + value: float + The value of the OpenBound. + """ + + def __init__(self, value: float): + """ + Initialize an OpenBound. + + Parameters + ---------- + value: float + The value of the OpenBound. + + Raises + ------ + ValueError + If value is nan. + """ + super().__init__(value) + + def __str__(self) -> str: + """Get a string representation of the concrete value of the OpenBound.""" + if self.value == float("-inf"): + return "-\u221e" + elif self.value == float("inf"): + return "\u221e" + else: + return super().__str__() + + def _str_lower_bound(self) -> str: + """Get a string representation of the OpenBound as the lower Bound of an interval.""" + return f"({self}" + + def _str_upper_bound(self) -> str: + """Get a string representation of the OpenBound as the upper Bound of an interval.""" + return f"{self})" + + def _check_lower_bound(self, actual: float) -> bool: + """ + Check that a value is not lower or equal to the OpenBound. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + return actual > self.value + + def _check_upper_bound(self, actual: float) -> bool: + """ + Check that a value is not higher or equal to the OpenBound. + + Parameters + ---------- + actual: float + The actual value that should be checked for not exceeding the Bound. + """ + return actual < self.value diff --git a/src/safeds/ml/classical/classification/_ada_boost.py b/src/safeds/ml/classical/classification/_ada_boost.py index 6cde8e4f2..73b4ef79d 100644 --- a/src/safeds/ml/classical/classification/_ada_boost.py +++ b/src/safeds/ml/classical/classification/_ada_boost.py @@ -4,6 +4,7 @@ from sklearn.ensemble import AdaBoostClassifier as sk_AdaBoostClassifier +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._classifier import Classifier @@ -31,8 +32,8 @@ class AdaBoost(Classifier): Raises ------ - ValueError - If `maximum_number_of_learners` or `learning_rate` are less than or equal to 0 + OutOfBoundsError + If `maximum_number_of_learners` or `learning_rate` are less than or equal to 0. """ def __init__( @@ -43,10 +44,14 @@ def __init__( learning_rate: float = 1.0, ) -> None: # Validation - if maximum_number_of_learners <= 0: - raise ValueError("The parameter 'maximum_number_of_learners' has to be grater than 0.") + if maximum_number_of_learners < 1: + raise OutOfBoundsError( + maximum_number_of_learners, + name="maximum_number_of_learners", + lower_bound=ClosedBound(1), + ) if learning_rate <= 0: - raise ValueError("The parameter 'learning_rate' has to be greater than 0.") + raise OutOfBoundsError(learning_rate, name="learning_rate", lower_bound=OpenBound(0)) # Hyperparameters self._learner = learner diff --git a/src/safeds/ml/classical/classification/_gradient_boosting.py b/src/safeds/ml/classical/classification/_gradient_boosting.py index 23804b3ca..593d1c632 100644 --- a/src/safeds/ml/classical/classification/_gradient_boosting.py +++ b/src/safeds/ml/classical/classification/_gradient_boosting.py @@ -4,6 +4,7 @@ from sklearn.ensemble import GradientBoostingClassifier as sk_GradientBoostingClassifier +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._classifier import Classifier @@ -29,16 +30,16 @@ class GradientBoosting(Classifier): Raises ------ - ValueError - If `number_of_trees` is less than or equal to 0 or `learning_rate` is non-positive. + OutOfBoundsError + If `number_of_trees` or `learning_rate` is less than or equal to 0. """ def __init__(self, *, number_of_trees: int = 100, learning_rate: float = 0.1) -> None: # Validation - if number_of_trees <= 0: - raise ValueError("The parameter 'number_of_trees' has to be greater than 0.") + if number_of_trees < 1: + raise OutOfBoundsError(number_of_trees, name="number_of_trees", lower_bound=ClosedBound(1)) if learning_rate <= 0: - raise ValueError("The parameter 'learning_rate' has to be greater than 0.") + raise OutOfBoundsError(learning_rate, name="learning_rate", lower_bound=OpenBound(0)) # Hyperparameters self._number_of_trees = number_of_trees diff --git a/src/safeds/ml/classical/classification/_k_nearest_neighbors.py b/src/safeds/ml/classical/classification/_k_nearest_neighbors.py index d7ff20362..7340af888 100644 --- a/src/safeds/ml/classical/classification/_k_nearest_neighbors.py +++ b/src/safeds/ml/classical/classification/_k_nearest_neighbors.py @@ -4,7 +4,7 @@ from sklearn.neighbors import KNeighborsClassifier as sk_KNeighborsClassifier -from safeds.exceptions import DatasetMissesDataError +from safeds.exceptions import ClosedBound, DatasetMissesDataError, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._classifier import Classifier @@ -27,14 +27,14 @@ class KNearestNeighbors(Classifier): Raises ------ - ValueError - If `number_of_neighbors` is less than or equal to 0. + OutOfBoundsError + If `number_of_neighbors` is less than 1. """ def __init__(self, number_of_neighbors: int) -> None: # Validation - if number_of_neighbors <= 0: - raise ValueError("The parameter 'number_of_neighbors' has to be greater than 0.") + if number_of_neighbors < 1: + raise OutOfBoundsError(number_of_neighbors, name="number_of_neighbors", lower_bound=ClosedBound(1)) # Hyperparameters self._number_of_neighbors = number_of_neighbors diff --git a/src/safeds/ml/classical/classification/_random_forest.py b/src/safeds/ml/classical/classification/_random_forest.py index 850c5296b..04e573d5a 100644 --- a/src/safeds/ml/classical/classification/_random_forest.py +++ b/src/safeds/ml/classical/classification/_random_forest.py @@ -4,6 +4,7 @@ from sklearn.ensemble import RandomForestClassifier as sk_RandomForestClassifier +from safeds.exceptions import ClosedBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._classifier import Classifier @@ -24,14 +25,14 @@ class RandomForest(Classifier): Raises ------ - ValueError - If `number_of_trees` is less than or equal to 0. + OutOfBoundsError + If `number_of_trees` is less than 1. """ def __init__(self, *, number_of_trees: int = 100) -> None: # Validation if number_of_trees < 1: - raise ValueError("The parameter 'number_of_trees' has to be greater than 0.") + raise OutOfBoundsError(number_of_trees, name="number_of_trees", lower_bound=ClosedBound(1)) # Hyperparameters self._number_of_trees = number_of_trees diff --git a/src/safeds/ml/classical/classification/_support_vector_machine.py b/src/safeds/ml/classical/classification/_support_vector_machine.py index 57a528d9f..123d72f49 100644 --- a/src/safeds/ml/classical/classification/_support_vector_machine.py +++ b/src/safeds/ml/classical/classification/_support_vector_machine.py @@ -5,6 +5,7 @@ from sklearn.svm import SVC as sk_SVC # noqa: N811 +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from safeds.ml.classical.classification import Classifier @@ -41,7 +42,7 @@ class SupportVectorMachine(Classifier): Raises ------ - ValueError + OutOfBoundsError If `c` is less than or equal to 0. """ @@ -53,7 +54,7 @@ def __init__(self, *, c: float = 1.0, kernel: SupportVectorMachineKernel | None # Hyperparameters if c <= 0: - raise ValueError("The parameter 'c' has to be strictly positive.") + raise OutOfBoundsError(c, name="c", lower_bound=OpenBound(0)) self._c = c self._kernel = kernel @@ -97,7 +98,7 @@ def get_sklearn_kernel(self) -> str: class Polynomial(SupportVectorMachineKernel): def __init__(self, degree: int): if degree < 1: - raise ValueError("The parameter 'degree' has to be greater than or equal to 1.") + raise OutOfBoundsError(degree, name="degree", lower_bound=ClosedBound(1)) self._degree = degree def get_sklearn_kernel(self) -> str: diff --git a/src/safeds/ml/classical/regression/_ada_boost.py b/src/safeds/ml/classical/regression/_ada_boost.py index d4d39bf85..8403c3479 100644 --- a/src/safeds/ml/classical/regression/_ada_boost.py +++ b/src/safeds/ml/classical/regression/_ada_boost.py @@ -4,6 +4,7 @@ from sklearn.ensemble import AdaBoostRegressor as sk_AdaBoostRegressor +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -31,8 +32,8 @@ class AdaBoost(Regressor): Raises ------ - ValueError - If `maximum_number_of_learners` or `learning_rate` are less than or equal to 0 + OutOfBoundsError + If `maximum_number_of_learners` or `learning_rate` are less than or equal to 0. """ def __init__( @@ -43,10 +44,14 @@ def __init__( learning_rate: float = 1.0, ) -> None: # Validation - if maximum_number_of_learners <= 0: - raise ValueError("The parameter 'maximum_number_of_learners' has to be grater than 0.") + if maximum_number_of_learners < 1: + raise OutOfBoundsError( + maximum_number_of_learners, + name="maximum_number_of_learners", + lower_bound=ClosedBound(1), + ) if learning_rate <= 0: - raise ValueError("The parameter 'learning_rate' has to be greater than 0.") + raise OutOfBoundsError(learning_rate, name="learning_rate", lower_bound=OpenBound(0)) # Hyperparameters self._learner = learner diff --git a/src/safeds/ml/classical/regression/_elastic_net_regression.py b/src/safeds/ml/classical/regression/_elastic_net_regression.py index 054bcb3cf..00a67d495 100644 --- a/src/safeds/ml/classical/regression/_elastic_net_regression.py +++ b/src/safeds/ml/classical/regression/_elastic_net_regression.py @@ -6,6 +6,7 @@ from sklearn.linear_model import ElasticNet as sk_ElasticNet +from safeds.exceptions import ClosedBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -29,14 +30,14 @@ class ElasticNetRegression(Regressor): Raises ------ - ValueError + OutOfBoundsError If `alpha` is negative or `lasso_ratio` is not between 0 and 1. """ def __init__(self, *, alpha: float = 1.0, lasso_ratio: float = 0.5) -> None: # Validation if alpha < 0: - raise ValueError("The parameter 'alpha' must be non-negative") + raise OutOfBoundsError(alpha, name="alpha", lower_bound=ClosedBound(0)) if alpha == 0: warn( ( @@ -47,7 +48,12 @@ def __init__(self, *, alpha: float = 1.0, lasso_ratio: float = 0.5) -> None: stacklevel=2, ) if lasso_ratio < 0 or lasso_ratio > 1: - raise ValueError("The parameter 'lasso_ratio' must be between 0 and 1.") + raise OutOfBoundsError( + lasso_ratio, + name="lasso_ratio", + lower_bound=ClosedBound(0), + upper_bound=ClosedBound(1), + ) elif lasso_ratio == 0: warnings.warn( ( diff --git a/src/safeds/ml/classical/regression/_gradient_boosting.py b/src/safeds/ml/classical/regression/_gradient_boosting.py index b783f4511..5d5f3b6ed 100644 --- a/src/safeds/ml/classical/regression/_gradient_boosting.py +++ b/src/safeds/ml/classical/regression/_gradient_boosting.py @@ -4,6 +4,7 @@ from sklearn.ensemble import GradientBoostingRegressor as sk_GradientBoostingRegressor +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -29,16 +30,16 @@ class GradientBoosting(Regressor): Raises ------ - ValueError - If `number_of_trees` is less than or equal to 0 or `learning_rate` is non-positive. + OutOfBoundsError + If `number_of_trees` or `learning_rate` are less than or equal to 0. """ def __init__(self, *, number_of_trees: int = 100, learning_rate: float = 0.1) -> None: # Validation - if number_of_trees <= 0: - raise ValueError("The parameter 'number_of_trees' has to be greater than 0.") + if number_of_trees < 1: + raise OutOfBoundsError(number_of_trees, name="number_of_trees", lower_bound=ClosedBound(1)) if learning_rate <= 0: - raise ValueError("The parameter 'learning_rate' has to be greater than 0.") + raise OutOfBoundsError(learning_rate, name="learning_rate", lower_bound=OpenBound(0)) # Hyperparameters self._number_of_trees = number_of_trees diff --git a/src/safeds/ml/classical/regression/_k_nearest_neighbors.py b/src/safeds/ml/classical/regression/_k_nearest_neighbors.py index 4da871342..fac6dad14 100644 --- a/src/safeds/ml/classical/regression/_k_nearest_neighbors.py +++ b/src/safeds/ml/classical/regression/_k_nearest_neighbors.py @@ -4,7 +4,7 @@ from sklearn.neighbors import KNeighborsRegressor as sk_KNeighborsRegressor -from safeds.exceptions import DatasetMissesDataError +from safeds.exceptions import ClosedBound, DatasetMissesDataError, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -27,14 +27,14 @@ class KNearestNeighbors(Regressor): Raises ------ - ValueError - If `number_of_neighbors` is less than or equal to 0. + OutOfBoundsError + If `number_of_neighbors` is less than 1. """ def __init__(self, number_of_neighbors: int) -> None: # Validation - if number_of_neighbors <= 0: - raise ValueError("The parameter 'number_of_neighbors' has to be greater than 0.") + if number_of_neighbors < 1: + raise OutOfBoundsError(number_of_neighbors, name="number_of_neighbors", lower_bound=ClosedBound(1)) # Hyperparameters self._number_of_neighbors = number_of_neighbors diff --git a/src/safeds/ml/classical/regression/_lasso_regression.py b/src/safeds/ml/classical/regression/_lasso_regression.py index ac238e330..1826ae9b6 100644 --- a/src/safeds/ml/classical/regression/_lasso_regression.py +++ b/src/safeds/ml/classical/regression/_lasso_regression.py @@ -5,6 +5,7 @@ from sklearn.linear_model import Lasso as sk_Lasso +from safeds.exceptions import ClosedBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -25,14 +26,14 @@ class LassoRegression(Regressor): Raises ------ - ValueError + OutOfBoundsError If `alpha` is negative. """ def __init__(self, *, alpha: float = 1.0) -> None: # Validation if alpha < 0: - raise ValueError("The parameter 'alpha' must be non-negative") + raise OutOfBoundsError(alpha, name="alpha", lower_bound=ClosedBound(0)) if alpha == 0: warn( ( diff --git a/src/safeds/ml/classical/regression/_random_forest.py b/src/safeds/ml/classical/regression/_random_forest.py index 3908592ff..8dd27941d 100644 --- a/src/safeds/ml/classical/regression/_random_forest.py +++ b/src/safeds/ml/classical/regression/_random_forest.py @@ -4,6 +4,7 @@ from sklearn.ensemble import RandomForestRegressor as sk_RandomForestRegressor +from safeds.exceptions import ClosedBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -24,14 +25,14 @@ class RandomForest(Regressor): Raises ------ - ValueError - If `number_of_trees` is less than or equal to 0. + OutOfBoundsError + If `number_of_trees` is less than 1. """ def __init__(self, *, number_of_trees: int = 100) -> None: # Validation if number_of_trees < 1: - raise ValueError("The parameter 'number_of_trees' has to be greater than 0.") + raise OutOfBoundsError(number_of_trees, name="number_of_trees", lower_bound=ClosedBound(1)) # Hyperparameters self._number_of_trees = number_of_trees diff --git a/src/safeds/ml/classical/regression/_ridge_regression.py b/src/safeds/ml/classical/regression/_ridge_regression.py index b775c4215..128551bf3 100644 --- a/src/safeds/ml/classical/regression/_ridge_regression.py +++ b/src/safeds/ml/classical/regression/_ridge_regression.py @@ -5,6 +5,7 @@ from sklearn.linear_model import Ridge as sk_Ridge +from safeds.exceptions import ClosedBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from ._regressor import Regressor @@ -26,14 +27,14 @@ class RidgeRegression(Regressor): Raises ------ - ValueError + OutOfBoundsError If `alpha` is negative. """ def __init__(self, *, alpha: float = 1.0) -> None: # Validation if alpha < 0: - raise ValueError("The parameter 'alpha' must be non-negative") + raise OutOfBoundsError(alpha, name="alpha", lower_bound=ClosedBound(0)) if alpha == 0.0: warnings.warn( ( diff --git a/src/safeds/ml/classical/regression/_support_vector_machine.py b/src/safeds/ml/classical/regression/_support_vector_machine.py index 3fb6fe367..22b8f0f49 100644 --- a/src/safeds/ml/classical/regression/_support_vector_machine.py +++ b/src/safeds/ml/classical/regression/_support_vector_machine.py @@ -5,6 +5,7 @@ from sklearn.svm import SVR as sk_SVR # noqa: N811 +from safeds.exceptions import ClosedBound, OpenBound, OutOfBoundsError from safeds.ml.classical._util_sklearn import fit, predict from safeds.ml.classical.regression import Regressor @@ -41,7 +42,7 @@ class SupportVectorMachine(Regressor): Raises ------ - ValueError + OutOfBoundsError If `c` is less than or equal to 0. """ @@ -53,7 +54,7 @@ def __init__(self, *, c: float = 1.0, kernel: SupportVectorMachineKernel | None # Hyperparameters if c <= 0: - raise ValueError("The parameter 'c' has to be strictly positive.") + raise OutOfBoundsError(c, name="c", lower_bound=OpenBound(0)) self._c = c self._kernel = kernel @@ -97,7 +98,7 @@ def get_sklearn_kernel(self) -> str: class Polynomial(SupportVectorMachineKernel): def __init__(self, degree: int): if degree < 1: - raise ValueError("The parameter 'degree' has to be greater than or equal to 1.") + raise OutOfBoundsError(degree, name="degree", lower_bound=ClosedBound(1)) self._degree = degree def get_sklearn_kernel(self) -> str: diff --git a/tests/safeds/data/image/containers/test_image.py b/tests/safeds/data/image/containers/test_image.py index 79af2a8e1..9bb66dace 100644 --- a/tests/safeds/data/image/containers/test_image.py +++ b/tests/safeds/data/image/containers/test_image.py @@ -5,6 +5,7 @@ from safeds.data.image.containers import Image from safeds.data.image.typing import ImageFormat from safeds.data.tabular.containers import Table +from safeds.exceptions import OutOfBoundsError from tests.helpers import resolve_resource_path @@ -280,7 +281,7 @@ def test_should_not_adjust_contrast(self) -> None: def test_should_raise(self) -> None: image = Image.from_png_file(resolve_resource_path("image/brightness/to_brighten.png")) - with pytest.raises(ValueError, match="Contrast factor has to be 0 or bigger"): + with pytest.raises(OutOfBoundsError, match=r"factor \(=-1\) is not inside \[0, \u221e\)."): image.adjust_contrast(-1) @@ -304,7 +305,7 @@ def test_should_not_brighten(self) -> None: def test_should_raise(self) -> None: image = Image.from_png_file(resolve_resource_path("image/brightness/to_brighten.png")) - with pytest.raises(ValueError, match="Brightness factor has to be 0 or bigger"): + with pytest.raises(OutOfBoundsError, match=r"factor \(=-1\) is not inside \[0, \u221e\)."): image.adjust_brightness(-1) @@ -361,7 +362,7 @@ def test_should_adjust_colors(self, image: Image, factor: float, expected: Image ids=["negative"], ) def test_should_throw(self, image: Image, factor: float) -> None: - with pytest.raises(ValueError, match="Color factor has to be 0 or bigger."): + with pytest.raises(OutOfBoundsError, match=rf"factor \(={factor}\) is not inside \[0, \u221e\)."): image.adjust_color_balance(factor) @pytest.mark.parametrize( diff --git a/tests/safeds/data/tabular/transformation/test_discretizer.py b/tests/safeds/data/tabular/transformation/test_discretizer.py index b4a69971b..dc9ea7d13 100644 --- a/tests/safeds/data/tabular/transformation/test_discretizer.py +++ b/tests/safeds/data/tabular/transformation/test_discretizer.py @@ -1,12 +1,12 @@ import pytest from safeds.data.tabular.containers import Table from safeds.data.tabular.transformation import Discretizer -from safeds.exceptions import NonNumericColumnError, TransformerNotFittedError, UnknownColumnNameError +from safeds.exceptions import NonNumericColumnError, OutOfBoundsError, TransformerNotFittedError, UnknownColumnNameError class TestInit: def test_should_raise_value_error(self) -> None: - with pytest.raises(ValueError, match="Parameter 'number_of_bins' must be >= 2."): + with pytest.raises(OutOfBoundsError, match=r"number_of_bins \(=1\) is not inside \[2, \u221e\)\."): _ = Discretizer(1) diff --git a/tests/safeds/exceptions/__init__.py b/tests/safeds/exceptions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/safeds/exceptions/test_out_of_bounds_error.py b/tests/safeds/exceptions/test_out_of_bounds_error.py new file mode 100644 index 000000000..f78006795 --- /dev/null +++ b/tests/safeds/exceptions/test_out_of_bounds_error.py @@ -0,0 +1,153 @@ +import re + +import pytest +from numpy import isinf, isnan +from safeds.exceptions import Bound, ClosedBound, OpenBound, OutOfBoundsError + + +@pytest.mark.parametrize( + "actual", + [0, 1, -1, 2, -2], + ids=["0", "1", "-1", "2", "-2"], +) +@pytest.mark.parametrize("variable_name", ["test_variable"], ids=["test_variable"]) +@pytest.mark.parametrize( + ("lower_bound", "match_lower"), + [ + (ClosedBound(-1), "[-1"), + (OpenBound(-1), "(-1"), + (None, "(-\u221e"), + (OpenBound(float("-inf")), "(-\u221e"), + (OpenBound(float("inf")), "(\u221e"), + ], + ids=["lb_closed_-1", "lb_open_-1", "lb_none", "lb_neg_inf", "lb_inf"], +) +@pytest.mark.parametrize( + ("upper_bound", "match_upper"), + [ + (ClosedBound(1), "1]"), + (OpenBound(1), "1)"), + (None, "\u221e)"), + (OpenBound(float("-inf")), "-\u221e)"), + (OpenBound(float("inf")), "\u221e)"), + ], + ids=["ub_closed_-1", "ub_open_-1", "ub_none", "ub_neg_inf", "ub_inf"], +) +def test_should_raise_out_of_bounds_error( + actual: float, + variable_name: str | None, + lower_bound: Bound | None, + upper_bound: Bound | None, + match_lower: str, + match_upper: str, +) -> None: + # Check (-inf, inf) interval: + if lower_bound is None and upper_bound is None: + with pytest.raises( + ValueError, + match=r"Illegal interval: Attempting to raise OutOfBoundsError, but no bounds given\.", + ): + raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) + return + # Check if infinity was passed instead of None: + if (lower_bound is not None and isinf(lower_bound.value)) or (upper_bound is not None and isinf(upper_bound.value)): + with pytest.raises( + ValueError, + match="Illegal interval: Lower and upper bounds must be real numbers, or None if unbounded.", + ): + raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) + return + # All tests: Check interval where lower > upper: + if lower_bound is not None and upper_bound is not None: + with pytest.raises( + ValueError, + match=r"Illegal interval: Attempting to raise OutOfBoundsError, but given upper bound .+ is actually less " + r"than given lower bound .+\.", + ): + raise OutOfBoundsError(actual, lower_bound=upper_bound, upper_bound=lower_bound) + # Check case where actual value lies inside the interval: + if (lower_bound is None or lower_bound._check_lower_bound(actual)) and ( + upper_bound is None or upper_bound._check_upper_bound(actual) + ): + with pytest.raises( + ValueError, + match=rf"Illegal interval: Attempting to raise OutOfBoundsError, but value {actual} is not actually outside" + rf" given interval [\[(].+,.+[\])]\.", + ): + raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) + return + # Check that error is raised correctly: + with pytest.raises( + OutOfBoundsError, + match=rf"{actual} is not inside {re.escape(match_lower)}, {re.escape(match_upper)}.", + ): + raise OutOfBoundsError(actual, lower_bound=lower_bound, upper_bound=upper_bound) + with pytest.raises( + OutOfBoundsError, + match=rf"{variable_name} \(={actual}\) is not inside {re.escape(match_lower)}, {re.escape(match_upper)}.", + ): + raise OutOfBoundsError(actual, name=variable_name, lower_bound=lower_bound, upper_bound=upper_bound) + + +@pytest.mark.parametrize( + ("value", "expected_value", "bound", "lower_bound"), + [ + (2, True, ClosedBound(2), False), + (2, True, ClosedBound(2), True), + (2, True, ClosedBound(3), False), + (2, True, ClosedBound(1), True), + (2, False, OpenBound(2), False), + (2, False, OpenBound(2), True), + (2, False, OpenBound(1), False), + (2, False, OpenBound(3), True), + (2, False, OpenBound(float("inf")), True), + (2, True, OpenBound(float("inf")), False), + (2, True, OpenBound(float("-inf")), True), + (2, False, OpenBound(float("-inf")), False), + ], + ids=[ + "ex_false-close_2-upper", + "ex_false-close_2-lower", + "ex_true-close_3-upper", + "ex_true-close_1-lower", + "ex_true-open_2-upper", + "ex_true-open_2-lower", + "ex_false-open_1-upper", + "ex_false-open_3-lower", + "ex_false-inf-lower", + "ex_true-inf-upper", + "ex_true--inf-lower", + "ex_false--inf-upper", + ], +) +def test_should_return_true_if_value_in_bounds( + value: float, + expected_value: bool, + bound: Bound, + lower_bound: bool, +) -> None: + if lower_bound: + assert expected_value == bound._check_lower_bound(value) + else: + assert expected_value == bound._check_upper_bound(value) + + +@pytest.mark.parametrize("value", [float("nan"), float("-inf"), float("inf")], ids=["nan", "neg_inf", "inf"]) +def test_should_raise_value_error(value: float) -> None: + if isnan(value): + with pytest.raises(ValueError, match="Bound must be a real number, not nan."): + ClosedBound(value) + with pytest.raises(ValueError, match="Bound must be a real number, not nan."): + OpenBound(value) + else: + with pytest.raises(ValueError, match=r"ClosedBound must be a real number, not \+\/\-inf\."): + ClosedBound(value) + + +@pytest.mark.parametrize("actual", [float("nan"), float("-inf"), float("inf")], ids=["nan", "neg_inf", "inf"]) +def test_should_raise_value_error_because_invalid_actual(actual: float) -> None: + with pytest.raises( + ValueError, + match="Attempting to raise OutOfBoundsError with actual value not being a real number.", + ): + raise OutOfBoundsError(actual, lower_bound=ClosedBound(-1), upper_bound=ClosedBound(1)) diff --git a/tests/safeds/ml/classical/classification/test_ada_boost.py b/tests/safeds/ml/classical/classification/test_ada_boost.py index 1ecec8f4c..4ed3a339b 100644 --- a/tests/safeds/ml/classical/classification/test_ada_boost.py +++ b/tests/safeds/ml/classical/classification/test_ada_boost.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import AdaBoost @@ -32,9 +33,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.n_estimators == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'maximum_number_of_learners' has to be grater than 0."): - AdaBoost(maximum_number_of_learners=-1) + @pytest.mark.parametrize("maximum_number_of_learners", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, maximum_number_of_learners: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"maximum_number_of_learners \(={maximum_number_of_learners}\) is not inside \[1, \u221e\)\.", + ): + AdaBoost(maximum_number_of_learners=maximum_number_of_learners) class TestLearningRate: @@ -47,6 +52,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.learning_rate == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'learning_rate' has to be greater than 0."): - AdaBoost(learning_rate=-1) + @pytest.mark.parametrize("learning_rate", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, learning_rate: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"learning_rate \(={learning_rate}\) is not inside \(0, \u221e\)\.", + ): + AdaBoost(learning_rate=learning_rate) diff --git a/tests/safeds/ml/classical/classification/test_gradient_boosting.py b/tests/safeds/ml/classical/classification/test_gradient_boosting.py index e30564036..34fbd2b0b 100644 --- a/tests/safeds/ml/classical/classification/test_gradient_boosting.py +++ b/tests/safeds/ml/classical/classification/test_gradient_boosting.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import GradientBoosting @@ -19,9 +20,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.n_estimators == 2 - def test_should_raise_if_less_than_1(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_trees' has to be greater than 0."): - GradientBoosting(number_of_trees=0) + @pytest.mark.parametrize("number_of_trees", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_1(self, number_of_trees: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_trees \(={number_of_trees}\) is not inside \[1, \u221e\)\.", + ): + GradientBoosting(number_of_trees=number_of_trees) class TestLearningRate: @@ -34,6 +39,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.learning_rate == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'learning_rate' has to be greater than 0."): - GradientBoosting(learning_rate=-1) + @pytest.mark.parametrize("learning_rate", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, learning_rate: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"learning_rate \(={learning_rate}\) is not inside \(0, \u221e\)\.", + ): + GradientBoosting(learning_rate=learning_rate) diff --git a/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py b/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py index 982f80184..9677d9541 100644 --- a/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py +++ b/tests/safeds/ml/classical/classification/test_k_nearest_neighbors.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import KNearestNeighbors @@ -19,9 +20,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.n_neighbors == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_neighbors' has to be greater than 0."): - KNearestNeighbors(number_of_neighbors=-1) + @pytest.mark.parametrize("number_of_neighbors", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, number_of_neighbors: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_neighbors \(={number_of_neighbors}\) is not inside \[1, \u221e\)\.", + ): + KNearestNeighbors(number_of_neighbors=number_of_neighbors) def test_should_raise_if_greater_than_sample_size(self, training_set: TaggedTable) -> None: with pytest.raises(ValueError, match="has to be less than or equal to the sample size"): diff --git a/tests/safeds/ml/classical/classification/test_random_forest.py b/tests/safeds/ml/classical/classification/test_random_forest.py index c6df609cc..794a242b9 100644 --- a/tests/safeds/ml/classical/classification/test_random_forest.py +++ b/tests/safeds/ml/classical/classification/test_random_forest.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import RandomForest @@ -19,6 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.n_estimators == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_trees' has to be greater than 0."): - RandomForest(number_of_trees=-1) + @pytest.mark.parametrize("number_of_trees", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, number_of_trees: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_trees \(={number_of_trees}\) is not inside \[1, \u221e\)\.", + ): + RandomForest(number_of_trees=number_of_trees) diff --git a/tests/safeds/ml/classical/classification/test_support_vector_machine.py b/tests/safeds/ml/classical/classification/test_support_vector_machine.py index d54b3d895..148ba4542 100644 --- a/tests/safeds/ml/classical/classification/test_support_vector_machine.py +++ b/tests/safeds/ml/classical/classification/test_support_vector_machine.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.classification import SupportVectorMachine @@ -19,12 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_classifier is not None assert fitted_model._wrapped_classifier.C == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises( - ValueError, - match="The parameter 'c' has to be strictly positive.", - ): - SupportVectorMachine(c=-1) + @pytest.mark.parametrize("c", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, c: float) -> None: + with pytest.raises(OutOfBoundsError, match=rf"c \(={c}\) is not inside \(0, \u221e\)\."): + SupportVectorMachine(c=c) class TestKernel: @@ -45,9 +44,10 @@ def test_should_get_sklearn_kernel_linear(self) -> None: linear_kernel = svm.kernel.get_sklearn_kernel() assert linear_kernel == "linear" - def test_should_raise_if_degree_less_than_1(self) -> None: - with pytest.raises(ValueError, match="The parameter 'degree' has to be greater than or equal to 1."): - SupportVectorMachine.Kernel.Polynomial(degree=0) + @pytest.mark.parametrize("degree", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_degree_less_than_1(self, degree: int) -> None: + with pytest.raises(OutOfBoundsError, match=rf"degree \(={degree}\) is not inside \[1, \u221e\)\."): + SupportVectorMachine.Kernel.Polynomial(degree=degree) def test_should_get_sklearn_kernel_polynomial(self) -> None: svm = SupportVectorMachine(c=2, kernel=SupportVectorMachine.Kernel.Polynomial(degree=2)) diff --git a/tests/safeds/ml/classical/regression/test_ada_boost.py b/tests/safeds/ml/classical/regression/test_ada_boost.py index 73f3511bb..52baec913 100644 --- a/tests/safeds/ml/classical/regression/test_ada_boost.py +++ b/tests/safeds/ml/classical/regression/test_ada_boost.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import AdaBoost @@ -32,9 +33,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.n_estimators == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'maximum_number_of_learners' has to be grater than 0."): - AdaBoost(maximum_number_of_learners=-1) + @pytest.mark.parametrize("maximum_number_of_learners", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, maximum_number_of_learners: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"maximum_number_of_learners \(={maximum_number_of_learners}\) is not inside \[1, \u221e\)\.", + ): + AdaBoost(maximum_number_of_learners=maximum_number_of_learners) class TestLearningRate: @@ -47,6 +52,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.learning_rate == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'learning_rate' has to be greater than 0."): - AdaBoost(learning_rate=-1) + @pytest.mark.parametrize("learning_rate", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, learning_rate: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"learning_rate \(={learning_rate}\) is not inside \(0, \u221e\)\.", + ): + AdaBoost(learning_rate=learning_rate) diff --git a/tests/safeds/ml/classical/regression/test_elastic_net_regression.py b/tests/safeds/ml/classical/regression/test_elastic_net_regression.py index f8e47ae61..b225c9c78 100644 --- a/tests/safeds/ml/classical/regression/test_elastic_net_regression.py +++ b/tests/safeds/ml/classical/regression/test_elastic_net_regression.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import ElasticNetRegression @@ -19,9 +20,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.alpha == 1 - def test_should_raise_if_less_than_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'alpha' must be non-negative"): - ElasticNetRegression(alpha=-1) + @pytest.mark.parametrize("alpha", [-0.5], ids=["minus_0_point_5"]) + def test_should_raise_if_less_than_0(self, alpha: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"alpha \(={alpha}\) is not inside \[0, \u221e\)\.", + ): + ElasticNetRegression(alpha=alpha) def test_should_warn_if_equal_to_0(self) -> None: with pytest.warns( @@ -44,13 +49,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.l1_ratio == 0.3 - def test_should_raise_if_less_than_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'lasso_ratio' must be between 0 and 1."): - ElasticNetRegression(lasso_ratio=-1.0) - - def test_should_raise_if_greater_than_1(self) -> None: - with pytest.raises(ValueError, match="The parameter 'lasso_ratio' must be between 0 and 1."): - ElasticNetRegression(lasso_ratio=2.0) + @pytest.mark.parametrize("lasso_ratio", [-0.5, 1.5], ids=["minus_zero_point_5", "one_point_5"]) + def test_should_raise_if_not_between_0_and_1(self, lasso_ratio: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"lasso_ratio \(={lasso_ratio}\) is not inside \[0, 1\]\.", + ): + ElasticNetRegression(lasso_ratio=lasso_ratio) def test_should_warn_if_0(self) -> None: with pytest.warns( diff --git a/tests/safeds/ml/classical/regression/test_gradient_boosting.py b/tests/safeds/ml/classical/regression/test_gradient_boosting.py index bf92e381e..0243551ae 100644 --- a/tests/safeds/ml/classical/regression/test_gradient_boosting.py +++ b/tests/safeds/ml/classical/regression/test_gradient_boosting.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import GradientBoosting @@ -19,9 +20,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.n_estimators == 2 - def test_should_raise_if_less_than_1(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_trees' has to be greater than 0."): - GradientBoosting(number_of_trees=0) + @pytest.mark.parametrize("number_of_trees", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_1(self, number_of_trees: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_trees \(={number_of_trees}\) is not inside \[1, \u221e\)\.", + ): + GradientBoosting(number_of_trees=number_of_trees) class TestLearningRate: @@ -34,6 +39,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.learning_rate == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'learning_rate' has to be greater than 0."): - GradientBoosting(learning_rate=-1) + @pytest.mark.parametrize("learning_rate", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, learning_rate: float) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"learning_rate \(={learning_rate}\) is not inside \(0, \u221e\)\.", + ): + GradientBoosting(learning_rate=learning_rate) diff --git a/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py b/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py index 5450a99f3..69bb27cb4 100644 --- a/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py +++ b/tests/safeds/ml/classical/regression/test_k_nearest_neighbors.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import KNearestNeighbors @@ -19,9 +20,13 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.n_neighbors == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_neighbors' has to be greater than 0."): - KNearestNeighbors(number_of_neighbors=-1) + @pytest.mark.parametrize("number_of_neighbors", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, number_of_neighbors: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_neighbors \(={number_of_neighbors}\) is not inside \[1, \u221e\)\.", + ): + KNearestNeighbors(number_of_neighbors=number_of_neighbors) def test_should_raise_if_greater_than_sample_size(self, training_set: TaggedTable) -> None: with pytest.raises(ValueError, match="has to be less than or equal to the sample size"): diff --git a/tests/safeds/ml/classical/regression/test_lasso_regression.py b/tests/safeds/ml/classical/regression/test_lasso_regression.py index 332b9f1ad..6705aa948 100644 --- a/tests/safeds/ml/classical/regression/test_lasso_regression.py +++ b/tests/safeds/ml/classical/regression/test_lasso_regression.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import LassoRegression @@ -19,9 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.alpha == 1 - def test_should_raise_if_less_than_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'alpha' must be non-negative"): - LassoRegression(alpha=-1) + @pytest.mark.parametrize("alpha", [-0.5], ids=["minus_zero_point_5"]) + def test_should_raise_if_less_than_0(self, alpha: float) -> None: + with pytest.raises(OutOfBoundsError, match=rf"alpha \(={alpha}\) is not inside \[0, \u221e\)\."): + LassoRegression(alpha=alpha) def test_should_warn_if_equal_to_0(self) -> None: with pytest.warns( diff --git a/tests/safeds/ml/classical/regression/test_random_forest.py b/tests/safeds/ml/classical/regression/test_random_forest.py index 89f103704..409f7fca3 100644 --- a/tests/safeds/ml/classical/regression/test_random_forest.py +++ b/tests/safeds/ml/classical/regression/test_random_forest.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import RandomForest @@ -19,6 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.n_estimators == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'number_of_trees' has to be greater than 0."): - RandomForest(number_of_trees=-1) + @pytest.mark.parametrize("number_of_trees", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, number_of_trees: int) -> None: + with pytest.raises( + OutOfBoundsError, + match=rf"number_of_trees \(={number_of_trees}\) is not inside \[1, \u221e\)\.", + ): + RandomForest(number_of_trees=number_of_trees) diff --git a/tests/safeds/ml/classical/regression/test_ridge_regression.py b/tests/safeds/ml/classical/regression/test_ridge_regression.py index ee3feb0b8..954fb6bbf 100644 --- a/tests/safeds/ml/classical/regression/test_ridge_regression.py +++ b/tests/safeds/ml/classical/regression/test_ridge_regression.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import RidgeRegression @@ -19,9 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.alpha == 1 - def test_should_raise_if_less_than_0(self) -> None: - with pytest.raises(ValueError, match="The parameter 'alpha' must be non-negative"): - RidgeRegression(alpha=-1) + @pytest.mark.parametrize("alpha", [-0.5], ids=["minus_zero_point_5"]) + def test_should_raise_if_less_than_0(self, alpha: float) -> None: + with pytest.raises(OutOfBoundsError, match=rf"alpha \(={alpha}\) is not inside \[0, \u221e\)\."): + RidgeRegression(alpha=alpha) def test_should_warn_if_equal_to_0(self) -> None: with pytest.warns( diff --git a/tests/safeds/ml/classical/regression/test_support_vector_machine.py b/tests/safeds/ml/classical/regression/test_support_vector_machine.py index 634c45901..82bd86e81 100644 --- a/tests/safeds/ml/classical/regression/test_support_vector_machine.py +++ b/tests/safeds/ml/classical/regression/test_support_vector_machine.py @@ -1,5 +1,6 @@ import pytest from safeds.data.tabular.containers import Table, TaggedTable +from safeds.exceptions import OutOfBoundsError from safeds.ml.classical.regression import SupportVectorMachine @@ -19,12 +20,10 @@ def test_should_be_passed_to_sklearn(self, training_set: TaggedTable) -> None: assert fitted_model._wrapped_regressor is not None assert fitted_model._wrapped_regressor.C == 2 - def test_should_raise_if_less_than_or_equal_to_0(self) -> None: - with pytest.raises( - ValueError, - match="The parameter 'c' has to be strictly positive.", - ): - SupportVectorMachine(c=-1) + @pytest.mark.parametrize("c", [-1.0, 0.0], ids=["minus_one", "zero"]) + def test_should_raise_if_less_than_or_equal_to_0(self, c: float) -> None: + with pytest.raises(OutOfBoundsError, match=rf"c \(={c}\) is not inside \(0, \u221e\)\."): + SupportVectorMachine(c=c) class TestKernel: @@ -45,9 +44,10 @@ def test_should_get_sklearn_kernel_linear(self) -> None: linear_kernel = svm.kernel.get_sklearn_kernel() assert linear_kernel == "linear" - def test_should_raise_if_degree_less_than_1(self) -> None: - with pytest.raises(ValueError, match="The parameter 'degree' has to be greater than or equal to 1."): - SupportVectorMachine.Kernel.Polynomial(degree=0) + @pytest.mark.parametrize("degree", [-1, 0], ids=["minus_one", "zero"]) + def test_should_raise_if_degree_less_than_1(self, degree: int) -> None: + with pytest.raises(OutOfBoundsError, match=rf"degree \(={degree}\) is not inside \[1, \u221e\)\."): + SupportVectorMachine.Kernel.Polynomial(degree=degree) def test_should_get_sklearn_kernel_polynomial(self) -> None: svm = SupportVectorMachine(c=2, kernel=SupportVectorMachine.Kernel.Polynomial(degree=2))