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

1"""An implementation of an box-constrained n-dimensional continuous space.""" 

2 

3from math import isfinite 

4from typing import Callable, Final, Iterable, cast 

5 

6import numpy as np 

7from numpy import clip 

8from pycommons.types import type_error 

9 

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 

13 

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" 

18 

19 

20class VectorSpace(NPArraySpace): 

21 """ 

22 A vector space where each element is an n-dimensional real vector. 

23 

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. 

28 

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 """ 

57 

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. 

64 

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}.") 

75 

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)) 

104 

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)) 

133 

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]}") 

139 

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 

148 

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. 

153 

154 This function is useful to ensure that only valid vectors are passed 

155 to :meth:`~moptipy.api.process.Process.evaluate`. 

156 

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))) 

163 

164 def validate(self, x: np.ndarray) -> None: 

165 """ 

166 Validate a vector. 

167 

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) 

174 

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}].") 

185 

186 def n_points(self) -> int: 

187 """ 

188 Get an upper bound for the number of different values in this space. 

189 

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. 

193 

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}.") 

212 

213 base = 2 * ((2 ** exponent) - 1) * (2 ** mantissa) - 1 

214 return base ** self.dimension 

215 

216 def __str__(self) -> str: 

217 """ 

218 Get the name of this space. 

219 

220 :return: "r" + dimension + dtype.char 

221 

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}" 

227 

228 def log_bounds(self, logger: KeyValueLogSection) -> None: 

229 """ 

230 Log the bounds of this space to the given logger. 

231 

232 :param logger: the logger for the parameters 

233 

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)) 

258 

259 def log_parameters_to(self, logger: KeyValueLogSection) -> None: 

260 """ 

261 Log the parameters of this space to the given logger. 

262 

263 :param logger: the logger for the parameters 

264 

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)