Coverage for moptipy / mo / problem / basic_mo_problem.py: 80%

133 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1"""The base class for implementing multi-objective problems.""" 

2from math import inf, isfinite 

3from typing import Any, Callable, Final, Iterable 

4 

5import numpy as np 

6from numpy import empty 

7from pycommons.types import type_error 

8 

9from moptipy.api.logging import KEY_SPACE_NUM_VARS, SCOPE_OBJECTIVE_FUNCTION 

10from moptipy.api.mo_problem import MOProblem 

11from moptipy.api.mo_utils import dominates 

12from moptipy.api.objective import Objective 

13from moptipy.utils.logger import KeyValueLogSection 

14from moptipy.utils.math import try_int 

15from moptipy.utils.nputils import ( 

16 KEY_NUMPY_TYPE, 

17 dtype_for_data, 

18 numpy_type_to_str, 

19) 

20 

21 

22class BasicMOProblem(MOProblem): 

23 """ 

24 The base class for implementing multi-objective optimization problems. 

25 

26 This class allows to construct a simple python function for scalarizing 

27 a vector of objective values in its constructor and also determines the 

28 right datatype for the objective vectors. 

29 

30 It therefore first obtains the type (integers or floats?) of the objective 

31 values as well as the bounds of the objective functions. This is used to 

32 determine the right numpy `dtype` for the objective vectors. We want to 

33 represent objective vectors as compact as possible and use an integer 

34 vector if possible. 

35 

36 Once this information is obtained, we invoke a call-back function 

37 `get_scalarizer` which should return a python function that computes the 

38 scalarization result, i.e., the single scalar value representing the 

39 vector of objective values in single-objective optimization. This function 

40 must be monotonous. If the bounds are finite, it is applied to the vector 

41 of lower and upper bounds to get the lower and upper bounds of the 

42 scalarization result. 

43 

44 Examples for implementing this class are 

45 class:`~moptipy.mo.problem.weighted_sum.WeightedSum` and 

46 :class:`~moptipy.mo.problem.weighted_sum.Prioritize`, which represent a 

47 multi-objective optimization problem either as weighted sum or by 

48 priorizing the objective value (via an internal weighted sum). 

49 """ 

50 

51 def __init__(self, objectives: Iterable[Objective], 

52 get_scalarizer: Callable[[bool, int, list[int | float], 

53 list[int | float]], 

54 Callable[[np.ndarray], int | float]] | None = None, 

55 domination: Callable[[np.ndarray, np.ndarray], int] | None 

56 = dominates) -> None: 

57 """ 

58 Create the basic multi-objective optimization problem. 

59 

60 :param objectives: the objective functions 

61 :param get_scalarizer: Create the function for scalarizing the 

62 objective values. This constructor receives as parameters a `bool` 

63 which is `True` if and only if all objective functions always 

64 return integers and `False` otherwise, i.e., if at least one of 

65 them may return a `float`, the length of the f-vectors, and lists 

66 with the lower and upper bounds of the objective functions. It can 

67 use this information to dynamically create and return the most 

68 efficient scalarization function. 

69 :param domination: a function reflecting the domination relationship 

70 between two vectors of objective values. It must obey the contract 

71 of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is 

72 the same as :func:`moptipy.api.mo_utils.dominates`, to which it 

73 defaults. `None` overrides nothing. 

74 """ 

75 if not isinstance(objectives, Iterable): 

76 raise type_error(objectives, "objectives", Iterable) 

77 if not callable(get_scalarizer): 

78 raise type_error(get_scalarizer, "get_scalarizer", call=True) 

79 

80 lower_bounds: Final[list[int | float]] = [] 

81 upper_bounds: Final[list[int | float]] = [] 

82 calls: Final[list[Callable[[Any], int | float]]] = [] 

83 min_lower_bound: int | float = inf 

84 max_upper_bound: int | float = -inf 

85 

86 # Iterate over all objective functions and see whether they are 

87 # integer-valued and have finite bounds and to collect the bounds. 

88 always_int: bool = True 

89 is_int: bool 

90 lb: int | float 

91 ub: int | float 

92 for objective in objectives: 

93 if not isinstance(objective, Objective): 

94 raise type_error(objective, "objective[i]", Objective) 

95 is_int = objective.is_always_integer() 

96 always_int = always_int and is_int 

97 calls.append(objective.evaluate) 

98 lb = objective.lower_bound() 

99 if isfinite(lb): 

100 if is_int: 

101 if not isinstance(lb, int): 

102 raise ValueError( 

103 f"if is_always_integer() of objective {objective}" 

104 " is True, then lower_bound() must be infinite or" 

105 f" int, but is {lb}.") 

106 else: 

107 lb = try_int(lb) 

108 min_lower_bound = min(min_lower_bound, lb) 

109 else: 

110 min_lower_bound = -inf 

111 lower_bounds.append(lb) 

112 ub = objective.upper_bound() 

113 if isfinite(ub): 

114 if is_int: 

115 if not isinstance(ub, int): 

116 raise ValueError( 

117 f"if is_always_integer() of objective {objective}" 

118 " is True, then upper_bound() must be infinite " 

119 f"or int, but is {ub}.") 

120 else: 

121 ub = try_int(ub) 

122 max_upper_bound = max(max_upper_bound, ub) 

123 else: 

124 max_upper_bound = inf 

125 if lb >= ub: 

126 raise ValueError( 

127 f"lower_bound()={lb} of objective {objective} must " 

128 f"be < than upper_bound()={ub}") 

129 upper_bounds.append(ub) 

130 

131 n: Final[int] = len(calls) 

132 if n <= 0: 

133 raise ValueError("No objective function found!") 

134 

135 use_lb: int | float = min_lower_bound 

136 use_ub: int | float = max_upper_bound 

137 if always_int: 

138 if isfinite(min_lower_bound) and isfinite(max_upper_bound): 

139 use_lb = min(min_lower_bound, 

140 min_lower_bound - max_upper_bound) 

141 use_ub = max(max_upper_bound, 

142 max_upper_bound - min_lower_bound) 

143 else: 

144 use_lb = -inf 

145 use_ub = inf 

146 

147 # Based on the above findings, determine the data type: 

148 #: The data type of the objective vectors. 

149 #: If the objectives all always are integers and have known and finite 

150 #: bounds, then we can use the smallest possible integer type. 

151 #: This type will be large enough to allow computing "a - b" of any two 

152 #: objective values "a" and "b" without overflow. 

153 #: If they are at least integer-valued, we can use the largest integer 

154 #: type. 

155 #: If also this is not True, then we just use floating points. 

156 self.__dtype: Final[np.dtype] = dtype_for_data( 

157 always_int, use_lb, use_ub) 

158 #: The dimension of the objective space. 

159 self.__dimension: Final[int] = n 

160 

161 #: the creator function for objective vectors 

162 self.f_create = lambda nn=n, dt=self.__dtype: empty( # type: ignore 

163 nn, dt) # type: ignore 

164 

165 #: the holder for lower bounds 

166 self.__lower_bounds: Final[tuple[int | float, ...]] = \ 

167 tuple(lower_bounds) 

168 #: the holder for upper bounds 

169 self.__upper_bounds: Final[tuple[int | float, ...]] = \ 

170 tuple(upper_bounds) 

171 

172 # set up the scalarizer 

173 self._scalarize: Final[Callable[[np.ndarray], int | float]] \ 

174 = get_scalarizer(always_int, n, lower_bounds, upper_bounds) 

175 if not callable(self._scalarize): 

176 raise type_error(self._scalarize, "result of get_scalarizer", 

177 call=True) 

178 

179 # compute the scalarized bounds 

180 temp: np.ndarray | None = None 

181 lb = -inf 

182 if isfinite(min_lower_bound): 

183 temp = np.array(lower_bounds, dtype=self.__dtype) 

184 lb = self._scalarize(temp) 

185 if not isinstance(lb, int | float): 

186 raise type_error(lb, "computed lower bound", (int, float)) 

187 if (not isfinite(lb)) and (lb > -inf): 

188 raise ValueError("non-finite computed lower bound " 

189 f"can only be -inf, but is {lb}.") 

190 lb = try_int(lb) 

191 #: the lower bound of this scalarization 

192 self.__lower_bound: Final[int | float] = lb 

193 

194 ub = inf 

195 if isfinite(max_upper_bound): 

196 temp = np.array(upper_bounds, dtype=self.__dtype) 

197 ub = self._scalarize(temp) 

198 if not isinstance(ub, int | float): 

199 raise type_error(ub, "computed upper bound", (int, float)) 

200 if (not isfinite(ub)) and (ub < inf): 

201 raise ValueError("non-finite computed upper bound " 

202 f"can only be inf, but is {ub}.") 

203 ub = try_int(ub) 

204 #: the upper bound of this scalarization 

205 self.__upper_bound: Final[int | float] = ub 

206 

207 #: the internal objectives 

208 self.__calls: Final[tuple[ 

209 Callable[[Any], int | float], ...]] = tuple(calls) 

210 #: the objective functions 

211 self._objectives = tuple(objectives) 

212 

213 #: the internal temporary array 

214 self._temp: Final[np.ndarray] = self.f_create() \ 

215 if temp is None else temp 

216 

217 if domination is not None: 

218 if not callable(domination): 

219 raise type_error(domination, "domination", call=True) 

220 self.f_dominates = domination # type: ignore 

221 

222 def initialize(self) -> None: 

223 """Initialize the multi-objective problem.""" 

224 super().initialize() 

225 for ff in self._objectives: 

226 ff.initialize() 

227 

228 def f_dimension(self) -> int: 

229 """ 

230 Obtain the number of objective functions. 

231 

232 :returns: the number of objective functions 

233 """ 

234 return self.__dimension 

235 

236 def f_dtype(self) -> np.dtype: 

237 """ 

238 Get the data type used in `f_create`. 

239 

240 :returns: the data type used by 

241 :meth:`moptipy.api.mo_problem.MOProblem.f_create`. 

242 """ 

243 return self.__dtype 

244 

245 def f_evaluate(self, x, fs: np.ndarray) -> int | float: 

246 """ 

247 Perform the multi-objective evaluation of a solution. 

248 

249 :param x: the solution to be evaluated 

250 :param fs: the array to receive the objective values 

251 :returns: the scalarized objective values 

252 """ 

253 for i, o in enumerate(self.__calls): 

254 fs[i] = o(x) 

255 return self._scalarize(fs) 

256 

257 def lower_bound(self) -> float | int: 

258 """ 

259 Get the lower bound of the scalarization result. 

260 

261 This function returns a theoretical limit for how good a solution 

262 could be at best. If no real limit is known, the function returns 

263 `-inf`. 

264 

265 :return: the lower bound of the scalarization result 

266 """ 

267 return self.__lower_bound 

268 

269 def upper_bound(self) -> float | int: 

270 """ 

271 Get the upper bound of the scalarization result. 

272 

273 This function returns a theoretical limit for how bad a solution could 

274 be at worst. If no real limit is known, the function returns `inf`. 

275 

276 :return: the upper bound of the scalarization result 

277 """ 

278 return self.__upper_bound 

279 

280 def evaluate(self, x) -> float | int: 

281 """ 

282 Convert the multi-objective problem into a single-objective one. 

283 

284 This function first evaluates all encapsulated objectives and then 

285 scalarizes the result. 

286 

287 :param x: the candidate solution 

288 :returns: the scalarized objective value 

289 """ 

290 return self.f_evaluate(x, self._temp) 

291 

292 def __str__(self) -> str: 

293 """Get the string representation of this basic scalarization.""" 

294 return "basicMoProblem" 

295 

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

297 """ 

298 Log the parameters of this function to the provided destination. 

299 

300 :param logger: the logger for the parameters 

301 """ 

302 super().log_parameters_to(logger) 

303 logger.key_value(KEY_SPACE_NUM_VARS, self.__dimension) 

304 logger.key_value(KEY_NUMPY_TYPE, numpy_type_to_str(self.__dtype)) 

305 for i, o in enumerate(self._objectives): 

306 with logger.scope(f"{SCOPE_OBJECTIVE_FUNCTION}{i}") as scope: 

307 o.log_parameters_to(scope) 

308 

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

310 """ 

311 Validate an objective vector. 

312 

313 :param x: the objective vector 

314 :raises TypeError: if the string is not an element of this space. 

315 :raises ValueError: if the shape of the vector is wrong or any of its 

316 element is not finite. 

317 """ 

318 super().f_validate(x) 

319 

320 lb: Final[tuple[int | float, ...]] = self.__lower_bounds 

321 ub: Final[tuple[int | float, ...]] = self.__upper_bounds 

322 for i, v in enumerate(x): 

323 if v < lb[i]: 

324 raise ValueError( 

325 f"encountered {v} at index {i} of {x}, which is below the " 

326 f"lower bound {lb[i]} for that position.") 

327 if v > ub[i]: 

328 raise ValueError( 

329 f"encountered {v} at index {i} of {x}, which is above the " 

330 f"upper bound {ub[i]} for that position.")