Skip to content

Commit

Permalink
feat: new error class OutOfBoundsError (#438)
Browse files Browse the repository at this point in the history
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 <alexgreus51@gmail.com>
Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com>
Co-authored-by: Lars Reimann <mail@larsreimann.com>
  • Loading branch information
4 people committed Jul 11, 2023
1 parent 2e59d4b commit 1f37e4a
Show file tree
Hide file tree
Showing 34 changed files with 673 additions and 130 deletions.
7 changes: 4 additions & 3 deletions src/safeds/data/image/containers/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
12 changes: 9 additions & 3 deletions src/safeds/data/tabular/transformation/_discretizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -18,7 +24,7 @@ class Discretizer(TableTransformer):
Raises
------
ValueError
OutOfBoundsError
If the given number_of_bins is less than 2.
"""

Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/safeds/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
ValueNotPresentWhenFittedError,
WrongFileExtensionError,
)
from safeds.exceptions._generic import (
Bound,
ClosedBound,
OpenBound,
OutOfBoundsError,
)
from safeds.exceptions._ml import (
DatasetContainsTargetError,
DatasetMissesDataError,
Expand All @@ -26,6 +32,8 @@
)

__all__ = [
# Generic exceptions
"OutOfBoundsError",
# Data exceptions
"ColumnIsTargetError",
"ColumnLengthMismatchError",
Expand All @@ -48,4 +56,8 @@
"ModelNotFittedError",
"PredictionError",
"UntaggedTableError",
# Other
"Bound",
"ClosedBound",
"OpenBound",
]
281 changes: 281 additions & 0 deletions src/safeds/exceptions/_generic.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 1f37e4a

Please sign in to comment.