Coverage for moptipy / algorithms / modules / temperature_schedule.py: 84%

122 statements  

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

1""" 

2A temperature schedule as needed by Simulated Annealing. 

3 

4The Simulated Annealing algorithm implemented in 

5:mod:`~moptipy.algorithms.so.simulated_annealing` performs a local search that 

6always accepts a non-worsening move, i.e., a solution which is not worse than 

7the currently maintained one. However, it will also *sometimes* accept one 

8that is worse. The probability of doing so depends on how much worse that 

9solution is and on the current *temperature* of the algorithm. The higher the 

10temperature, the higher the acceptance probability. The temperature changes 

11over time according to the 

12:class:`~moptipy.algorithms.modules.temperature_schedule.TemperatureSchedule`. 

13 

14The temperature schedule receives an iteration index `tau` as input and 

15returns the current temperature via :meth:`~moptipy.algorithms.modules.\ 

16temperature_schedule.TemperatureSchedule.temperature`. Notice that `tau` is 

17zero-based for simplicity reason, meanings that the first objective function 

18evaluation is at index `0`. 

19""" 

20 

21from math import e, isfinite, log 

22from typing import Final 

23 

24from pycommons.math.int_math import try_int 

25from pycommons.strings.enforce import enforce_non_empty_str_without_ws 

26from pycommons.types import check_int_range, type_error 

27 

28from moptipy.api.component import Component 

29from moptipy.api.objective import Objective, check_objective 

30from moptipy.utils.logger import KeyValueLogSection 

31from moptipy.utils.strings import num_to_str_for_name 

32 

33 

34# start schedule 

35class TemperatureSchedule(Component): 

36 """The base class for temperature schedules.""" 

37 

38 def __init__(self, t0: float) -> None: 

39 # end schedule 

40 """ 

41 Initialize the temperature schedule. 

42 

43 :param t0: the starting temperature, must be > 0 

44 """ 

45 super().__init__() 

46 if not isinstance(t0, float): 

47 raise type_error(t0, "t0", float) 

48 if (not isfinite(t0)) or (t0 <= 0.0): 

49 raise ValueError(f"t0 must be >0, cannot be {t0}.") 

50# start schedule 

51 #: the starting temperature 

52 self.t0: Final[float] = t0 

53 

54 def temperature(self, tau: int) -> float: 

55 """ 

56 Compute the temperature at iteration `tau`. 

57 

58 :param tau: the iteration index, starting with `0` at the first 

59 comparison of two solutions, at which point the starting 

60 temperature :attr:`~TemperatureSchedule.t0` should be returned 

61 :returns: the temperature 

62 """ 

63# end schedule 

64 

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

66 """ 

67 Log all parameters of this temperature schedule as key-value pairs. 

68 

69 :param logger: the logger for the parameters 

70 

71 >>> from moptipy.utils.logger import InMemoryLogger 

72 >>> with InMemoryLogger() as l: 

73 ... with l.key_values("C") as kv: 

74 ... TemperatureSchedule(0.1).log_parameters_to(kv) 

75 ... text = l.get_log() 

76 >>> text[1] 

77 'name: TemperatureSchedule' 

78 >>> text[3] 

79 'T0: 0.1' 

80 >>> len(text) 

81 6 

82 """ 

83 super().log_parameters_to(logger) 

84 logger.key_value("T0", self.t0) 

85 

86 

87# start exponential 

88class ExponentialSchedule(TemperatureSchedule): 

89 """ 

90 The exponential temperature schedule. 

91 

92 The current temperature is computed as `t0 * (1 - epsilon) ** tau`. 

93 

94 >>> ex = ExponentialSchedule(10.0, 0.05) 

95 >>> print(f"{ex.t0} - {ex.epsilon}") 

96 10.0 - 0.05 

97 >>> ex.temperature(0) 

98 10.0 

99 >>> ex.temperature(1) 

100 9.5 

101 >>> ex.temperature(2) 

102 9.025 

103 >>> ex.temperature(1_000_000_000_000_000_000) 

104 0.0 

105 """ 

106 

107 def __init__(self, t0: float, epsilon: float) -> None: 

108 """ 

109 Initialize the exponential temperature schedule. 

110 

111 :param t0: the starting temperature, must be > 0 

112 :param epsilon: the epsilon parameter of the schedule, in (0, 1) 

113 """ 

114 super().__init__(t0) 

115# end exponential 

116 if not isinstance(epsilon, float): 

117 raise type_error(epsilon, "epsilon", float) 

118 if (not isfinite(epsilon)) or (not (0.0 < epsilon < 1.0)): 

119 raise ValueError( 

120 f"epsilon cannot be {epsilon}, must be in (0,1).") 

121# start exponential 

122 #: the epsilon parameter of the exponential schedule 

123 self.epsilon: Final[float] = epsilon 

124 #: the value used as basis for the exponent 

125 self.__one_minus_epsilon: Final[float] = 1.0 - epsilon 

126# end exponential 

127 if not (0.0 < self.__one_minus_epsilon < 1.0): 

128 raise ValueError( 

129 f"epsilon cannot be {epsilon}, because 1-epsilon must be in " 

130 f"(0, 1) but is {self.__one_minus_epsilon}.") 

131# start exponential 

132 

133 def temperature(self, tau: int) -> float: 

134 """ 

135 Compute the temperature at iteration `tau`. 

136 

137 :param tau: the iteration index, starting with `0` at the first 

138 comparison of two solutions, at which point the starting 

139 temperature :attr:`~TemperatureSchedule.t0` should be returned 

140 :returns: the temperature 

141 

142 >>> s = ExponentialSchedule(100.0, 0.5) 

143 >>> s.temperature(0) 

144 100.0 

145 >>> s.temperature(1) 

146 50.0 

147 >>> s.temperature(10) 

148 0.09765625 

149 """ 

150 return self.t0 * (self.__one_minus_epsilon ** tau) 

151# end exponential 

152 

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

154 """ 

155 Log all parameters of the exponential temperature schedule. 

156 

157 :param logger: the logger for the parameters 

158 

159 >>> from moptipy.utils.logger import InMemoryLogger 

160 >>> with InMemoryLogger() as l: 

161 ... with l.key_values("C") as kv: 

162 ... ExponentialSchedule(0.2, 0.6).log_parameters_to(kv) 

163 ... text = l.get_log() 

164 >>> text[1] 

165 'name: exp0d2_0d6' 

166 >>> text[3] 

167 'T0: 0.2' 

168 >>> text[5] 

169 'e: 0.6' 

170 >>> len(text) 

171 8 

172 """ 

173 super().log_parameters_to(logger) 

174 logger.key_value("e", self.epsilon) 

175 

176 def __str__(self) -> str: 

177 """ 

178 Get the string representation of the exponential temperature schedule. 

179 

180 :returns: the name of this schedule 

181 

182 >>> ExponentialSchedule(100.5, 0.3) 

183 exp100d5_0d3 

184 """ 

185 return (f"exp{num_to_str_for_name(self.t0)}_" 

186 f"{num_to_str_for_name(self.epsilon)}") 

187 

188 

189# start logarithmic 

190class LogarithmicSchedule(TemperatureSchedule): 

191 """ 

192 The logarithmic temperature schedule. 

193 

194 The temperature is computed as `t0 / log(e + (tau * epsilon))`. 

195 

196 >>> lg = LogarithmicSchedule(10.0, 0.1) 

197 >>> print(f"{lg.t0} - {lg.epsilon}") 

198 10.0 - 0.1 

199 >>> lg.temperature(0) 

200 10.0 

201 >>> lg.temperature(1) 

202 9.651322627630812 

203 >>> lg.temperature(1_000_000_000_000_000_000_000_000_000_000_000_000_000) 

204 0.11428802155348732 

205 """ 

206 

207 def __init__(self, t0: float, epsilon: float) -> None: 

208 """ 

209 Initialize the logarithmic temperature schedule. 

210 

211 :param t0: the starting temperature, must be > 0 

212 :param epsilon: the epsilon parameter of the schedule, is > 0 

213 """ 

214 super().__init__(t0) 

215# end logarithmic 

216 if not isinstance(epsilon, float): 

217 raise type_error(epsilon, "epsilon", float) 

218 if (not isfinite(epsilon)) or (epsilon <= 0.0): 

219 raise ValueError( 

220 f"epsilon cannot be {epsilon}, must be > 0.") 

221# start logarithmic 

222 #: the epsilon parameter of the logarithmic schedule 

223 self.epsilon: Final[float] = epsilon 

224 

225 def temperature(self, tau: int) -> float: 

226 """ 

227 Compute the temperature at iteration `tau`. 

228 

229 :param tau: the iteration index, starting with `0` at the first 

230 comparison of two solutions, at which point the starting 

231 temperature :attr:`~TemperatureSchedule.t0` should be returned 

232 :returns: the temperature 

233 

234 >>> s = LogarithmicSchedule(100.0, 0.5) 

235 >>> s.temperature(0) 

236 100.0 

237 >>> s.temperature(1) 

238 85.55435113150568 

239 >>> s.temperature(10) 

240 48.93345190925178 

241 """ 

242 return self.t0 / log(e + (tau * self.epsilon)) 

243# end logarithmic 

244 

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

246 """ 

247 Log all parameters of the logarithmic temperature schedule. 

248 

249 :param logger: the logger for the parameters 

250 

251 >>> from moptipy.utils.logger import InMemoryLogger 

252 >>> with InMemoryLogger() as l: 

253 ... with l.key_values("C") as kv: 

254 ... LogarithmicSchedule(0.2, 0.6).log_parameters_to(kv) 

255 ... text = l.get_log() 

256 >>> text[1] 

257 'name: ln0d2_0d6' 

258 >>> text[3] 

259 'T0: 0.2' 

260 >>> text[5] 

261 'e: 0.6' 

262 >>> len(text) 

263 8 

264 """ 

265 super().log_parameters_to(logger) 

266 logger.key_value("e", self.epsilon) 

267 

268 def __str__(self) -> str: 

269 """ 

270 Get the string representation of the logarithmic temperature schedule. 

271 

272 :returns: the name of this schedule 

273 

274 >>> LogarithmicSchedule(100.5, 0.3) 

275 ln100d5_0d3 

276 """ 

277 return (f"ln{num_to_str_for_name(self.t0)}_" 

278 f"{num_to_str_for_name(self.epsilon)}") 

279 

280 

281class ExponentialScheduleBasedOnBounds(ExponentialSchedule): 

282 """ 

283 An exponential schedule configured based on the objective's range. 

284 

285 This exponential schedule takes an objective function as parameter. 

286 It uses the lower and the upper bound of this function, `LB` and `UB`, 

287 to select a start and end temperature based on the provided fractions. 

288 Here, we set `W = lb_sum_weight * LB + ub_sum_weight * UB`. 

289 If we set `lb_sum_weight = -1` and `ub_sum_weight = 1`, then `W` will be 

290 the range of the objective function. 

291 If we set `lb_sum_weight = 1` and `ub_sum_weight = 0`, then we base the 

292 temperature setup entirely on the lower bound. 

293 If we set `lb_sum_weight = 0` and `ub_sum_weight = 1`, then we base the 

294 temperature setup entirely on the upper bound. 

295 Roughly, the start temperature will be `W * start_range_frac` and 

296 the end temperature, to be reached after `n_steps` FEs, will be 

297 `W * end_range_frac`. 

298 Since sometimes the upper and lower bound may be excessivly large, we 

299 can provide limits for `W` in form of `min_bound_sum` and `max_bound_sum`. 

300 This will then override any other computation. 

301 Notice that it is expected that `tau == 0` when the temperature function 

302 is first called. It is expected that `tau == n_range - 1` when it is 

303 called for the last time. 

304 

305 >>> from moptipy.examples.bitstrings.onemax import OneMax 

306 >>> es = ExponentialScheduleBasedOnBounds( 

307 ... OneMax(10), -1, 1, 0.01, 0.0001, 10**8) 

308 >>> es.temperature(0) 

309 0.1 

310 >>> es.temperature(1) 

311 0.09999999539482989 

312 >>> es.temperature(10**8 - 1) 

313 0.0010000000029841878 

314 >>> es.temperature(10**8) 

315 0.0009999999569324865 

316 

317 >>> es = ExponentialScheduleBasedOnBounds( 

318 ... OneMax(10), -1, 1, 0.01, 0.0001, 10**8, max_bound_sum=5) 

319 >>> es.temperature(0) 

320 0.05 

321 >>> es.temperature(1) 

322 0.04999999769741494 

323 >>> es.temperature(10**8 - 1) 

324 0.0005000000014920939 

325 >>> es.temperature(10**8) 

326 0.0004999999784662432 

327 

328 >>> try: 

329 ... ExponentialScheduleBasedOnBounds(1, 0.01, 0.0001, 10**8) 

330 ... except TypeError as te: 

331 ... print(te) 

332 objective function should be an instance of moptipy.api.objective.\ 

333Objective but is int, namely 1. 

334 

335 >>> try: 

336 ... ExponentialScheduleBasedOnBounds( 

337 ... OneMax(10), -1, 1, -1.0, 0.0001, 10**8) 

338 ... except ValueError as ve: 

339 ... print(ve) 

340 Invalid bound sum factors [-1.0, 0.0001]. 

341 

342 >>> try: 

343 ... ExponentialScheduleBasedOnBounds( 

344 ... OneMax(10), -1, 1, 0.9, 0.0001, 1) 

345 ... except ValueError as ve: 

346 ... print(ve) 

347 n_steps=1 is invalid, must be in 2..1000000000000000. 

348 """ 

349 

350 def __init__(self, f: Objective, 

351 lb_sum_weight: int | float = -1, 

352 ub_sum_weight: int | float = 1, 

353 start_factor: float = 1e-3, 

354 end_factor: float = 1e-7, 

355 n_steps: int = 1_000_000, 

356 min_bound_sum: int | float = 1e-20, 

357 max_bound_sum: int | float = 1e20) -> None: 

358 """ 

359 Initialize the range-based exponential schedule. 

360 

361 :param f: the objective function whose range we will use 

362 :param lb_sum_weight: the weight of the lower bound in the bound sum 

363 :param ub_sum_weight: the weight of the upper bound in the bound sum 

364 :parma start_factor: the factor multiplied with the bound sum to get 

365 the starting temperature 

366 :parm end_factor: the factor multiplied with the bound sum to get 

367 the end temperature 

368 :param n_steps: the number of steps until the end range should be 

369 reached 

370 :param min_bound_sum: a lower limit for the weighted sum of the bounds 

371 :param max_bound_sum: an upper limit for the weighted sum of the bounds 

372 """ 

373 f = check_objective(f) 

374 if not isinstance(lb_sum_weight, int | float): 

375 raise type_error(lb_sum_weight, "lb_sum_weight", (int, float)) 

376 if not isinstance(ub_sum_weight, int | float): 

377 raise type_error(ub_sum_weight, "ub_sum_weight", (int, float)) 

378 if not isinstance(start_factor, float): 

379 raise type_error(start_factor, "start_factor", float) 

380 if not isinstance(end_factor, float): 

381 raise type_error(end_factor, "end_factor", float) 

382 if not isinstance(min_bound_sum, int | float): 

383 raise type_error(min_bound_sum, "min_bound_sum", (int, float)) 

384 if not isinstance(max_bound_sum, int | float): 

385 raise type_error(max_bound_sum, "max_bound_sum", (int, float)) 

386 

387 if not (isfinite(min_bound_sum) and isfinite(max_bound_sum) and ( 

388 0 < min_bound_sum < max_bound_sum)): 

389 raise ValueError(f"Invalid bound sum limits [{min_bound_sum}" 

390 f", {max_bound_sum}].") 

391 if not (isfinite(start_factor) and isfinite(end_factor) and ( 

392 0.0 < end_factor < start_factor < 1e50)): 

393 raise ValueError(f"Invalid bound sum factors [{start_factor}" 

394 f", {end_factor}].") 

395 if not (isfinite(lb_sum_weight) and isfinite(ub_sum_weight)): 

396 raise ValueError(f"Invalid bound sum weights [{lb_sum_weight}" 

397 f", {ub_sum_weight}].") 

398 #: the number of steps that we will perform until reaching the end 

399 #: range fraction temperature 

400 self.__n_steps: Final[int] = check_int_range( 

401 n_steps, "n_steps", 2, 1_000_000_000_000_000) 

402 

403 lb_sum_weight = try_int(lb_sum_weight) 

404 #: the sum weight for the lower bound 

405 self.__lb_sum_weight: Final[int | float] = lb_sum_weight 

406 ub_sum_weight = try_int(ub_sum_weight) 

407 #: the sum weight for the upper bound 

408 self.__ub_sum_weight: Final[int | float] = ub_sum_weight 

409 #: the start temperature bound sum factor 

410 self.__start_factor: Final[float] = start_factor 

411 #: the end temperature bound sum factor 

412 self.__end_factor: Final[float] = end_factor 

413 min_bound_sum = try_int(min_bound_sum) 

414 #: the minimum value for the bound sum 

415 self.__min_bound_sum: Final[int | float] = min_bound_sum 

416 max_bound_sum = try_int(max_bound_sum) 

417 #: the maximum value for the bound sum 

418 self.__max_bound_sum: Final[int | float] = max_bound_sum 

419 

420 #: the name of the objective function used 

421 self.__used_objective: Final[str] = enforce_non_empty_str_without_ws( 

422 str(f)) 

423 flb: Final[float | int] = f.lower_bound() 

424 fub: Final[float | int] = f.upper_bound() 

425 if flb > fub: 

426 raise ValueError( 

427 f"Objective function lower bound {flb} > upper bound {fub}?") 

428 

429 #: the lower bound of the objective value 

430 self.__f_lower_bound: Final[int | float] = flb 

431 #: the upper bound for the objective value 

432 self.__f_upper_bound: Final[int | float] = fub 

433 

434 bound_sum: Final[float | int] = try_int(max(min_bound_sum, min( 

435 max_bound_sum, 

436 (flb * lb_sum_weight if lb_sum_weight != 0 else 0) + ( 

437 fub * ub_sum_weight if ub_sum_weight != 0 else 0)))) 

438 if not (isfinite(bound_sum) and ( 

439 min_bound_sum <= bound_sum <= max_bound_sum)): 

440 raise ValueError( 

441 f"Invalid bound sum {bound_sum} resulting from bounds [{flb}" 

442 f", {fub}] and weights {lb_sum_weight}, {ub_sum_weight}.") 

443 #: the bound sum 

444 self.__f_bound_sum: Final[int | float] = bound_sum 

445 

446 t0: Final[float] = start_factor * bound_sum 

447 te: Final[float] = end_factor * bound_sum 

448 if not (isfinite(t0) and isfinite(te) and (0 < te < t0 < 1e100)): 

449 raise ValueError( 

450 f"Invalid setup {start_factor}, {end_factor}, " 

451 f"{bound_sum} leading to temperatures {t0}, {te}.") 

452 #: the end temperature 

453 self.__te: Final[float] = te 

454 

455 epsilon: Final[float] = 1 - (te / t0) ** (1 / (n_steps - 1)) 

456 if not (isfinite(epsilon) and (0 < epsilon < 1) and ( 

457 0 < (1 - epsilon) < 1)): 

458 raise ValueError( 

459 f"Invalid computed epsilon {epsilon} resulting from setup " 

460 f"{start_factor}, {end_factor}, {bound_sum} leading " 

461 f"to temperatures {t0}, {te}.") 

462 super().__init__(t0, epsilon) 

463 

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

465 """ 

466 Log all parameters of the configured exponential temperature schedule. 

467 

468 :param logger: the logger for the parameters 

469 

470 >>> from moptipy.utils.logger import InMemoryLogger 

471 >>> from moptipy.examples.bitstrings.onemax import OneMax 

472 >>> with InMemoryLogger() as l: 

473 ... with l.key_values("C") as kv: 

474 ... ExponentialScheduleBasedOnBounds( 

475 ... OneMax(10), -1, 1, 0.01, 0.0001).log_parameters_to(kv) 

476 ... text = l.get_log() 

477 >>> text[1] 

478 'name: expRm1_1_0d01_0d0001_1em20_1e20' 

479 >>> text[3] 

480 'T0: 0.1' 

481 >>> text[5] 

482 'e: 4.6051641873212645e-6' 

483 >>> text[7] 

484 'nSteps: 1000000' 

485 >>> text[8] 

486 'lbSumWeight: -1' 

487 >>> text[9] 

488 'ubSumWeight: 1' 

489 >>> len(text) 

490 25 

491 """ 

492 super().log_parameters_to(logger) 

493 logger.key_value("nSteps", self.__n_steps) 

494 logger.key_value("lbSumWeight", self.__lb_sum_weight) 

495 logger.key_value("ubSumWeight", self.__ub_sum_weight) 

496 logger.key_value("startFactor", self.__start_factor) 

497 logger.key_value("endFactor", self.__end_factor) 

498 logger.key_value("minBoundSum", self.__min_bound_sum) 

499 logger.key_value("maxBoundSum", self.__max_bound_sum) 

500 logger.key_value("f", self.__used_objective) 

501 logger.key_value("fLB", self.__f_lower_bound) 

502 logger.key_value("fUB", self.__f_upper_bound) 

503 logger.key_value("boundSum", self.__f_bound_sum) 

504 logger.key_value("Tend", self.__te) 

505 

506 def __str__(self) -> str: 

507 """ 

508 Get the string representation of the configured exponential schedule. 

509 

510 :returns: the name of this schedule 

511 

512 >>> from moptipy.examples.bitstrings.onemax import OneMax 

513 >>> ExponentialScheduleBasedOnBounds(OneMax(10), -1, 1, 0.01, 0.0001) 

514 expRm1_1_0d01_0d0001_1em20_1e20 

515 """ 

516 return "expR" + "_".join(map(num_to_str_for_name, ( 

517 self.__lb_sum_weight, self.__ub_sum_weight, self.__start_factor, 

518 self.__end_factor, self.__min_bound_sum, self.__max_bound_sum)))