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

128 statements  

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

1""" 

2The weighted sum scalarization of multi-objective problems. 

3 

4Here we provide two simple methods to scalarize multi-objective problems by 

5using weights, namely 

6 

7- :class:`~moptipy.mo.problem.weighted_sum.WeightedSum`, a sum with arbitrary, 

8 user-defined weights of the objective values 

9- :class:`~moptipy.mo.problem.weighted_sum.Prioritize`, a weighted sum of the 

10 objective values where the weights are automatically determined such that 

11 the first objective function is prioritized over the second one, the second 

12 one over the third, and so on. 

13""" 

14from math import inf, isfinite 

15from typing import Any, Callable, Final, Iterable, cast 

16 

17import numpy as np 

18from numpy import sum as npsum 

19from pycommons.strings.string_conv import num_to_str 

20from pycommons.types import type_error 

21 

22from moptipy.api.mo_utils import dominates 

23from moptipy.api.objective import Objective 

24from moptipy.mo.problem.basic_mo_problem import BasicMOProblem 

25from moptipy.utils.logger import KeyValueLogSection 

26from moptipy.utils.math import try_int 

27from moptipy.utils.nputils import dtype_for_data 

28 

29 

30def _sum_int(a: np.ndarray) -> int: 

31 """ 

32 Sum up an array and convert the result to an `int` value. 

33 

34 :param a: the array 

35 :returns: the sum of the elements in `a` as `int` 

36 """ 

37 return int(npsum(a)) 

38 

39 

40def _sum_float(a: np.ndarray) -> float: 

41 """ 

42 Sum up an array and convert the result to a `float` value. 

43 

44 :param a: the array 

45 :returns: the sum of the elements in `a` as `float` 

46 """ 

47 return float(npsum(a)) 

48 

49 

50class BasicWeightedSum(BasicMOProblem): 

51 """ 

52 Base class for scalarizing objective values by a weighted sum. 

53 

54 This class brings the basic tools to scalarize vectors of objective 

55 values by computing weighted sums. This class should not be used 

56 directly. Instead, use its sub-classes 

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

58 :class:`~moptipy.mo.problem.weighted_sum.Prioritize`. 

59 """ 

60 

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

62 get_scalarizer: Callable[ 

63 [bool, int, list[int | float], 

64 list[int | float], Callable[ 

65 [np.dtype | tuple[int | float, ...] | None], None]], 

66 Callable[[np.ndarray], int | float]], 

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

68 = dominates) -> None: 

69 """ 

70 Create the sum-based scalarization. 

71 

72 :param objectives: the objectives 

73 :param domination: a function reflecting the domination relationship 

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

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

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

77 defaults. `None` overrides nothing. 

78 """ 

79 holder: list[Any] = [] 

80 super().__init__( 

81 objectives, 

82 cast("Callable[[bool, int, list[int | float], list[int | float]]," 

83 "Callable[[np.ndarray], int | float]]", 

84 lambda ai, n, lb, ub, fwd=holder.append: 

85 get_scalarizer(ai, n, lb, ub, fwd)), 

86 domination) 

87 if len(holder) != 2: 

88 raise ValueError( 

89 f"need weights and weights dtype, but got {holder}.") 

90 #: the internal weights 

91 self.weights: Final[tuple[int | float, ...] | None] = \ 

92 cast("tuple[int | float, ...] | None", holder[0]) 

93 if self.weights is not None: 

94 if not isinstance(self.weights, tuple): 

95 raise type_error(self.weights, "weights", [tuple, None]) 

96 if len(self.weights) != self.f_dimension(): 

97 raise ValueError( 

98 f"length of weights {self.weights} is not " 

99 f"f_dimension={self.f_dimension()}.") 

100 #: the internal weights dtype 

101 self.__weights_dtype: Final[np.dtype | None] = \ 

102 cast("np.dtype | None", holder[1]) 

103 if (self.__weights_dtype is not None) \ 

104 and (not isinstance(self.__weights_dtype, np.dtype)): 

105 raise type_error( 

106 self.__weights_dtype, "weights_dtype", np.dtype) 

107 

108 def __str__(self): 

109 """ 

110 Get the string representation of the weighted sum scalarization. 

111 

112 :returns: `"weightedSumBase"` 

113 """ 

114 return "weightedSumBase" 

115 

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

117 """ 

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

119 

120 :param logger: the logger for the parameters 

121 """ 

122 super().log_parameters_to(logger) 

123 

124 weights: tuple[int | float, ...] | None = self.weights 

125 logger.key_value("weights", ";".join( 

126 (["1"] * self.f_dimension()) if weights is None else 

127 [num_to_str(w) for w in weights])) 

128 logger.key_value("weightsDtype", 

129 "None" if self.__weights_dtype is None 

130 else self.__weights_dtype.char) 

131 

132 

133def _make_sum_scalarizer( 

134 always_int: bool, n: int, 

135 lower_bounds: list[int | float], upper_bounds: list[int | float], 

136 weights: tuple[int | float, ...] | None, 

137 callback: Callable[ 

138 [np.dtype | tuple[int | float, ...] | None], 

139 None]) -> Callable[[np.ndarray], int | float]: 

140 """ 

141 Create a weighted sum scalarization function. 

142 

143 If `weights` is `None`, we will just use the plain summation function from 

144 numpy and convert its result to `int` if `always_int` is `True` and to 

145 `float` otherwise. 

146 If `weights` is a tuple of weights, then we will convert it to a numpy 

147 `ndarray`. If `always_int` is `True` and all weights are integers, and the 

148 lower and upper bound of the objectives are known, we try to pick the 

149 smallest integer data type for this array big enough to hold both the 

150 total lower and total upper bound. If at least the lower bounds are >= 0, 

151 then we pick the largest unsigned integer data type. If the bounds are 

152 unknown, then we pick the largest signed integer type. If any weight is 

153 not an integer and `always_int` is `False`, we use a default floating 

154 point weight array. 

155 

156 This should yield the overall fastest, most precise, and most memory 

157 efficient way to compute a weighted sum scalarization. 

158 

159 :param always_int: will all objectives always be integer 

160 :param n: the number of objectives 

161 :param lower_bounds: the optional lower bounds 

162 :param upper_bounds: the optional upper bounds 

163 :param weights: the optional array of weights, `None` if all weights 

164 are `1`. 

165 :param callback: the callback function to receive the weights 

166 :returns: the scalarization function 

167 """ 

168 if not isinstance(lower_bounds, list): 

169 raise type_error(lower_bounds, "lower_bounds", list) 

170 if len(lower_bounds) != n: 

171 raise ValueError( 

172 f"there should be {n} values in lower_bounds={lower_bounds}") 

173 

174 if not isinstance(upper_bounds, list): 

175 raise type_error(upper_bounds, "upper_bounds", list) 

176 if len(upper_bounds) != n: 

177 raise ValueError( 

178 f"there should be {n} values in upper_bounds={lower_bounds}") 

179 

180 if weights is None: 

181 callback(None) 

182 callback(None) 

183 return _sum_int if always_int else _sum_float 

184 if not isinstance(weights, tuple): 

185 raise type_error(weights, "weights", tuple) 

186 if len(weights) != n: 

187 raise ValueError( 

188 f"there should be {n} values in weights={lower_bounds}") 

189 if not isinstance(always_int, bool): 

190 raise type_error(always_int, "always_int", bool) 

191 

192 min_sum: int | float = 0 

193 max_sum: int | float = 0 

194 min_weight: int | float = inf 

195 max_weight: int | float = -inf 

196 everything_is_int: bool = always_int 

197 

198 for i, weight in enumerate(weights): 

199 if weight <= 0: 

200 raise ValueError("no weight can be <=0, but encountered " 

201 f"{weight} in {weights}.") 

202 

203 if not isinstance(weight, int): 

204 everything_is_int = False 

205 if not isfinite(weight): 

206 raise ValueError("weight must be finite, but " 

207 f"encountered {weight} in {weights}.") 

208 min_sum = -inf 

209 max_sum = inf 

210 break 

211 

212 min_weight = min(min_weight, weight) 

213 max_weight = max(max_weight, weight) 

214 

215 if lower_bounds is not None: 

216 min_sum += weight * lower_bounds[i] 

217 if upper_bounds is not None: 

218 max_sum += weight * upper_bounds[i] 

219 

220 if min_sum >= max_sum: 

221 raise ValueError( 

222 f"weighted sum minimum={min_sum} >= maximum={max_sum}?") 

223 

224 # re-check for plain summation 

225 if 1 <= min_weight <= max_weight <= 1: 

226 callback(None) 

227 callback(None) 

228 return _sum_int if always_int else _sum_float 

229 

230 dtype: Final[np.dtype] = dtype_for_data( 

231 everything_is_int, min_sum, max_sum) 

232 use_weights: Final[np.ndarray] = np.array(weights, dtype) 

233 

234 callback(weights) 

235 callback(dtype) 

236 

237 if everything_is_int: 

238 return cast("Callable[[np.ndarray], int | float]", 

239 lambda a, w=use_weights: int(npsum(a * w))) 

240 return cast("Callable[[np.ndarray], int | float]", 

241 lambda a, w=use_weights: float(npsum(a * w))) 

242 

243 

244class WeightedSum(BasicWeightedSum): 

245 """Scalarize objective values by computing their weighted sum.""" 

246 

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

248 weights: Iterable[int | float] | None = None, 

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

250 = dominates) -> None: 

251 """ 

252 Create the sum-based scalarization. 

253 

254 :param objectives: the objectives 

255 :param weights: the weights of the objective values, or `None` if all 

256 weights are `1`. 

257 :param domination: a function reflecting the domination relationship 

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

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

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

261 defaults. `None` overrides nothing. 

262 """ 

263 use_weights: tuple[int | float, ...] | None \ 

264 = None if weights is None else tuple(try_int(w) for w in weights) 

265 

266 super().__init__( 

267 objectives, 

268 cast("Callable[[bool, int, list[int | float], list[int | float], " 

269 "Callable[[np.dtype | tuple[int | float, ...] | None], " 

270 "None]], Callable[[np.ndarray], int | float]]", 

271 lambda ai, n, lb, ub, cb, uw=use_weights: 

272 _make_sum_scalarizer(ai, n, lb, ub, uw, cb)), 

273 domination) 

274 

275 def __str__(self): 

276 """ 

277 Get the string representation of the weighted sum scalarization. 

278 

279 :returns: `"weightedSum"` 

280 """ 

281 return "weightedSum" if self.f_dominates is dominates \ 

282 else "weightedSumWithDominationFunc" 

283 

284 

285def _prioritize( 

286 always_int: bool, n: int, 

287 lower_bounds: list[int | float], upper_bounds: list[int | float], 

288 callback: Callable[[np.dtype | tuple[ 

289 int | float, ...] | None], None]) \ 

290 -> Callable[[np.ndarray], int | float]: 

291 """ 

292 Create a weighted-sum based prioritization of the objective functions. 

293 

294 If all objective functions are integers and have upper and lower bounds, 

295 we can use integer weights to create a prioritization such that gaining 

296 one unit of the first objective function is always more important than any 

297 improvement of the second objective, that gaining one unit of the second 

298 objective always outweighs all possible gains in terms of the third one, 

299 and so on. 

300 

301 :param always_int: will all objectives always be integer 

302 :param n: the number of objectives 

303 :param lower_bounds: the optional lower bounds 

304 :param upper_bounds: the optional upper bounds 

305 :param callback: the callback function to receive the weights 

306 :returns: the scalarization function 

307 """ 

308 if n == 1: 

309 return _make_sum_scalarizer(always_int, n, lower_bounds, 

310 upper_bounds, None, callback) 

311 if not always_int: 

312 raise ValueError("priority-based weighting is only possible for " 

313 "integer-valued objectives") 

314 weights: list[int] = [1] 

315 weight: int = 1 

316 for i in range(n - 1, 0, -1): 

317 lb: int | float = lower_bounds[i] 

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

319 raise type_error(lb, f"lower_bound[{i}]", (int, float)) 

320 if not isfinite(lb): 

321 raise ValueError(f"lower_bound[{i}]={lb}, but must be finite") 

322 if not isinstance(lb, int): 

323 raise type_error(lb, f"finite lower_bound[{i}]", int) 

324 ub: int | float = upper_bounds[i] 

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

326 raise type_error(ub, f"upper_bound[{i}]", (int, float)) 

327 if not isfinite(ub): 

328 raise ValueError(f"upper_bound[{i}]={ub}, but must be finite") 

329 if not isinstance(ub, int): 

330 raise type_error(ub, f"finite upper_bound[{i}]", int) 

331 weight *= (1 + cast("int", ub) - min(0, cast("int", lb))) 

332 weights.append(weight) 

333 

334 weights.reverse() 

335 return _make_sum_scalarizer(always_int, n, lower_bounds, upper_bounds, 

336 tuple(weights), callback) 

337 

338 

339class Prioritize(BasicWeightedSum): 

340 """Prioritize the first objective over the second and so on.""" 

341 

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

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

344 = dominates) -> None: 

345 """ 

346 Create the sum-based prioritization. 

347 

348 :param objectives: the objectives 

349 :param domination: a function reflecting the domination relationship 

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

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

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

353 defaults. `None` overrides nothing. 

354 """ 

355 super().__init__(objectives, _prioritize, domination) 

356 

357 def __str__(self): 

358 """ 

359 Get the name of the weighted sum-based prioritization. 

360 

361 :returns: `"weightBasedPrioritization"` 

362 """ 

363 return "weightBasedPrioritization" if self.f_dominates is dominates \ 

364 else "weightBasedPrioritizationWithDominationFunc"