# Copyright 2021-2023 Lawrence Livermore National Security, LLC and other
# MuyGPyS Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: MIT
"""
Scalar Hyperparameter
Hyperparameters specifications are expected to provide a numeric scalar or
string `val` a 2-tuple or string `bounds`. `bounds` is either a 2-tuple
indicating a lower and upper optimization bound or the string `"fixed"`, which
exempts the hyperparameter from optimization (`"fixed"` is the default behavior
if `bounds` is unspecified). `val` is either a floating point value (within the
range between the upper and lower bounds if specified) or the strings "sample"
or "log_sample", which sample a guess between the provided lower and upper
bounds.
"""
from collections.abc import Sequence
from numbers import Number
from typing import Callable, cast, List, Tuple, Union
import MuyGPyS._src.math.numpy as np
import MuyGPyS._src.math as mm
from MuyGPyS import config
from MuyGPyS._src.mpi_utils import _is_mpi_mode
[docs]class Parameter:
"""
A MuyGPs kernel or model Hyperparameter. Also called `ScalarParam`.
Hyperparameters are defined by a value and optimization bounds. Values must
be scalar numeric types, and bounds are either a len == 2 iterable container
whose elements are numeric scalars in increasing order, or the string
`fixed`. If `bounds == "fixed"` (the default behavior), the hyperparameter
value will remain fixed during optimization. `val` must remain within the
range of the upper and lower bounds, if not `fixed`.
Args:
val:
A scalar within the range of the upper and lower bounds (if given).
val can also be the strings `"sample"` or `"log_sample"`, which will
result in randomly sampling a value within the range given by the
bounds.
bounds:
Iterable container of len 2 containing lower and upper bounds (in
that order), or the string `"fixed"`.
Raises:
ValueError:
Any `bounds` string other than `"fixed"` will produce an error.
ValueError:
A non-iterable non-string type for `bounds` will produce an
error.
ValueError:
A `bounds` iterable of len other than 2 will produce an error.
ValueError:
Iterable `bounds` values of non-numeric types will produce an error.
ValueError:
A lower bound that is not less than an upper bound will produce
an error.
ValueError:
`val == "sample" or val == "log_sample"` will produce an error
if `self._bounds == "fixed"`.
ValueError:
Any string other than `"sample"` or `"log_sample"` will produce
an error.
ValueError:
A `val` outside of the range specified by `self._bounds` will
produce an error.
"""
def __init__(
self,
val: Union[str, float],
bounds: Union[str, Tuple[float, float]] = "fixed",
):
"""
Initialize a hyperparameter.
"""
self._set_bounds(bounds)
self._set_val(val)
def __str__(self, **kwargs):
bstring = "fixed" if self._fixed is True else self._bounds
return f"{type(self).__name__}({self._val}, {bstring})"
def _set(self, rhs) -> None:
"""
Reset hyperparameter value and/or bounds using keyword arguments.
Args:
rhs:
Another hyperparameter.
Raises:
ValueError:
Any `bounds` string other than `"fixed"` will produce an error.
ValueError:
A non-numeric, non-numeric, or non-string type for `bounds` will
produce an error.
ValueError:
A `bounds` iterable of len other than 2 will produce an error.
ValueError:
Iterable `bounds` values of non-numeric types will produce an
error.
ValueError:
A lower bound that is not less than an upper bound will produce
an error.
ValueError:
`val == "sample" or val == "log_sample"` will produce an error
if `self._bounds == "fixed"`.
ValueError:
Any string other than `"sample"` or `"log_sample"` will produce
an error.
ValueError:
A `val` outside of the range specified by `self._bounds` will
produce an error.
"""
self._val = rhs._val
self._bounds = rhs._bounds
self._fixed = rhs._fixed
def _sample_val(self, val: str) -> float:
if self.fixed() is True:
if isinstance(val, str):
raise ValueError(
f"Fixed bounds do not support string value ({val}) prompts."
)
if val == "sample":
newval = float(
np.random.uniform(low=self._bounds[0], high=self._bounds[1])
)
elif val == "log_sample":
newval = float(
np.exp(
np.random.uniform(
low=np.log(self._bounds[0]),
high=np.log(self._bounds[1]),
)
)
)
else:
raise ValueError(f"Unsupported string hyperparameter value {val}.")
if _is_mpi_mode() is True:
newval = config.mpi_state.comm_world.bcast(newval, root=0)
return newval
def _set_val(self, val: Union[str, float]) -> None:
"""
Set hyperparameter value; sample if appropriate.
Throws on out-of-range and other badness.
Args:
val:
A valid scalar value or the strings `"sample"` or
`"log_sample"`.
Raises:
ValueError:
A non-scalar, non-numeric and non-string val will produce an
error.
ValueError:
`val == "sample" or val == "log_sample"` will produce an error
if `bounds == "fixed"`.
ValueError:
Any `val` string other than `"sample"` or `"log_sample"` will
produce an error.
ValueError:
A `val` outside of the range specified by `bounds` will
produce an error.
"""
if isinstance(val, str):
val = self._sample_val(val)
if isinstance(val, Sequence) or hasattr(val, "__len__"):
raise ValueError(
f"Nonscalar hyperparameter value {val} is not allowed."
)
if not isinstance(val, mm.ndarray):
val = float(val)
if self.fixed() is False:
any_below = np.any(
np.choose(
cast(float, val) < cast(float, self._bounds[0]) - 1e-5,
[False, True],
)
)
any_above = np.any(
np.choose(
cast(float, val) > cast(float, self._bounds[1]) + 1e-5,
[False, True],
)
)
if any_below:
raise ValueError(
f"Hyperparameter value {val} is lesser than the "
f"optimization lower bound {self._bounds[0]}"
)
if any_above:
raise ValueError(
f"Hyperparameter value {val} is greater than the "
f"optimization upper bound {self._bounds[1]}"
)
self._val = mm.parameter(val)
def _set_bounds(
self,
bounds: Union[str, Tuple[float, float]],
) -> None:
"""
Set hyperparameter bounds.
Args:
bounds:
Iterable container of len 2 containing lower and upper bounds
(in that order), or the string `"fixed"`.
Raises:
ValueError:
Any string other than `"fixed"` will produce an error.
ValueError:
A non-iterable type will produce an error.
ValueError:
An iterable of len other than 2 will produce an error.
ValueError:
Iterable values of non-numeric types will produce an error.
ValueError:
A lower bound that is not less than an upper bound will produce
an error.
"""
if isinstance(bounds, str) is True:
if bounds == "fixed":
self._bounds = (0.0, 0.0) # default value
self._fixed = True
else:
raise ValueError(f"Unknown bound option {bounds}.")
else:
if hasattr(bounds, "__iter__") is not True:
raise ValueError(
f"Unknown bound optiom {bounds} of a non-iterable type "
f"{type(bounds)}."
)
if len(bounds) != 2:
raise ValueError(
f"Provided hyperparameter optimization bounds have "
f"unsupported length {len(bounds)}."
)
if isinstance(bounds[0], Number) is not True:
raise ValueError(
f"Nonscalar {bounds[0]} of type {type(bounds[0])} is not a "
f"supported hyperparameter bound type."
)
if isinstance(bounds[1], Number) is not True:
raise ValueError(
f"Nonscalar {bounds[1]} of type {type(bounds[1])} is not a "
f"supported hyperparameter bound type."
)
bounds = (float(bounds[0]), float(bounds[1]))
if bounds[0] > bounds[1]:
raise ValueError(
f"Lower bound {bounds[0]} is not lesser than upper bound "
f"{bounds[1]}."
)
self._bounds = bounds
self._fixed = False
[docs] def __call__(self) -> float:
"""
Value accessor.
Returns:
The current value of the hyperparameter.
"""
return self._val
[docs] def get_bounds(self) -> Tuple[float, float]:
"""
Bounds accessor.
Returns:
The lower and upper bound tuple.
"""
return self._bounds
[docs] def fixed(self) -> bool:
"""
Report whether the parameter is fixed, and is to be ignored during
optimization.
Returns:
`True` if fixed, `False` otherwise.
"""
return self._fixed
def apply_fn(self, fn: Callable, name: str) -> Callable:
def applied_fn(*args, **kwargs):
kwargs.setdefault(name, self())
return fn(*args, **kwargs)
return applied_fn
def append_lists(
self,
name: str,
names: List[str],
params: List[float],
bounds: List[Tuple[float, float]],
):
if not self.fixed():
names.append(name)
params.append(self())
bounds.append(self.get_bounds())