Coverage for moptipy / spaces / vectorspace.py: 80%
101 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
1"""An implementation of an box-constrained n-dimensional continuous space."""
3from math import isfinite
4from typing import Callable, Final, Iterable, cast
6import numpy as np
7from numpy import clip
8from pycommons.types import type_error
10from moptipy.spaces.nparrayspace import NPArraySpace
11from moptipy.utils.logger import KeyValueLogSection
12from moptipy.utils.nputils import DEFAULT_FLOAT, array_to_str, is_np_float
14#: the log key for the lower bound, i.e., the minimum permitted value
15KEY_LOWER_BOUND: Final[str] = "lb"
16#: the log key for the upper bound, i.e., the maximum permitted value
17KEY_UPPER_BOUND: Final[str] = "ub"
20class VectorSpace(NPArraySpace):
21 """
22 A vector space where each element is an n-dimensional real vector.
24 Such spaces are useful for continuous optimization. The vectors are
25 implemented as one-dimensional :class:`numpy.ndarray` of length `n`.
26 A vector space is constraint by a box which defines the minimum and
27 maximum permitted value for each of its `n` elements.
29 >>> s = VectorSpace(3)
30 >>> print(s.dimension)
31 3
32 >>> print(s.dtype)
33 float64
34 >>> print(s.lower_bound)
35 [0. 0. 0.]
36 >>> print(s.upper_bound)
37 [1. 1. 1.]
38 >>> print(s.lower_bound_all_same)
39 True
40 >>> print(s.upper_bound_all_same)
41 True
42 >>> s = VectorSpace(2, -1.0, 5.0)
43 >>> print(s.lower_bound)
44 [-1. -1.]
45 >>> print(s.upper_bound)
46 [5. 5.]
47 >>> s = VectorSpace(2, [-1.0, -2.0], 5.0)
48 >>> print(s.lower_bound)
49 [-1. -2.]
50 >>> print(s.upper_bound)
51 [5. 5.]
52 >>> print(s.lower_bound_all_same)
53 False
54 >>> print(s.upper_bound_all_same)
55 True
56 """
58 def __init__(self, dimension: int,
59 lower_bound: float | Iterable[float] = 0.0,
60 upper_bound: float | Iterable[float] = 1.0,
61 dtype: np.dtype = DEFAULT_FLOAT) -> None:
62 """
63 Create the vector-based search space.
65 :param dimension: The dimension of the search space,
66 i.e., the number of decision variables.
67 :param dtype: The basic data type of the vector space,
68 i.e., the type of the decision variables
69 :param lower_bound: the optional minimum value(s)
70 :param upper_bound: the optional maximum value(s)
71 """
72 super().__init__(dimension, dtype)
73 if not is_np_float(dtype):
74 raise TypeError(f"Invalid data type {dtype}.")
76 # first, we process the lower bounds
77 lower_bound_all_same: bool = True
78 if isinstance(lower_bound, float):
79 # only a single value is given
80 if not isfinite(lower_bound):
81 raise ValueError(
82 f"invalid lower bound {lower_bound}.")
83 # if a single value is given, then we expand it a vector
84 lower_bound = np.full(dimension, lower_bound, dtype)
85 elif isinstance(lower_bound, Iterable):
86 # lower bounds are given as vector or iterable
87 lb = np.array(lower_bound, dtype)
88 if len(lb) != dimension:
89 raise ValueError(f"wrong length {lb} of lower "
90 f"bound iterable {lower_bound}")
91 if lb.shape != (dimension, ):
92 raise ValueError(f"invalid shape={lb.shape} of "
93 f"lower bound {lower_bound}")
94 first = lb[0]
95 for index, item in enumerate(lb):
96 if first != item:
97 lower_bound_all_same = False
98 if not np.isfinite(item):
99 raise ValueError(f"{index}th lower bound={item}")
100 lower_bound = lb
101 else:
102 raise type_error(lower_bound, "lower_bound", (
103 float, Iterable, None))
105 # now, we process the upper bounds
106 upper_bound_all_same: bool = True
107 if isinstance(upper_bound, float):
108 # only a single value is given
109 if not isfinite(upper_bound):
110 raise ValueError(
111 f"invalid upper bound {upper_bound}.")
112 # if a single value is given, then we expand it a vector
113 upper_bound = np.full(dimension, upper_bound, dtype)
114 elif isinstance(upper_bound, Iterable):
115 # upper bounds are given as vector or iterable
116 lb = np.array(upper_bound, dtype)
117 if len(lb) != dimension:
118 raise ValueError(f"wrong length {lb} of upper "
119 f"bound iterable {upper_bound}")
120 if lb.shape != (dimension,):
121 raise ValueError(f"invalid shape={lb.shape} of "
122 f"upper bound {upper_bound}")
123 first = lb[0]
124 for index, item in enumerate(lb):
125 if first != item:
126 upper_bound_all_same = False
127 if not np.isfinite(item):
128 raise ValueError(f"{index}th upper bound={item}")
129 upper_bound = lb
130 else:
131 raise type_error(upper_bound, "upper_bound", (
132 float, Iterable, None))
134 # check that the bounds are consistent
135 for idx, ll in enumerate(lower_bound):
136 if not ll < upper_bound[idx]:
137 raise ValueError(f"lower_bound[{idx}]={ll} >= "
138 f"upper_bound[{idx}]={upper_bound[idx]}")
140 #: the lower bounds for all variables
141 self.lower_bound: Final[np.ndarray] = lower_bound
142 #: all dimensions have the same lower bound
143 self.lower_bound_all_same: Final[bool] = lower_bound_all_same
144 #: the upper bounds for all variables
145 self.upper_bound: Final[np.ndarray] = upper_bound
146 #: all dimensions have the same upper bound
147 self.upper_bound_all_same: Final[bool] = upper_bound_all_same
149 def clipped(self, func: Callable[[np.ndarray], int | float]) \
150 -> Callable[[np.ndarray], int | float]:
151 """
152 Wrap a function ensuring that all vectors are clipped to the bounds.
154 This function is useful to ensure that only valid vectors are passed
155 to :meth:`~moptipy.api.process.Process.evaluate`.
157 :param func: the function to wrap
158 :returns: the wrapped function
159 """
160 return cast("Callable[[np.ndarray], int | float]",
161 lambda x, lb=self.lower_bound, ub=self.upper_bound,
162 ff=func: ff(clip(x, lb, ub, x)))
164 def validate(self, x: np.ndarray) -> None:
165 """
166 Validate a vector.
168 :param x: the real vector
169 :raises TypeError: if the string is not an :class:`numpy.ndarray`.
170 :raises ValueError: if the shape of the vector is wrong or any of its
171 element is not finite.
172 """
173 super().validate(x)
175 mib: Final[np.ndarray] = self.lower_bound
176 mab: Final[np.ndarray] = self.upper_bound
177 for index, item in enumerate(x):
178 miv = mib[index]
179 mav = mab[index]
180 if not np.isfinite(item):
181 raise ValueError(f"x[{index}]={item}, which is not finite")
182 if not (miv <= item <= mav):
183 raise ValueError(
184 f"x[{index}]={item}, but should be in [{miv},{mav}].")
186 def n_points(self) -> int:
187 """
188 Get an upper bound for the number of different values in this space.
190 :return: We return the approximate number of finite floating point
191 numbers while ignoring the box constraint. This value here therefore
192 is an upper bound.
194 >>> import numpy as npx
195 >>> print(VectorSpace(3, dtype=npx.dtype(npx.float64)).n_points())
196 6267911251143764491534102180507836301813760039183993274367
197 """
198 if self.dtype.char == "e":
199 exponent = 5
200 mantissa = 10
201 elif self.dtype.char == "f":
202 exponent = 8
203 mantissa = 23
204 elif self.dtype == "d":
205 exponent = 11
206 mantissa = 52
207 elif self.dtype == "g":
208 exponent = 15
209 mantissa = 112
210 else:
211 raise ValueError(f"Invalid dtype {self.dtype}.")
213 base = 2 * ((2 ** exponent) - 1) * (2 ** mantissa) - 1
214 return base ** self.dimension
216 def __str__(self) -> str:
217 """
218 Get the name of this space.
220 :return: "r" + dimension + dtype.char
222 >>> import numpy as npx
223 >>> print(VectorSpace(3, dtype=npx.dtype(npx.float64)))
224 r3d
225 """
226 return f"r{self.dimension}{self.dtype.char}"
228 def log_bounds(self, logger: KeyValueLogSection) -> None:
229 """
230 Log the bounds of this space to the given logger.
232 :param logger: the logger for the parameters
234 >>> from moptipy.utils.logger import InMemoryLogger
235 >>> import numpy as npx
236 >>> space = VectorSpace(2, -5.0, [2.0, 3.0])
237 >>> with InMemoryLogger() as l:
238 ... with l.key_values("C") as kv:
239 ... space.log_bounds(kv)
240 ... text = l.get_log()
241 >>> text[-2]
242 'ub: 2;3'
243 >>> text[-3]
244 'lb: -5'
245 >>> len(text)
246 4
247 """
248 if self.lower_bound_all_same:
249 logger.key_value(
250 KEY_LOWER_BOUND, self.lower_bound[0], also_hex=False)
251 else:
252 logger.key_value(KEY_LOWER_BOUND, array_to_str(self.lower_bound))
253 if self.upper_bound_all_same:
254 logger.key_value(
255 KEY_UPPER_BOUND, self.upper_bound[0], also_hex=False)
256 else:
257 logger.key_value(KEY_UPPER_BOUND, array_to_str(self.upper_bound))
259 def log_parameters_to(self, logger: KeyValueLogSection) -> None:
260 """
261 Log the parameters of this space to the given logger.
263 :param logger: the logger for the parameters
265 >>> from moptipy.utils.logger import InMemoryLogger
266 >>> import numpy as npx
267 >>> space = VectorSpace(2, -5.0, [2.0, 3.0])
268 >>> space.dimension
269 2
270 >>> space.dtype.char
271 'd'
272 >>> with InMemoryLogger() as l:
273 ... with l.key_values("C") as kv:
274 ... space.log_parameters_to(kv)
275 ... text = l.get_log()
276 >>> text[-2]
277 'ub: 2;3'
278 >>> text[-3]
279 'lb: -5'
280 >>> text[-4]
281 'dtype: d'
282 >>> text[-5]
283 'nvars: 2'
284 >>> text[-6]
285 'class: moptipy.spaces.vectorspace.VectorSpace'
286 >>> text[-7]
287 'name: r2d'
288 >>> len(text)
289 8
290 """
291 super().log_parameters_to(logger)
292 self.log_bounds(logger)