Source code for moptipy.evaluation.axis_ranger

"""A utility to specify axis ranges."""
import sys
from math import inf, isfinite
from typing import Callable, Final

import numpy as np
from matplotlib.axes import Axes  # type: ignore
from pycommons.types import type_error

from moptipy.api.logging import (
    KEY_BEST_F,
    KEY_LAST_IMPROVEMENT_FE,
    KEY_LAST_IMPROVEMENT_TIME_MILLIS,
    KEY_TOTAL_FES,
    KEY_TOTAL_TIME_MILLIS,
)
from moptipy.evaluation.base import (
    F_NAME_NORMALIZED,
    F_NAME_RAW,
    F_NAME_SCALED,
    TIME_UNIT_FES,
    TIME_UNIT_MILLIS,
)
from moptipy.evaluation.end_statistics import KEY_ERT_FES, KEY_ERT_TIME_MILLIS

#: The internal minimum float value for log-scaled axes.
_MIN_LOG_FLOAT: Final[float] = sys.float_info.min


[docs] class AxisRanger: """An object for simplifying axis range computations.""" def __init__(self, chosen_min: float | None = None, chosen_max: float | None = None, use_data_min: bool = True, use_data_max: bool = True, log_scale: bool = False, log_base: float | None = None): """ Initialize the axis ranger. :param chosen_min: the chosen minimum :param chosen_max: the chosen maximum :param use_data_min: use the minimum found in the data? :param use_data_max: use the maximum found in the data? :param log_scale: should the axis be log-scaled? :param log_base: the base to be used for the logarithm """ if not isinstance(log_scale, bool): raise type_error(log_scale, "log_scale", bool) #: Should the axis be log-scaled? self.log_scale: Final[bool] = log_scale self.__log_base: Final[float | None] = \ log_base if self.log_scale else None if self.__log_base is not None: if not isinstance(log_base, float): raise type_error(log_base, "log_base", float) if log_base <= 1.0: raise ValueError(f"log_base must be > 1, but is {log_base}.") if chosen_min is not None: if not isinstance(chosen_min, float | int): raise type_error(chosen_min, "chosen_min", (int, float)) chosen_min = float(chosen_min) if not isfinite(chosen_min): raise ValueError(f"chosen_min cannot be {chosen_min}.") if self.log_scale and (chosen_min <= 0): raise ValueError( f"if log_scale={self.log_scale}, then chosen_min must " f"be > 0, but is {chosen_min}.") #: The pre-defined, chosen minimum axis value. self.__chosen_min: Final[float | None] = chosen_min if chosen_max is not None: if not isinstance(chosen_max, float | int): raise type_error(chosen_max, "chosen_max", (int, float)) chosen_max = float(chosen_max) if not isfinite(chosen_max): raise ValueError(f"chosen_max cannot be {chosen_max}.") if (self.__chosen_min is not None) and \ (chosen_max <= self.__chosen_min): raise ValueError(f"If chosen_min is {self.__chosen_min}, then" f" chosen_max cannot be {chosen_max}.") #: The pre-defined, chosen maximum axis value. self.__chosen_max: Final[float | None] = chosen_max if not isinstance(use_data_min, bool): raise type_error(use_data_min, "use_data_min", bool) #: Should we use the data min value? self.__use_data_min: Final[bool] = use_data_min if not isinstance(use_data_max, bool): raise type_error(use_data_max, "use_data_max", bool) #: Should we use the data max value? self.__use_data_max: Final[bool] = use_data_max #: The minimum detected from the data. self.__detected_min: float = inf #: The maximum detected from the data. self.__detected_max: float = _MIN_LOG_FLOAT if self.log_scale \ else -inf #: Did we detect a minimum? self.__has_detected_min = False #: Did we detect a maximum? self.__has_detected_max = False
[docs] def register_array(self, data: np.ndarray) -> None: """ Register a data array. :param data: the data to register """ if self.__use_data_min or self.__use_data_max: d = data[np.isfinite(data)] if self.__use_data_min: self.register_value(float(d.min())) if self.__use_data_max: self.register_value(float(d.max()))
[docs] def register_value(self, value: float) -> None: """ Register a single value. :param value: the data to register """ if isfinite(value): if self.__use_data_min and ( (value < self.__detected_min) and ((value > 0.0) or (not self.log_scale))): self.__detected_min = value self.__has_detected_min = True if self.__use_data_max and (value > self.__detected_max): self.__detected_max = value self.__has_detected_max = True
[docs] def pad_detected_range(self, pad_min: bool = False, pad_max: bool = False) -> None: """ Add some padding to the current detected range. This function increases the current detected or chosen maximum value and/or decreases the current detected minimum by a small amount. This can be useful when we want to plot stuff that otherwise would become invisible because it would be directly located at the boundary of a plot. This function works by computing a slightly smaller/larger value than the current detected minimum/maximum and then passing it to :meth:`register_value`. It can only work if the end(s) chosen for padding are in "detect" mode and the other end is either in "detect" or "chosen" mode. This method should be called *only* once and *only* after all data has been registered (via :meth:`register_value` :meth:`register_array`) and before calling :meth:`apply`. :param pad_min: should we pad the minimum? :param pad_max: should we pad the maximum? :raises ValueError: if this axis ranger is not configured to use a detected minimum/maximum or does not have a detected minimum/maximum or any other invalid situation occurs """ if not isinstance(pad_min, bool): raise type_error(pad_min, "pad_min", bool) if not isinstance(pad_max, bool): raise type_error(pad_max, "pad_max", bool) if not (pad_min or pad_max): return max_value: float min_value: float if self.__use_data_min: if not self.__has_detected_min: raise ValueError("No minimum detected so far.") min_value = self.__detected_min else: if pad_min: raise ValueError("Can only pad minimum if use_data_min.") if self.__chosen_min is None: raise ValueError("Chosen min is None!") min_value = self.__chosen_min if self.__use_data_max: if not self.__has_detected_max: raise ValueError("No maximum detected so far.") max_value = self.__detected_max else: if pad_max: raise ValueError("Can only pad maximum if use_data_max.") if self.__chosen_max is None: raise ValueError("Chosen max is None!") max_value = self.__chosen_max if min_value >= max_value: raise ValueError( f"minimum={min_value} while maximum={max_value}.") new_max: float if pad_max: if max_value >= inf: return new_max = max_value + (3.0 * (max_value - min_value)) / 100.0 if not isfinite(new_max) or (new_max <= max_value): raise ValueError(f"invalid padded max={new_max} at min=" f"{min_value} and max={max_value}.") self.register_value(new_max) else: new_max = max_value new_min: float if pad_min: if min_value <= -inf: return new_min = min_value - (3.0 * (max_value - min_value)) / 100.0 if self.log_scale and (new_min <= 0.0 < min_value): new_min = 0.5 * min_value if not isfinite(new_min) or (new_min >= min_value): raise ValueError(f"invalid padded min={new_min} at min=" f"{min_value} and max={max_value}.") self.register_value(new_min) else: new_min = min_value if new_min > new_max: raise ValueError(f"new_min={new_min}, new_max={new_max}??")
[docs] def apply(self, axes: Axes, which_axis: str) -> None: """ Apply this axis ranger to the given axis. :param axes: the axes object to which the ranger shall be applied :param which_axis: the axis to which it should be applied, either `"x"` or `"y"` or both (`"xy"`) """ if not isinstance(which_axis, str): raise type_error(which_axis, "which_axis", str) for is_x_axis in (True, False): if ("x" if is_x_axis else "y") not in which_axis: continue use_min, use_max = \ axes.get_xlim() if is_x_axis else axes.get_ylim() if not isfinite(use_min): raise ValueError(f"Minimum data interval cannot be {use_min}.") if not isfinite(use_max): raise ValueError(f"Maximum data interval cannot be {use_max}.") if use_max <= use_min: raise ValueError(f"Invalid axis range[{use_min},{use_max}].") replace_range = False if self.__chosen_min is not None: use_min = self.__chosen_min replace_range = True elif self.__use_data_min: if not self.__has_detected_min: raise ValueError("No minimum in data detected.") use_min = self.__detected_min replace_range = True if self.__chosen_max is not None: use_max = self.__chosen_max replace_range = True elif self.__use_data_max: if not self.__has_detected_max: raise ValueError("No maximum in data detected.") use_max = self.__detected_max replace_range = True if replace_range: if use_min >= use_max: raise ValueError( f"Invalid computed range [{use_min},{use_max}].") if is_x_axis: axes.set_xlim(use_min, use_max) else: axes.set_ylim(use_min, use_max) if self.log_scale: if use_min <= 0: raise ValueError("minimum must be positive if log scale " f"is defined, but found {use_min}.") if is_x_axis: if self.__log_base is None: axes.semilogx() else: axes.semilogx(base=self.__log_base) elif self.__log_base is None: axes.semilogy() else: axes.semilogy(base=self.__log_base)
[docs] def get_pinf_replacement(self) -> float: """ Get a reasonable finite value that can replace positive infinity. :return: a reasonable finite value that can be used to replace positive infinity """ data_max: float = 0.0 if self.__chosen_max is not None: data_max = self.__chosen_max elif self.__has_detected_max: data_max = self.__detected_max return min(1e100, max(1e70, 1e5 * data_max))
[docs] def get_0_replacement(self) -> float: """ Get a reasonable positive finite value that can replace `0`. :return: a reasonable finite value that can be used to replace `0` """ data_min: float = 1e-100 if self.__chosen_min is not None: data_min = self.__chosen_min elif self.__has_detected_min: data_min = self.__detected_min return max(1e-100, min(1e-70, 1e-5 * data_min))
[docs] @staticmethod def for_axis(name: str, chosen_min: float | None = None, chosen_max: float | None = None, use_data_min: bool | None = None, use_data_max: bool | None = None, log_scale: bool | None = None, log_base: float | None = None) -> "AxisRanger": """ Create a default axis ranger based on the axis type. The axis ranger will use the minimal values and log scaling options that usually make sense for the dimension, unless overridden by the optional arguments. :param name: the axis type name, supporting `"ms"`, `"FEs"`, `"plainF"`, `"scaledF"`, and `"normalizedF"` :param chosen_min: the chosen minimum :param chosen_max: the chosen maximum :param use_data_min: should the data minimum be used :param use_data_max: should the data maximum be used :param log_scale: the log scale indicator :param log_base: the log base :return: the `AxisRanger` """ if not isinstance(name, str): raise type_error(name, "axis name", str) __log: bool = False __min: float | None = None __max: float | None = None __data_min: bool = chosen_min is None __data_max: bool = chosen_max is None if name in (TIME_UNIT_MILLIS, KEY_LAST_IMPROVEMENT_TIME_MILLIS, KEY_TOTAL_TIME_MILLIS, KEY_ERT_TIME_MILLIS): if chosen_min is not None: if (chosen_min < 0) or (not isfinite(chosen_min)): raise ValueError("chosen_min must be >= 0 for axis " f"type {name}, but is {chosen_min}.") __log = (chosen_min > 0) if log_scale is not None: if log_scale and (not __log): raise ValueError(f"Cannot set log_scale={log_scale} " f"and chosen_min={chosen_min} for " f"axis type {name}.") __log = log_scale elif log_scale is None: __log = True else: __log = log_scale __min = (1 if __log else 0) if chosen_min is None else chosen_min if use_data_max is not None: __data_max = use_data_max __data_min = False if use_data_min is None else use_data_min if chosen_max is not None: __max = chosen_max return AxisRanger(__min, __max, __data_min, __data_max, __log, log_base if __log else None) if name in (TIME_UNIT_FES, KEY_LAST_IMPROVEMENT_FE, KEY_TOTAL_FES, KEY_ERT_FES): if chosen_min is None: __min = 1 else: if (chosen_min < 1) or (not isfinite(chosen_min)): raise ValueError("chosen_min must be >= 1 for axis " f"type {name}, but is {chosen_min}.") __min = chosen_min __log = True if (log_scale is None) else log_scale if use_data_max is not None: __data_max = use_data_max __data_min = False if use_data_min is None else use_data_min return AxisRanger(__min, chosen_max, __data_min, __data_max, __log, log_base if __log else None) if name in (F_NAME_RAW, KEY_BEST_F): if use_data_max is not None: __data_max = use_data_max if use_data_min is not None: __data_min = use_data_min if log_scale is not None: __log = log_scale return AxisRanger(chosen_min, chosen_max, __data_min, __data_max, __log, log_base if __log else None) if name == F_NAME_SCALED: __min = 1 elif name == F_NAME_NORMALIZED: if (log_scale is None) or (not log_scale): __min = 0 elif name == "ecdf": if (log_scale is None) or (not log_scale): __min = 0 __max = 1 else: raise ValueError(f"Axis type {name!r} is unknown.") if chosen_min is not None: __min = chosen_min if log_scale is not None: __log = log_scale if use_data_max is not None: __data_max = use_data_max if use_data_min is not None: __data_min = use_data_min return AxisRanger(__min, chosen_max, __data_min, __data_max, __log, log_base if __log else None)
[docs] @staticmethod def for_axis_func(chosen_min: float | None = None, chosen_max: float | None = None, use_data_min: bool | None = None, use_data_max: bool | None = None, log_scale: bool | None = None, log_base: float | None = None) -> Callable: """ Generate a function that provides the default per-axis ranger. :param chosen_min: the chosen minimum :param chosen_max: the chosen maximum :param use_data_min: should the data minimum be used :param use_data_max: should the data maximum be used :param log_scale: the log scale indicator :param log_base: the log base :return: a function in the shape of :meth:`for_axis` with the provided defaults """ def __func(name: str, cmi=chosen_min, cma=chosen_max, udmi=use_data_min, udma=use_data_max, ls=log_scale, lb=log_base) -> AxisRanger: return AxisRanger.for_axis(name, cmi, cma, udmi, udma, ls, lb) return __func