Coverage for moptipy/evaluation/mo_end_results.py: 78%

240 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-18 11:24 +0000

1"""A set of end results from a multi-objective run.""" 

2 

3import argparse 

4from dataclasses import dataclass 

5from itertools import chain 

6from typing import Any, Callable, Final, Generator, Iterable, cast 

7 

8from pycommons.ds.sequences import reiterable 

9from pycommons.io.console import logger 

10from pycommons.io.csv import ( 

11 CSV_SEPARATOR, 

12 csv_column, 

13 csv_scope, 

14 csv_val_or_none, 

15) 

16from pycommons.io.parser import Parser 

17from pycommons.io.path import Path, file_path, write_lines 

18from pycommons.strings.string_conv import ( 

19 num_to_str, 

20 str_to_num, 

21) 

22from pycommons.types import type_error 

23 

24from moptipy.api.logging import ( 

25 PREFIX_SECTION_ARCHIVE, 

26 SECTION_ARCHIVE_QUALITY, 

27 SECTION_PROGRESS, 

28 SUFFIX_SECTION_ARCHIVE_X, 

29 SUFFIX_SECTION_ARCHIVE_Y, 

30) 

31from moptipy.evaluation.end_results import CsvReader as CsvReaderBase 

32from moptipy.evaluation.end_results import CsvWriter as CsvWriterBase 

33from moptipy.evaluation.end_results import EndResult 

34from moptipy.evaluation.end_results import EndResultLogParser as _Erlp 

35from moptipy.utils.help import moptipy_argparser 

36from moptipy.utils.logger import ( 

37 SECTION_END, 

38 SECTION_START, 

39) 

40from moptipy.utils.math import try_int 

41 

42 

43@dataclass(frozen=True, init=False, order=False, eq=False) 

44class MOEndResult(EndResult): 

45 """A multi-objective end result record.""" 

46 

47 #: The objective values for the subordinate objective functions. 

48 fs: tuple[int | float, ...] 

49 

50 def __init__(self, 

51 algorithm: str, 

52 instance: str, 

53 objective: str, 

54 encoding: str | None, 

55 rand_seed: int, 

56 best_f: int | float, 

57 last_improvement_fe: int, 

58 last_improvement_time_millis: int, 

59 total_fes: int, 

60 total_time_millis: int, 

61 goal_f: int | float | None, 

62 max_fes: int | None, 

63 max_time_millis: int | None, 

64 fs: tuple[int | float, ...], 

65 x: str | None = None, 

66 y: str | None = None) -> None: 

67 """ 

68 Create the multi-objective end result record. 

69 

70 :param algorithm: the algorithm name 

71 :param instance: the instance name 

72 :param objective: the name of the objective function 

73 :param encoding: the name of the encoding that was used, if any, or 

74 `None` if no encoding was used 

75 :param rand_seed: the random seed 

76 :param best_f: the best reached objective value 

77 :param last_improvement_fe: the FE when best_f was reached 

78 :param last_improvement_time_millis: the time when best_f was reached 

79 :param total_fes: the total FEs 

80 :param total_time_millis: the total runtime 

81 :param goal_f: the goal objective value, if provide 

82 :param max_fes: the optional maximum FEs 

83 :param max_time_millis: the optional maximum runtime 

84 :param fs: the objective value vector 

85 :param x: the optional point in the search space 

86 :param y: the optional point in the solution space 

87 

88 :raises TypeError: if any parameter has a wrong type 

89 :raises ValueError: if the parameter values are inconsistent 

90 """ 

91 super().__init__( 

92 algorithm=algorithm, 

93 instance=instance, 

94 objective=objective, 

95 encoding=encoding, 

96 rand_seed=rand_seed, 

97 best_f=best_f, 

98 last_improvement_fe=last_improvement_fe, 

99 last_improvement_time_millis=last_improvement_time_millis, 

100 total_fes=total_fes, 

101 total_time_millis=total_time_millis, 

102 goal_f=goal_f, 

103 max_fes=max_fes, 

104 max_time_millis=max_time_millis, 

105 x=x, 

106 y=y) 

107 fsc = tuple.__len__(fs) 

108 if fsc <= 0: 

109 raise ValueError("Number of objectives must be greater than 0.") 

110 fsu: list[int | float] = [] 

111 changed: bool = False 

112 for val in fs: 

113 val2 = try_int(val) 

114 if val2 is not val: 

115 changed = True 

116 fsu.append(val2) 

117 object.__setattr__(self, "fs", tuple(fsu) if changed else fs) 

118 

119 def _tuple(self) -> tuple[Any, ...]: 

120 """ 

121 Get the comparison tuple. 

122 

123 :return: the comparison tuple 

124 """ 

125 cr = list(super()._tuple()) 

126 cr.extend(self.fs) 

127 return tuple(cr) 

128 

129 

130def to_csv(results: Iterable[EndResult], file: str) -> Path: 

131 """ 

132 Write a sequence of end results to a file in CSV format. 

133 

134 :param results: the end results 

135 :param file: the path 

136 :return: the path of the file that was written 

137 """ 

138 path: Final[Path] = Path(file) 

139 logger(f"Writing end results to CSV file {path!r}.") 

140 path.ensure_parent_dir_exists() 

141 with path.open_for_write() as wt: 

142 write_lines(CsvWriter.write(results), wt) 

143 logger(f"Done writing end results to CSV file {path!r}.") 

144 return path 

145 

146 

147def from_csv(file: str, 

148 filterer: Callable[[EndResult], bool] 

149 = lambda _: True) -> Generator[ 

150 EndResult | MOEndResult, None, None]: 

151 """ 

152 Parse a given CSV file to get :class:`MOEndResult` Records. 

153 

154 :param file: the path to parse 

155 :param filterer: an optional filter function 

156 """ 

157 path: Final[Path] = file_path(file) 

158 logger(f"Now reading CSV file {path!r}.") 

159 with path.open_for_read() as rd: 

160 for r in CsvReader.read(rd): 

161 if filterer(r): 

162 yield r 

163 logger(f"Done reading CSV file {path!r}.") 

164 

165 

166def from_logs(path: str, parse_x: bool = True, parse_y: bool = True) \ 

167 -> Generator[EndResult | MOEndResult, None, None]: 

168 """ 

169 Parse a given path and yield all (multi-objective) end results found. 

170 

171 If `path` identifies a file with suffix `.txt`, then this file is 

172 parsed. The appropriate :class:`moptipy.evaluation.end_results.EndResult` 

173 or :class:`MOEndResult` is created and yielded. 

174 If `path` identifies a directory, then this directory is parsed 

175 recursively for each log file found, one record is yielded. 

176 

177 :param path: the path to parse 

178 :param parse_x: should we parse the points in the search space, too? 

179 :param parse_y: should we parse the points in the solution space, too? 

180 """ 

181 for group in __MOEndResultLogParser(parse_x, parse_y).parse(path): 

182 yield from group 

183 

184 

185class CsvWriter(CsvWriterBase): 

186 """A class for CSV writing of `EndResult` records.""" 

187 

188 def __init__(self, data: Iterable[EndResult], 

189 scope: str | None = None) -> None: 

190 """ 

191 Initialize the csv writer. 

192 

193 :param data: the data 

194 :param scope: the prefix to be pre-pended to all columns 

195 """ 

196 data = reiterable(data) 

197 super().__init__(data, scope) 

198 

199 #: do we need the encoding? 

200 self.__fcols: Final[int] = max( 

201 tuple.__len__(er.fs) if isinstance( 

202 er, MOEndResult) else 0 for er in data) 

203 

204 def get_column_titles(self) -> Iterable[str]: 

205 """ 

206 Get the column titles. 

207 

208 :returns: the column titles 

209 """ 

210 p: Final[str | None] = self.scope 

211 return chain(super().get_column_titles(), ( 

212 csv_scope(p, x) for x in ( 

213 f"f{i}" for i in range(self.__fcols)))) 

214 

215 def get_row(self, data: EndResult) -> Iterable[str]: 

216 """ 

217 Render a single end result record to a CSV row. 

218 

219 :param data: the end result record 

220 :returns: the row iterator 

221 """ 

222 yield from super().get_row(data) 

223 if isinstance(data, MOEndResult): 

224 yield from map(num_to_str, data.fs) 

225 

226 def get_header_comments(self) -> Iterable[str]: 

227 """ 

228 Get any possible header comments. 

229 

230 :returns: the header comments 

231 """ 

232 return ("Multi-Objective Experiment End Results", 

233 "See the description at the bottom of the file.") 

234 

235 def get_footer_comments(self) -> Iterable[str]: 

236 """ 

237 Get any possible footer comments. 

238 

239 :returns: the footer comments 

240 """ 

241 yield from super().get_footer_comments() 

242 for i in range(self.__fcols): 

243 yield (f"f{i}: the objective value computed with " 

244 f"the {i + 1}-th objective function.") 

245 

246 

247class CsvReader(CsvReaderBase): 

248 """A csv parser for end results.""" 

249 

250 def __init__(self, columns: dict[str, int]) -> None: 

251 """ 

252 Create a CSV parser for `EndResult` records. 

253 

254 :param columns: the columns 

255 """ 

256 super().__init__(columns) 

257 i: int = 0 

258 fcols: Final[list[int]] = [] 

259 while True: 

260 colname: str = f"f{i}" 

261 try: 

262 fcols.append(csv_column(columns, colname)) 

263 except KeyError: 

264 break 

265 i += 1 

266 #: the objective value columns 

267 self.__fcols: Final[tuple[int, ...]] = tuple(fcols) 

268 

269 def parse_row(self, data: list[str]) -> EndResult | MOEndResult: 

270 """ 

271 Parse a row of data. 

272 

273 :param data: the data row 

274 :return: the end result statistics 

275 """ 

276 res = super().parse_row(data) 

277 vals: Final[list[int | float]] = [] 

278 for col in self.__fcols: 

279 v = csv_val_or_none(data, col, str_to_num) 

280 if v is None: 

281 break 

282 vals.append(v) 

283 if list.__len__(vals) <= 0: 

284 return res 

285 return MOEndResult( 

286 algorithm=res.algorithm, 

287 instance=res.instance, 

288 objective=res.objective, 

289 encoding=res.encoding, 

290 rand_seed=res.rand_seed, 

291 best_f=res.best_f, 

292 last_improvement_fe=res.last_improvement_fe, 

293 last_improvement_time_millis=res.last_improvement_time_millis, 

294 total_fes=res.total_fes, 

295 total_time_millis=res.total_time_millis, 

296 goal_f=res.goal_f, 

297 max_fes=res.max_fes, 

298 max_time_millis=res.max_time_millis, 

299 fs=tuple(vals), 

300 x=res.x, 

301 y=res.y) 

302 

303 

304class __MOEndResultLogParser(Parser[Iterable[EndResult]]): 

305 """The internal log parser class.""" 

306 

307 def __init__(self, parse_x: bool = True, parse_y: bool = True) -> None: 

308 """ 

309 Parse the log files. 

310 

311 :param parse_x: whether to parse the x values 

312 :param parse_y: whether to parse the y values 

313 """ 

314 super().__init__() 

315 if not isinstance(parse_x, bool): 

316 raise type_error(parse_x, "parse_x", bool) 

317 if not isinstance(parse_y, bool): 

318 raise type_error(parse_y, "parse_y", bool) 

319 #: shall we parse the points in the search space? 

320 self.__parse_x: Final[bool] = parse_x 

321 #: shall we parse the points in the solution space? 

322 self.__parse_y: Final[bool] = parse_y 

323 

324 def _parse_file(self, file: Path) -> Iterable[EndResult]: 

325 """ 

326 Get the parsing result. 

327 

328 :returns: the `EndResult` instance 

329 """ 

330 self._progress_logger( 

331 f"Beginning multi-objective parsing of file {file!r}.") 

332 o: Final[EndResult] = _Erlp().parse_file(file) 

333 if not isinstance(o, EndResult): 

334 raise type_error(o, f"parse({file!r})", EndResult) 

335 

336 with file.open_for_read() as reader: 

337 lines: tuple[str, ...] = tuple(map(str.strip, str.splitlines( 

338 reader.read()))) 

339 count = tuple.__len__(lines) 

340 if count <= 2: 

341 raise ValueError( 

342 f"Inconsistent number {count} of lines in file {file!r}") 

343 

344 # first, we process the archive to find all the retained points 

345 archive: Final[list[tuple[int | float, ...]]] = [] 

346 begin: str = f"{SECTION_START}{SECTION_ARCHIVE_QUALITY}" 

347 end: str = f"{SECTION_END}{SECTION_ARCHIVE_QUALITY}" 

348 state: int = 0 

349 for line in lines: 

350 if line == begin: 

351 if state != 0: 

352 raise ValueError(f"Inconsistent begin state in " 

353 f"file {file!r} vs. {begin!r}/{end!r}.") 

354 state = 1 

355 continue 

356 if line == end: 

357 if state != 2: 

358 raise ValueError(f"Inconsistent end state in " 

359 f"file {file!r} vs. {begin!r}/{end!r}.") 

360 state = 3 

361 break 

362 if state == 1: 

363 if line.startswith("f"): 

364 state = 2 

365 continue 

366 state = 2 

367 if state != 2: 

368 continue 

369 try: 

370 archive.append(tuple(map(str_to_num, map(str.strip, str.split( 

371 line, CSV_SEPARATOR))))) 

372 except ValueError as ve: 

373 raise ValueError( 

374 f"Error when parsing line {line!r} of file {file!r} in " 

375 f"{SECTION_ARCHIVE_QUALITY}.") from ve 

376 if state != 3: 

377 return (o, ) 

378 

379 # now, we find the progress to find out when the solutions emerged 

380 progress: Final[list[tuple[int | float, ...]]] = [] 

381 begin = f"{SECTION_START}{SECTION_PROGRESS}" 

382 end = f"{SECTION_END}{SECTION_PROGRESS}" 

383 state = 0 

384 for line in lines: 

385 if line == begin: 

386 if state != 0: 

387 raise ValueError(f"Inconsistent begin state in " 

388 f"file {file!r} vs. {begin!r}/{end!r}.") 

389 state = 1 

390 continue 

391 if line == end: 

392 if state != 2: 

393 raise ValueError(f"Inconsistent end state in " 

394 f"file {file!r} vs. {begin!r}/{end!r}.") 

395 state = 3 

396 break 

397 if state == 1: 

398 if line.startswith("fes"): 

399 state = 2 

400 continue 

401 state = 2 

402 if state != 2: 

403 continue 

404 try: 

405 progress.append(tuple(map(str_to_num, map(str.strip, str.split( 

406 line, CSV_SEPARATOR))))) 

407 except ValueError as ve: 

408 raise ValueError( 

409 f"Error when parsing line {line!r} of file {file!r} " 

410 f"in {SECTION_PROGRESS}.") from ve 

411 if state not in {0, 3}: 

412 raise ValueError(f"Inconsistent state {state} in " 

413 f"file {file!r} vs. {begin!r}/{end!r}.") 

414 

415 count = list.__len__(archive) 

416 if count < 1: 

417 raise ValueError(f"No solution archived in file {file!r}.") 

418 

419 # Now we try to find the solutions and points matching them 

420 x: dict[int, str] = {} 

421 y: dict[int, str] = {} 

422 if self.__parse_x or self.__parse_y: 

423 current: list[str] = [] 

424 current_id: int = 0 

425 state = 0 

426 arc_start: Final[str] = f"{SECTION_START}{PREFIX_SECTION_ARCHIVE}" 

427 arc_end: Final[str] = f"{SECTION_END}{PREFIX_SECTION_ARCHIVE}" 

428 

429 for line in lines: 

430 if str.startswith(line, arc_start): 

431 if state != 0: 

432 raise ValueError( 

433 f"Inconsistent start state in {file!r}, " 

434 f"encountered {line!r}.") 

435 if str.endswith(line, SUFFIX_SECTION_ARCHIVE_X): 

436 current_id = int(line[len(arc_start):-len( 

437 SUFFIX_SECTION_ARCHIVE_X)]) 

438 if current_id in x: 

439 raise ValueError( 

440 f"Encountered archive X {current_id} twice " 

441 f"in {file!r}.") 

442 state = 1 

443 current.clear() 

444 continue 

445 if str.endswith(line, SUFFIX_SECTION_ARCHIVE_Y): 

446 current_id = int(line[len(arc_start):-len( 

447 SUFFIX_SECTION_ARCHIVE_Y)]) 

448 if current_id in y: 

449 raise ValueError( 

450 f"Encountered archive Y {current_id} twice " 

451 f"in {file!r}.") 

452 state = 2 

453 current.clear() 

454 continue 

455 if state in {1, 2}: 

456 if str.startswith(line, arc_end): 

457 if state == 1: 

458 if self.__parse_x: 

459 x[current_id] = "\n".join(current) 

460 elif (state == 2) and self.__parse_y: 

461 y[current_id] = "\n".join(current) 

462 current.clear() 

463 state = 0 

464 continue 

465 current.append(line) 

466 

467 if state != 0: 

468 raise ValueError(f"Inconsistent state in file {file!r}.") 

469 

470 dim: Final[int] = tuple.__len__(archive[0]) 

471 for solution in archive: 

472 if tuple.__len__(solution) != dim: 

473 raise ValueError( 

474 f"Inconsistent archive dimension of {solution} in file " 

475 f"{file!r}, should be {dim}.") 

476 

477 for time in progress: 

478 if tuple.__len__(time) != dim + 2: 

479 raise ValueError( 

480 "Inconsistent progress dimension of record " 

481 f"{time} in {file!r}, should be {dim + 2}.") 

482 

483 out: list[MOEndResult] = [] 

484 for i, solution in enumerate(archive): 

485 found: tuple[int | float, ...] | None = None 

486 for time in progress: 

487 if time[-dim:] == solution: 

488 found = time 

489 break 

490 out.append(MOEndResult( 

491 algorithm=o.algorithm, 

492 instance=o.instance, 

493 objective=o.objective, 

494 encoding=o.encoding, 

495 rand_seed=o.rand_seed, 

496 best_f=solution[0], 

497 last_improvement_fe=o.last_improvement_fe 

498 if found is None else cast("int", found[0]), 

499 last_improvement_time_millis=o.last_improvement_time_millis 

500 if found is None else cast("int", found[1]), 

501 total_fes=o.total_fes, 

502 total_time_millis=o.total_time_millis, 

503 goal_f=o.goal_f, 

504 max_fes=o.max_fes, 

505 max_time_millis=o.max_time_millis, 

506 fs=solution[1:], 

507 x=x.get(i), 

508 y=y.get(i))) 

509 self._progress_logger(f"Done parsing file {file!r} multi-objectively.") 

510 return out 

511 

512 

513# Run log files to end results if executed as script 

514if __name__ == "__main__": 

515 parser: Final[argparse.ArgumentParser] = moptipy_argparser( 

516 __file__, 

517 "Convert multi-objective log files obtained with moptipy to the " 

518 "end results CSV format that can be post-processed or exported to " 

519 "other tools.", 

520 "This program recursively parses a folder hierarchy created by" 

521 " the moptipy multi-objective experiment execution facility. " 

522 "This folder structure follows the scheme of algorithm/instance/" 

523 "log_file and has one log file per run. As result of the parsing, " 

524 "one CSV file (where columns are separated by ';') is created with" 

525 " one row per log file. This row contains the end-of-run state" 

526 " loaded from the log file. Whereas the log files may store " 

527 "the complete progress of one run of one algorithm on one " 

528 "problem instance as well as the algorithm configuration " 

529 "parameters, instance features, system settings, and the final" 

530 " results, the end results CSV file will only represent the " 

531 "final result quality, when it was obtained, how long the runs" 

532 " took, etc. This information is much denser and smaller and " 

533 "suitable for importing into other tools such as Excel or for " 

534 "postprocessing.") 

535 parser.add_argument( 

536 "source", nargs="?", default="./results", 

537 help="the location of the experimental results, i.e., the root folder " 

538 "under which to search for log files", type=Path) 

539 parser.add_argument( 

540 "dest", help="the path to the end results CSV file to be created", 

541 type=Path, nargs="?", default="./evaluation/end_results.txt") 

542 args: Final[argparse.Namespace] = parser.parse_args() 

543 

544 to_csv(from_logs(args.source), args.dest)