Coverage for src/sparkle/CLI/generate_report.py: 66%

474 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 12:00 +0000

1#!/usr/bin/env python3 

2"""Sparkle command to generate a report for an executed experiment.""" 

3 

4import sys 

5import shutil 

6import argparse 

7from pathlib import Path 

8import time 

9import json 

10import pandas as pd 

11 

12from pylatex import NoEscape, NewPage 

13import pylatex as pl 

14from sparkle import __version__ as __sparkle_version__ 

15 

16from sparkle.CLI.help import global_variables as gv 

17from sparkle.CLI.help import resolve_object_name 

18from sparkle.CLI.help import logging as sl 

19from sparkle.CLI.help import argparse_custom as ac 

20 

21from sparkle.solver import Solver 

22from sparkle.instance import Instance_Set 

23from sparkle.selector import Extractor 

24from sparkle.structures import PerformanceDataFrame, FeatureDataFrame 

25from sparkle.configurator.configurator import ConfigurationScenario 

26from sparkle.selector.selector import SelectionScenario 

27from sparkle.types import SolverStatus 

28from sparkle.platform import Settings 

29 

30from sparkle.platform import latex 

31from sparkle.platform.output.configuration_output import ConfigurationOutput 

32from sparkle.platform.output.selection_output import SelectionOutput 

33 

34 

35MAX_DEC = 4 # Maximum decimals used for each reported value 

36MAX_COLS_PER_TABLE = 2 # number of value columns extra to number of key columns 

37WIDE_TABLE_THRESHOLD = 4 # columns above which we switch to landscape 

38NUM_KEYS_PDF = 3 

39NUM_KEYS_FDF = 3 

40MAX_CELL_LEN = 17 

41 

42 

43def parser_function() -> argparse.ArgumentParser: 

44 """Define the command line arguments.""" 

45 parser = argparse.ArgumentParser( 

46 description="Generates a report for all known selection, configuration and " 

47 "parallel portfolio scenarios will be generated.", 

48 epilog="If you wish to filter specific solvers, instance sets, ... have a look " 

49 "at the command line arguments.", 

50 ) 

51 # Add argument for filtering solvers 

52 parser.add_argument( 

53 *ac.SolversReportArgument.names, **ac.SolversReportArgument.kwargs 

54 ) 

55 # Add argument for filtering instance sets 

56 parser.add_argument( 

57 *ac.InstanceSetsReportArgument.names, **ac.InstanceSetsReportArgument.kwargs 

58 ) 

59 

60 # Add argument for filtering appendix 

61 parser.add_argument( 

62 *Settings.OPTION_appendices.args, **Settings.OPTION_appendices.kwargs 

63 ) 

64 

65 # Add argument for filtering configurators? 

66 # Add argument for filtering selectors? 

67 # Add argument for filtering ??? scenario ids? configuration ids? 

68 parser.add_argument(*ac.GenerateJSONArgument.names, **ac.GenerateJSONArgument.kwargs) 

69 return parser 

70 

71 

72def generate_configuration_section( 

73 report: pl.Document, 

74 scenario: ConfigurationScenario, 

75 scenario_output: ConfigurationOutput, 

76) -> None: 

77 """Generate a section for a configuration scenario.""" 

78 report_dir = Path(report.default_filepath).parent 

79 time_stamp = time.strftime("%Y%m%d%H%M%S", time.localtime(time.time())) 

80 plot_dir = ( 

81 report_dir 

82 / f"{scenario.configurator.__name__}_{scenario.name}_plots_{time_stamp}" 

83 ) 

84 plot_dir.mkdir(exist_ok=True) 

85 

86 # 1. Write section intro 

87 report.append( 

88 pl.Section( 

89 f"{scenario.configurator.__name__} Configuration: " 

90 f"{scenario.solver.name} on {scenario.instance_set.name}" 

91 ) 

92 ) 

93 report.append("In this scenario, ") 

94 report.append( 

95 pl.UnsafeCommand( 

96 f"textbf{{{scenario.configurator.__name__}}} " 

97 f"({scenario.configurator.full_name})~\\cite" 

98 f"{{{scenario.configurator.__name__}}} with version " 

99 f"{scenario.configurator.version} was used for configuration. " 

100 ) 

101 ) 

102 report.append( 

103 f"The Solver {scenario.solver} was optimised on training set " 

104 f"{scenario.instance_set}. The scenario was run {scenario.number_of_runs} " 

105 f"times independently with different seeds, yielding {scenario.number_of_runs} " 

106 f"configurations. The cutoff time for the solver was set to " 

107 f"{scenario.solver_cutoff_time} seconds. The optimised objective is " 

108 f"{scenario.sparkle_objectives[0]}. Each Configuration was evaluated on the " 

109 "training set to determine the best configuration, e.g. the best " 

110 f"{scenario.sparkle_objectives[0]} value on the training set." 

111 ) 

112 

113 # 2. Report all the configurator settings in table format 

114 report.append(pl.Subsection("Configurator Settings")) 

115 report.append( 

116 f"The following settings were used for {scenario.configurator.__name__}:\n" 

117 ) 

118 tabular = pl.Tabular("l|r") 

119 tabular.add_row("Setting", "Value") 

120 tabular.add_hline() 

121 for setting, value in scenario.serialise().items(): 

122 # Keep only the last path segment for paths 

123 # Otherwise tables get too wide and we can't see other values 

124 stripped_value = str(value).strip().replace("\\", "/") 

125 segments = [segment for segment in stripped_value.split("/") if segment] 

126 if segments[-1]: 

127 tabular.add_row([setting, segments[-1]]) 

128 else: 

129 tabular.add_row([setting, "None"]) 

130 table_conf_settings = pl.Table(position="h") 

131 table_conf_settings.append(pl.UnsafeCommand("centering")) 

132 table_conf_settings.append(tabular) 

133 table_conf_settings.add_caption("Configurator Settings") 

134 report.append(table_conf_settings) 

135 

136 # 3. Report details on instance and solver used 

137 report.append(pl.Subsection("Solver & Instance Set(s) Details")) 

138 cs = scenario_output.solver.get_configuration_space() 

139 report.append( 

140 f"The solver {scenario_output.solver} was configured using " 

141 f"{len(cs.values())} configurable (hyper)parameters. " 

142 f"The configuration space has {len(cs.conditions)} conditions. " 

143 ) 

144 report.append("The following instance sets were used for the scenario:") 

145 with report.create(pl.Itemize()) as instance_set_latex_list: 

146 for instance_set in [ 

147 scenario_output.instance_set_train 

148 ] + scenario_output.test_instance_sets: 

149 training_set_name = instance_set.name.replace("_", " ") # Latex fix 

150 instance_set_latex_list.add_item( 

151 pl.UnsafeCommand( 

152 f"textbf{{{training_set_name}}} ({instance_set.size} instances)" 

153 ) 

154 ) 

155 

156 # Function to generate a results summary of default vs best on an instance set 

157 def instance_set_summary(instance_set_name: str) -> None: 

158 """Generate a results summary of default vs best on an instance set.""" 

159 instance_set_results = scenario_output.instance_set_results[instance_set_name] 

160 report.append( 

161 f"The {scenario.sparkle_objectives[0]} value of the Default " 

162 f"Configuration on {instance_set_name} was " 

163 ) 

164 report.append( 

165 pl.UnsafeCommand( 

166 f"textbf{{{round(instance_set_results.default_performance, MAX_DEC)}}}.\n" 

167 ) 

168 ) 

169 report.append( 

170 f"The {scenario.sparkle_objectives[0]} value of the Best " 

171 f"Configuration on {instance_set_name} was " 

172 ) 

173 report.append( 

174 pl.UnsafeCommand( 

175 f"textbf{{{round(instance_set_results.best_performance, MAX_DEC)}}}.\n" 

176 ) 

177 ) 

178 report.append("In ") 

179 report.append(latex.AutoRef(f"fig:bestvsdefault{instance_set_name}{time_stamp}")) 

180 report.append(pl.utils.bold(" ")) # Force white space 

181 report.append("the results are plotted per instance.") 

182 # Create graph to compare best configuration vs default on the instance set 

183 

184 df = pd.DataFrame( 

185 [ 

186 instance_set_results.default_instance_performance, 

187 instance_set_results.best_instance_performance, 

188 ], 

189 index=["Default Configuration", "Best Configuration"], 

190 dtype=float, 

191 ).T 

192 plot = latex.comparison_plot(df, None) 

193 plot_path = ( 

194 plot_dir / f"{scenario_output.best_configuration_key}_vs_" 

195 f"Default_{instance_set_name}.pdf" 

196 ) 

197 plot.write_image(plot_path, width=500, height=500) 

198 with report.create(pl.Figure(position="h")) as figure: 

199 figure.add_image( 

200 str(plot_path.relative_to(report_dir)), 

201 width=pl.utils.NoEscape(r"0.6\textwidth"), 

202 ) 

203 figure.add_caption( 

204 f"Best vs Default Performance on {instance_set_name} " 

205 f"({scenario.sparkle_objectives[0]})" 

206 ) 

207 figure.append( 

208 pl.UnsafeCommand( 

209 r"label{" 

210 f"fig:bestvsdefault{instance_set_name}{time_stamp}" 

211 r"}" 

212 ) 

213 ) 

214 if scenario.sparkle_objectives[0].time: # Write status table 

215 report.append("The following Solver status were found per instance:") 

216 tabular = pl.Tabular("l|c|c|c") 

217 tabular.add_row("Status", "Default", "Best", "Overlap") 

218 tabular.add_hline() 

219 # Count the statuses 

220 for status in SolverStatus: 

221 default_count, best_count, overlap_count = 0, 0, 0 

222 for instance in instance_set_results.instance_status_default.keys(): 

223 instance = str(instance) 

224 default_hit = ( 

225 instance_set_results.instance_status_default[instance] == status 

226 ) 

227 best_hit = ( 

228 instance_set_results.instance_status_best[instance] == status 

229 ) 

230 default_count += default_hit 

231 best_count += best_hit 

232 overlap_count += default_hit and best_hit 

233 if default_count or best_count: 

234 tabular.add_row(status, default_count, best_count, overlap_count) 

235 table_status_values = pl.Table(position="h") 

236 table_status_values.append(pl.UnsafeCommand("centering")) 

237 table_status_values.append(tabular) 

238 table_status_values.add_caption( 

239 "Status count for the best and default configuration." 

240 ) 

241 report.append(table_status_values) 

242 

243 # 4. Report the results of the best configuration on the training set vs the default 

244 report.append( 

245 pl.Subsection( 

246 f"Comparison of Default and Best Configuration on Training Set " 

247 f"{scenario_output.instance_set_train.name}" 

248 ) 

249 ) 

250 instance_set_summary(scenario_output.instance_set_train.name) 

251 

252 # 5. Report the actual config values 

253 report.append(pl.Subsubsection("Best Configuration Values")) 

254 if ( 

255 scenario_output.best_configuration_key 

256 == PerformanceDataFrame.default_configuration 

257 ): 

258 report.append( 

259 "The configurator failed to find a better configuration than the " 

260 "default configuration on the training set in this scenario." 

261 ) 

262 else: 

263 report.append( 

264 "The following parameter values " 

265 "were found to be the best on the training set:\n" 

266 ) 

267 tabular = pl.Tabular("l|r") 

268 tabular.add_row("Parameter", "Value") 

269 tabular.add_hline() 

270 for parameter, value in scenario_output.best_configuration.items(): 

271 tabular.add_row([parameter, str(value)]) 

272 table_best_values = pl.Table(position="h") 

273 table_best_values.append(pl.UnsafeCommand("centering")) 

274 table_best_values.append(tabular) 

275 table_best_values.add_caption("Best found configuration values") 

276 report.append(table_best_values) 

277 

278 # 6. Report the results of best vs default conf on the test sets 

279 

280 for test_set in scenario_output.test_instance_sets: 

281 report.append( 

282 pl.Subsection( 

283 f"Comparison of Default and Best Configuration on Test Set " 

284 f"{test_set.name}" 

285 ) 

286 ) 

287 instance_set_summary(test_set.name) 

288 

289 # 7. Report the parameter ablation scenario if present 

290 if scenario.ablation_scenario: 

291 report.append(pl.Subsection("Parameter importance via Ablation")) 

292 report.append("Ablation analysis ") 

293 report.append(pl.UnsafeCommand(r"cite{FawcettHoos16} ")) 

294 test_set = scenario.ablation_scenario.test_set 

295 if not scenario.ablation_scenario.test_set: 

296 test_set = scenario.ablation_scenario.train_set 

297 report.append( 

298 f"is performed from the default configuration of {scenario.solver} to the " 

299 f"best found configuration ({scenario_output.best_configuration_key}) " 

300 "to see which parameter changes between them contribute most to the improved" 

301 " performance. The ablation path uses the training set " 

302 f"{scenario.ablation_scenario.train_set.name} and validation is performed " 

303 f"on the test set {test_set.name}. The set of parameters that differ in the " 

304 "two configurations will form the ablation path. Starting from the default " 

305 "configuration, the path is computed by performing a sequence of rounds. In" 

306 " a round, each available parameter is flipped in the configuration and is " 

307 "validated on its performance. The flipped parameter with the best " 

308 "performance in that round, is added to the configuration and the next round" 

309 " starts with the remaining parameters. This repeats until all parameters " 

310 "are flipped, which is the best found configuration. The analysis resulted " 

311 "in the ablation presented in " 

312 ) 

313 report.append(latex.AutoRef("tab:ablationtable")) 

314 report.append(".") 

315 

316 # Add ablation table 

317 tabular = pl.Tabular("r|l|r|r|r") 

318 data = scenario.ablation_scenario.read_ablation_table() 

319 for index, row in enumerate(data): 

320 tabular.add_row(*row) 

321 if index == 0: 

322 tabular.add_hline() 

323 table_ablation = pl.Table(position="h") 

324 table_ablation.append(pl.UnsafeCommand("centering")) 

325 table_ablation.append(tabular) 

326 table_ablation.add_caption("Ablation table") 

327 table_ablation.append(pl.UnsafeCommand(r"label{tab:ablationtable}")) 

328 report.append(table_ablation) 

329 

330 

331def generate_selection_section( 

332 report: pl.Document, scenario: SelectionScenario, scenario_output: SelectionOutput 

333) -> None: 

334 """Generate a section for a selection scenario.""" 

335 report_dir = Path(report.default_filepath).parent 

336 time_stamp = time.strftime("%Y%m%d%H%M%S", time.localtime(time.time())) 

337 plot_dir = report_dir / f"{scenario.name.replace(' ', '_')}_plots_{time_stamp}" 

338 plot_dir.mkdir(exist_ok=True) 

339 report.append( 

340 pl.Section( 

341 f"Selection: {scenario.selector.model_class.__name__} on " 

342 f"{' '.join([s[0] for s in scenario_output.training_instance_sets])}" 

343 ) 

344 ) 

345 report.append( 

346 f"In this scenario, a {scenario.selector.model_class.__name__} " 

347 f" ({scenario.selector.selector_class.__name__}) was trained on the " 

348 "performance and feature data using ASF-lib. The following solvers " 

349 f"were run with a cutoff time of {scenario.solver_cutoff} seconds:" 

350 ) 

351 with report.create(pl.Itemize()) as solver_latex_list: 

352 for solver_name in scenario_output.solvers.keys(): 

353 solver_name = solver_name.replace("_", " ") 

354 solver_latex_list.add_item( 

355 pl.UnsafeCommand( 

356 f"textbf{{{solver_name}}} " 

357 f"({len(scenario_output.solvers[solver_name])} configurations)" 

358 ) 

359 ) 

360 # Report training instance sets 

361 report.append("The following training instance sets were used:") 

362 with report.create(pl.Itemize()) as instance_set_latex_list: 

363 for training_set_name, set_size in scenario_output.training_instance_sets: 

364 training_set_name = training_set_name.replace("_", " ") # Latex fix 

365 instance_set_latex_list.add_item( 

366 pl.UnsafeCommand(f"textbf{{{training_set_name}}} ({set_size} instances)") 

367 ) 

368 # Report feature extractors 

369 report.append( 

370 "The following feature extractors were used with a extractor cutoff " 

371 f"time of {scenario.extractor_cutoff} seconds:" 

372 ) 

373 with report.create(pl.Itemize()) as feature_extractor_latex_list: 

374 for feature_extractor_name in scenario.feature_extractors: 

375 extractor = resolve_object_name( 

376 feature_extractor_name, 

377 gv.file_storage_data_mapping[gv.extractor_nickname_list_path], 

378 gv.settings().DEFAULT_extractor_dir, 

379 class_name=Extractor, 

380 ) 

381 if extractor is not None: 

382 output_dimension = extractor.output_dimension 

383 else: 

384 extractor_name = Path(feature_extractor_name).name 

385 output_dimension = sum( 

386 column.startswith(f"{extractor_name}_") 

387 or column.endswith(f"_{extractor_name}") 

388 for column in scenario.feature_data.columns 

389 ) 

390 feature_extractor_name = feature_extractor_name.replace("_", " ") # Latex 

391 feature_extractor_latex_list.add_item( 

392 pl.UnsafeCommand( 

393 f"textbf{{{feature_extractor_name}}} ({output_dimension} features)" 

394 ) 

395 ) 

396 # Report Training results 

397 report.append(pl.Subsection("Training Results")) 

398 # 1. Report VBS and selector performance, create ranking list of the solvers 

399 # TODO Add ref here to the training sets section? 

400 report.append( 

401 f"In this section, the {scenario.objective.name} results for the " 

402 "portfolio selector on solving the training instance set(s) listed " 

403 "is reported. " 

404 ) 

405 report.append( 

406 f"The {scenario.objective.name} values for the Virtual Best Solver " 

407 "(VBS), i.e., the perfect portfolio selector is " 

408 ) 

409 report.append(pl.utils.bold(f"{round(scenario_output.vbs_performance, MAX_DEC)}")) 

410 report.append(", the actual portfolio selector performance is ") 

411 report.append( 

412 pl.utils.bold(f"{round(scenario_output.actual_performance, MAX_DEC)}.\n") 

413 ) 

414 

415 report.append( 

416 f"Below, the solvers are ranked based on {scenario.objective.name} performance:" 

417 ) 

418 with report.create(pl.Enumerate()) as ranking_list: 

419 for solver_name, conf_id, value in scenario_output.solver_performance_ranking: 

420 value = round(value, MAX_DEC) 

421 solver_name = solver_name.replace("_", " ") # Latex fix 

422 conf_id = conf_id.replace("_", " ") # Latex fix 

423 ranking_list.add_item( 

424 pl.UnsafeCommand(f"textbf{{{solver_name}}} ({conf_id}): {value}") 

425 ) 

426 

427 # 2. Marginal contribution ranking list VBS 

428 report.append(pl.Subsubsection("Marginal Contribution Ranking List")) 

429 report.append( 

430 "The following list shows the marginal contribution ranking list for the VBS:" 

431 ) 

432 with report.create(pl.Enumerate()) as ranking_list: 

433 for ( 

434 solver_name, 

435 conf_id, 

436 contribution, 

437 performance, 

438 ) in scenario_output.marginal_contribution_perfect: 

439 contribution, performance = ( 

440 round(contribution, MAX_DEC), 

441 round(performance, MAX_DEC), 

442 ) 

443 solver_name = solver_name.replace("_", " ") # Latex fix 

444 conf_id = conf_id.replace("_", " ") # Latex fix 

445 ranking_list.add_item( 

446 pl.UnsafeCommand( 

447 f"textbf{{{solver_name}}} ({conf_id}): {contribution} ({performance})" 

448 ) 

449 ) 

450 

451 # 3. Marginal contribution ranking list actual selector 

452 report.append( 

453 "The following list shows the marginal contribution ranking list for " 

454 "the actual portfolio selector:" 

455 ) 

456 with report.create(pl.Enumerate()) as ranking_list: 

457 for ( 

458 solver_name, 

459 conf_id, 

460 contribution, 

461 performance, 

462 ) in scenario_output.marginal_contribution_actual: 

463 contribution, performance = ( 

464 round(contribution, MAX_DEC), 

465 round(performance, MAX_DEC), 

466 ) 

467 solver_name = solver_name.replace("_", " ") # Latex fix 

468 conf_id = conf_id.replace("_", " ") # Latex fix 

469 ranking_list.add_item( 

470 pl.UnsafeCommand( 

471 f"textbf{{{solver_name}}} ({conf_id}): {contribution} ({performance})" 

472 ) 

473 ) 

474 

475 # 4. Create scatter plot analysis 

476 report.append(pl.Subsubsection("Scatter Plot Analysis")) 

477 report.append(latex.AutoRef(f"fig:sbsvsselector{time_stamp}")) 

478 report.append(pl.utils.bold(" ")) # Trick to force a white space 

479 report.append( 

480 "shows the empirical comparison between the portfolio " 

481 "selector and the single best solver (SBS). " 

482 ) 

483 report.append(latex.AutoRef("fig:vbsvsselector")) 

484 report.append(pl.utils.bold(" ")) # Trick to force a white space 

485 report.append( 

486 "shows the empirical comparison between the actual portfolio selector " 

487 "and the virtual best solver (VBS)." 

488 ) 

489 # Create figure on SBS versus the selector 

490 sbs_name, sbs_config, _ = scenario_output.solver_performance_ranking[0] 

491 # sbs_plot_name = f"{Path(sbs_name).name} ({sbs_config})" 

492 sbs_performance = scenario_output.sbs_performance 

493 selector_performance = scenario_output.actual_performance_data 

494 

495 # Join the data together 

496 

497 df = pd.DataFrame( 

498 [sbs_performance, selector_performance], 

499 index=[f"{Path(sbs_name).name} ({sbs_config})", "Selector"], 

500 dtype=float, 

501 ).T 

502 plot = latex.comparison_plot(df, "Single Best Solver vs Selector") 

503 plot_path = ( 

504 plot_dir / f"{Path(sbs_name).name}_{sbs_config}_vs_" 

505 f"Selector_{scenario.selector.model_class.__name__}.pdf" 

506 ) 

507 plot.write_image(plot_path, width=500, height=500) 

508 with report.create(pl.Figure()) as figure: 

509 figure.add_image( 

510 str(plot_path.relative_to(report_dir)), 

511 width=pl.utils.NoEscape(r"0.6\textwidth"), 

512 ) 

513 figure.add_caption( 

514 "Empirical comparison between the Single Best Solver and the Selector" 

515 ) 

516 label = r"label{fig:sbsvsselector" + str(time_stamp) + r"}" 

517 figure.append(pl.UnsafeCommand(f"{label}")) 

518 

519 # Comparison between the actual portfolio selector in Sparkle and the VBS. 

520 vbs_performance = scenario_output.vbs_performance_data.tolist() 

521 df = pd.DataFrame( 

522 [vbs_performance, selector_performance], 

523 index=["Virtual Best Solver", "Selector"], 

524 dtype=float, 

525 ).T 

526 plot = latex.comparison_plot(df, "Virtual Best Solver vs Selector") 

527 plot_path = ( 

528 plot_dir 

529 / f"Virtual_Best_Solver_vs_Selector_{scenario.selector.model_class.__name__}.pdf" 

530 ) 

531 plot.write_image(plot_path, width=500, height=500) 

532 with report.create(pl.Figure()) as figure: 

533 figure.add_image( 

534 str(plot_path.relative_to(report_dir)), 

535 width=pl.utils.NoEscape(r"0.6\textwidth"), 

536 ) 

537 figure.add_caption( 

538 "Empirical comparison between the Virtual Best Solver and the Selector" 

539 ) 

540 figure.append(pl.UnsafeCommand(r"label{fig:vbsvsselector}")) 

541 

542 if scenario_output.test_sets: 

543 report.append(pl.Subsection("Test Results")) 

544 report.append("The following results are reported on the test set(s):") 

545 with report.create(pl.Itemize()) as latex_list: 

546 for test_set_name, test_set_size in scenario_output.test_sets: 

547 result = round( 

548 scenario_output.test_set_performance[test_set_name], MAX_DEC 

549 ) 

550 latex_list.add_item( 

551 pl.UnsafeCommand( 

552 f"textbf{{{test_set_name}}} ({test_set_size} instances): {result}" 

553 ) 

554 ) 

555 

556 

557def generate_parallel_portfolio_section( 

558 report: pl.Document, scenario: PerformanceDataFrame 

559) -> None: 

560 """Generate a section for a parallel portfolio scenario.""" 

561 report_dir = Path(report.default_filepath).parent 

562 portfolio_name = scenario.csv_filepath.parent.name 

563 time_stamp = time.strftime("%Y%m%d%H%M%S", time.localtime(time.time())) 

564 plot_dir = report_dir / f"{portfolio_name.replace(' ', '_')}_plots_{time_stamp}" 

565 plot_dir.mkdir() 

566 report.append(pl.Section(f"Parallel Portfolio {portfolio_name}")) 

567 report.append( 

568 "In this scenario, Sparkle runs the portfolio of Solvers on each instance in " 

569 "parallel with " 

570 f"{gv.settings().parallel_portfolio_num_seeds_per_solver} different " 

571 "seeds. The cutoff time for each solver run is set to " 

572 f"{gv.settings().solver_cutoff_time} seconds." 

573 ) 

574 report.append(pl.Subsection("Solvers & Instance Sets")) 

575 report.append("The following Solvers were used in the portfolio:") 

576 # 1. Report on the Solvers and Instance Sets used for the portfolio 

577 with report.create(pl.Itemize()) as solver_latex_list: 

578 configs = scenario.configurations 

579 for solver in scenario.solvers: 

580 solver_name = solver.replace("_", " ") 

581 solver_latex_list.add_item( 

582 pl.UnsafeCommand( 

583 f"textbf{{{solver_name}}} ({len(configs[solver])} configurations)" 

584 ) 

585 ) 

586 report.append("The following Instance Sets were used in the portfolio:") 

587 instance_sets = set(set_name for set_name, _ in scenario.instance_pairs) 

588 instance_set_count = [ 

589 sum(1 for set_name, _ in scenario.instance_pairs if set_name == instance_set) 

590 for instance_set in instance_sets 

591 ] 

592 with report.create(pl.Itemize()) as instance_set_latex_list: 

593 for set_name, set_size in zip(instance_sets, instance_set_count): 

594 set_name = set_name.replace("_", " ") # Latex fix 

595 instance_set_latex_list.add_item( 

596 pl.UnsafeCommand(f"textbf{{{set_name}}} ({set_size} instances)") 

597 ) 

598 # 2. List which solver was the best on how many instances 

599 report.append(pl.Subsection("Portfolio Performance")) 

600 objective = scenario.objectives[0] 

601 report.append( 

602 f"The objective for the portfolio is {objective}. The " 

603 "following performance of the solvers was found over the instances: " 

604 ) 

605 best_solver_count = {solver: 0 for solver in scenario.solvers} 

606 for instance_pair in scenario.instance_pairs: 

607 ranking = scenario.get_solver_ranking( 

608 objective=objective, instance_pairs=[instance_pair] 

609 ) 

610 best_solver_count[ranking[0][0]] += 1 

611 

612 with report.create(pl.Itemize()) as latex_list: 

613 for solver, count in best_solver_count.items(): 

614 solver_name = solver.replace("_", " ") 

615 latex_list.add_item( 

616 pl.UnsafeCommand( 

617 f"textbf{{{solver_name}}} was the best solver on {count} instance(s)." 

618 ) 

619 ) 

620 # TODO Report how many instances remained unsolved 

621 

622 # 3. Create table showing the performance of the portfolio vs and all solvers, 

623 # by showing the status count and number of times the solver was best 

624 solver_cancelled_count = {solver: 0 for solver in scenario.solvers} 

625 solver_timeout_count = {solver: 0 for solver in scenario.solvers} 

626 status_objective = [ 

627 objective 

628 for objective in scenario.objective_names 

629 if objective.lower().startswith("status") 

630 ][0] 

631 cancelled_status = [ 

632 SolverStatus.UNKNOWN, 

633 SolverStatus.CRASHED, 

634 SolverStatus.WRONG, 

635 SolverStatus.ERROR, 

636 SolverStatus.KILLED, 

637 ] 

638 for solver in scenario.solvers: 

639 status = scenario.get_value(solver=solver, objective=status_objective) 

640 for status in scenario.get_value(solver=solver, objective=status_objective): 

641 status = SolverStatus(status) 

642 if status in cancelled_status: 

643 solver_cancelled_count[solver] += 1 

644 elif status == SolverStatus.TIMEOUT: 

645 solver_timeout_count[solver] += 1 

646 

647 report.append(latex.AutoRef("tab:parallelportfoliotable")) 

648 report.append(pl.utils.bold(" ")) 

649 report.append(" shows the performance of the portfolio on the test set(s).") 

650 tabular = pl.Tabular("r|rrrr") 

651 tabular.add_row(["Solver", objective, "# Timeouts", "# Cancelled", "# Best"]) 

652 tabular.add_hline() 

653 solver_performance = { 

654 solver: round(performance, MAX_DEC) 

655 for solver, _, performance in scenario.get_solver_ranking(objective=objective) 

656 } 

657 for solver in scenario.solvers: 

658 tabular.add_row( 

659 solver, 

660 solver_performance[solver], 

661 solver_timeout_count[solver], 

662 solver_cancelled_count[solver], 

663 best_solver_count[solver], 

664 ) 

665 tabular.add_hline() 

666 portfolio_performance = round( 

667 scenario.best_performance(objective=objective), MAX_DEC 

668 ) 

669 tabular.add_row( 

670 portfolio_name, 

671 portfolio_performance, 

672 sum(solver_timeout_count.values()), 

673 sum(solver_cancelled_count.values()), 

674 sum(best_solver_count.values()), 

675 ) 

676 table_portfolio = pl.Table(position="h") 

677 table_portfolio.append(pl.UnsafeCommand("centering")) 

678 table_portfolio.append(tabular) 

679 table_portfolio.add_caption("Parallel Portfolio Performance") 

680 table_portfolio.append(pl.UnsafeCommand(r"label{tab:parallelportfoliotable}")) 

681 report.append(table_portfolio) 

682 

683 # 4. Create scatter plot analysis between the portfolio and the single best solver 

684 sbs_name = scenario.get_solver_ranking(objective=objective)[0][0] 

685 sbs_instance_performance = scenario.get_value( 

686 solver=sbs_name, objective=objective.name 

687 ) 

688 sbs_name = Path(sbs_name).name 

689 report.append(latex.AutoRef("fig:portfoliovssbs")) 

690 report.append(pl.utils.bold(" ")) 

691 report.append( 

692 " shows the emprical comparison between the portfolio and the single " 

693 f"best solver (SBS) {sbs_name}." 

694 ) 

695 portfolio_instance_performance = scenario.best_instance_performance( 

696 objective=objective.name 

697 ).tolist() 

698 

699 df = pd.DataFrame( 

700 [sbs_instance_performance, portfolio_instance_performance], 

701 index=[f"SBS ({sbs_name}) Performance", "Portfolio Performance"], 

702 dtype=float, 

703 ).T 

704 plot = latex.comparison_plot(df, None) 

705 plot_path = plot_dir / f"sbs_{sbs_name}_vs_parallel_portfolio.pdf" 

706 plot.write_image(plot_path, width=500, height=500) 

707 with report.create(pl.Figure(position="h")) as figure: 

708 figure.add_image( 

709 str(plot_path.relative_to(report_dir)), 

710 width=pl.utils.NoEscape(r"0.6\textwidth"), 

711 ) 

712 figure.add_caption(f"Portfolio vs SBS Performance ({objective})") 

713 figure.append(pl.UnsafeCommand(r"label{fig:portfoliovssbs}")) 

714 

715 

716def append_dataframe_longtable( 

717 report: pl.Document, 

718 df: pd.DataFrame, 

719 caption: str, 

720 label: str, 

721 max_cols: int = MAX_COLS_PER_TABLE, 

722 wide_threshold: int = WIDE_TABLE_THRESHOLD, 

723 num_keys: int = NUM_KEYS_PDF, 

724) -> None: 

725 """Appends a pandas DataFrame to a PyLaTeX document as one or more LaTeX longtables. 

726 

727 Args: 

728 report: The PyLaTeX document to which the table(s) will be appended. 

729 df: The DataFrame to be rendered as LaTeX longtable(s). 

730 caption: The caption for the table(s). 

731 label: The LaTeX label for referencing the table(s). 

732 max_cols: Maximum number of columns per table chunk. 

733 Defaults to MAX_COLS_PER_TABLE. 

734 wide_threshold: Number of columns above which the table is rotated 

735 to landscape. Defaults to WIDE_TABLE_THRESHOLD. 

736 num_keys: Number of key columns to include in each table chunk. 

737 Defaults to NUM_KEYS_PDF. 

738 

739 Returns: 

740 None 

741 """ 

742 import math 

743 from typing import Union 

744 

745 def latex_escape_text(string: str) -> str: 

746 """Escape special LaTeX characters in a string.""" 

747 # escape text, but insert our own LaTeX macro around it 

748 return ( 

749 string.replace("\\", r"\textbackslash{}") 

750 .replace("&", r"\&") 

751 .replace("%", r"\%") 

752 .replace("$", r"\$") 

753 .replace("#", r"\#") 

754 .replace("_", r"\_") 

755 .replace("{", r"\{") 

756 .replace("}", r"\}") 

757 .replace("~", r"\textasciitilde{}") 

758 .replace("^", r"\textasciicircum{}") 

759 ) 

760 

761 def last_path_segment(text: str) -> str: 

762 """Keep only the last non-empty path-like segment. Handles both back and forwardslashes. Removes any leading/trailing slashes.""" 

763 stripped_text = str(text).strip().replace("\\", "/") 

764 segments = [ 

765 segment for segment in stripped_text.split("/") if segment 

766 ] # ignore empty segments 

767 return segments[-1] if segments else "" 

768 

769 def wrap_fixed_shortstack(cell: str, width: int = MAX_CELL_LEN) -> str: 

770 """Wrap long text to a fixed width for LaTeX tables.""" 

771 string_cell = last_path_segment(cell) 

772 if len(string_cell) <= width: 

773 return latex_escape_text(string_cell) 

774 chunks = [ 

775 latex_escape_text(string_cell[index : index + width]) 

776 for index in range(0, len(string_cell), width) 

777 ] 

778 # left-aligned shortstack: forces line breaks and grows row height 

779 return r"\shortstack[l]{" + r"\\ ".join(chunks) + "}" 

780 

781 def wrap_header_labels( 

782 df: pd.DataFrame, width_per_cell: int = MAX_CELL_LEN 

783 ) -> pd.DataFrame: 

784 """Wrap long header labels to a fixed width for LaTeX tables.""" 

785 df_copy = df.copy() 

786 if isinstance(df_copy.columns, pd.MultiIndex): 

787 new_cols = [] 

788 for column in df_copy.columns: 

789 new_cols.append( 

790 tuple( 

791 wrap_fixed_shortstack(last_path_segment(index), width_per_cell) 

792 if isinstance(index, str) 

793 else index 

794 for index in column 

795 ) 

796 ) 

797 names = [ 

798 ( 

799 wrap_fixed_shortstack(last_path_segment(name), width_per_cell) 

800 if isinstance(name, str) 

801 else name 

802 ) 

803 for name in (df_copy.columns.names or []) 

804 ] 

805 df_copy.columns = pd.MultiIndex.from_tuples(new_cols, names=names) 

806 else: 

807 df_copy.columns = [ 

808 wrap_fixed_shortstack(last_path_segment(column), width_per_cell) 

809 if isinstance(column, str) 

810 else column 

811 for column in df_copy.columns 

812 ] 

813 return df_copy 

814 

815 def format_cell(cell: Union[int, float, str]) -> str: 

816 """Format a cell for printing in a LaTeX table.""" 

817 try: 

818 float_cell = float(cell) 

819 except (TypeError, ValueError): 

820 return wrap_fixed_shortstack(last_path_segment(str(cell)), MAX_CELL_LEN) 

821 

822 if not math.isfinite(float_cell): 

823 return "NaN" 

824 

825 if float_cell.is_integer(): 

826 return str(int(float_cell)) 

827 # round to MAX_DEC, then strip trailing zeros 

828 stripped_cell = f"{round(float_cell, MAX_DEC):.{MAX_DEC}f}".rstrip("0").rstrip( 

829 "." 

830 ) 

831 return stripped_cell 

832 

833 df_copy = df.copy() 

834 

835 # Inorder to be able to show the key columns, we need to reset the index 

836 if not isinstance(df_copy.index, pd.RangeIndex) and df_copy.index.name in ( 

837 None, 

838 "index", 

839 "", 

840 ): 

841 df_copy = df_copy.reset_index() 

842 

843 # Remove the Seed column from the performance dataframe since it is not 

844 # very informative and clutters the table 

845 if isinstance(df, PerformanceDataFrame): 

846 mask = df_copy.columns.get_level_values("Meta") == "Seed" 

847 df_copy = df_copy.loc[:, ~mask] 

848 

849 # For performance dataframe, we want to show values of objectives with their corresponding instance and run. 

850 # Since objective, instance and run are indexes in the performance dataframe, 

851 # they will be part of the index and we need to reset the index to get them 

852 # as columns. 

853 # We'll name them as key columns, since they are the key to identify the value of the objective 

854 # for a given instance and run. 

855 # (Respectively FeatureGroup, FeatureName, Extractor in feature dataframe) 

856 keys = df_copy.iloc[:, :num_keys] # Key columns 

857 

858 # Split the dataframe into chunks of max_cols per page 

859 number_column_chunks = max((df_copy.shape[1] - 1) // max_cols + 1, 1) 

860 for i in range(number_column_chunks): 

861 report.append(NewPage()) 

862 full_part = None 

863 start_col = i * max_cols 

864 end_col = (i + 1) * max_cols 

865 

866 # Select the value columns for this chunk 

867 values = df_copy.iloc[ 

868 :, 

869 start_col + num_keys : end_col + num_keys, 

870 ] 

871 

872 # Concatenate the key and value columns 

873 full_part = pd.concat([keys, values], axis=1) 

874 

875 # If there are no value columns left, we are done 

876 if (full_part.shape[1]) <= num_keys: 

877 break 

878 

879 full_part_wrapped = wrap_header_labels(full_part, MAX_CELL_LEN) 

880 

881 # tell pandas how to print numbers 

882 formatters = {col: format_cell for col in full_part_wrapped.columns} 

883 

884 tex = full_part_wrapped.to_latex( 

885 longtable=True, 

886 index=False, 

887 escape=False, # We want to split the long words, not escape them 

888 caption=caption + (f" (part {i + 1})" if number_column_chunks > 1 else ""), 

889 label=label + f"-p{i + 1}" if number_column_chunks > 1 else label, 

890 float_format=None, 

891 multicolumn=True, 

892 multicolumn_format="c", 

893 multirow=False, 

894 column_format="c" * full_part_wrapped.shape[1], 

895 formatters=formatters, 

896 ) 

897 

898 # centre the whole table horizontally 

899 centred_tex = "\\begin{center}\n" + tex + "\\end{center}\n" 

900 

901 # rotate if still too wide 

902 if full_part_wrapped.shape[1] > wide_threshold: 

903 report.append(NoEscape(r"\begin{landscape}")) 

904 report.append(NoEscape(centred_tex)) 

905 report.append(NoEscape(r"\end{landscape}")) 

906 else: 

907 report.append(NoEscape(centred_tex)) 

908 

909 

910def generate_appendix( 

911 report: pl.Document, 

912 performance_data: PerformanceDataFrame, 

913 feature_data: FeatureDataFrame, 

914) -> None: 

915 """Appendix. 

916 

917 Args: 

918 report: The LaTeX document object to which the appendix will be added. 

919 performance_data: The performance data to be included in the appendix. 

920 feature_data: The feature data to be included in the appendix. 

921 

922 Returns: 

923 None 

924 """ 

925 report.packages.append(pl.Package("pdflscape")) # Landscape pages 

926 report.packages.append(pl.Package("longtable")) # Long tables 

927 report.packages.append(pl.Package("booktabs")) # Better table formatting 

928 report.append(pl.NewPage()) 

929 report.append(pl.NoEscape(r"\clearpage")) 

930 report.append(pl.UnsafeCommand("appendix")) 

931 report.append(pl.Section("Performance DataFrame")) 

932 

933 append_dataframe_longtable( 

934 report, 

935 performance_data, 

936 caption="Performance DataFrame", 

937 label="tab:perf_data", 

938 max_cols=MAX_COLS_PER_TABLE, 

939 wide_threshold=WIDE_TABLE_THRESHOLD, 

940 num_keys=NUM_KEYS_PDF, 

941 ) 

942 

943 report.append(pl.Section("Feature DataFrame")) 

944 append_dataframe_longtable( 

945 report, 

946 feature_data, 

947 caption="Feature DataFrame", 

948 label="tab:feature_data", 

949 max_cols=MAX_COLS_PER_TABLE, 

950 wide_threshold=WIDE_TABLE_THRESHOLD, 

951 num_keys=NUM_KEYS_FDF, 

952 ) 

953 

954 

955def main(argv: list[str]) -> None: 

956 """Generate a report for executed experiments in the platform.""" 

957 # Log command call 

958 sl.log_command(sys.argv, gv.settings().random_state) 

959 

960 # Define command line arguments 

961 parser = parser_function() 

962 

963 # Process command line arguments 

964 args = parser.parse_args(argv) 

965 

966 performance_data = PerformanceDataFrame(gv.settings().DEFAULT_performance_data_path) 

967 feature_data = FeatureDataFrame(gv.settings().DEFAULT_feature_data_path) 

968 

969 # Fetch all known scenarios 

970 configuration_scenarios = gv.configuration_scenarios(refresh=True) 

971 selection_scenarios = gv.selection_scenarios(refresh=True) 

972 parallel_portfolio_scenarios = gv.parallel_portfolio_scenarios() 

973 

974 # Filter scenarios based on args 

975 if args.solvers: 

976 solvers = [ 

977 resolve_object_name( 

978 solver, 

979 gv.solver_nickname_mapping, 

980 gv.settings().DEFAULT_solver_dir, 

981 Solver, 

982 ) 

983 for solver in args.solvers 

984 ] 

985 configuration_scenarios = [ 

986 scenario 

987 for scenario in configuration_scenarios 

988 if scenario.solver.directory in [solver.directory for solver in solvers] 

989 ] 

990 selection_scenarios = [ 

991 scenario 

992 for scenario in selection_scenarios 

993 if set(scenario.solvers).intersection( 

994 [str(solver.directory) for solver in solvers] 

995 ) 

996 ] 

997 parallel_portfolio_scenarios = [ 

998 scenario 

999 for scenario in parallel_portfolio_scenarios 

1000 if set(scenario.solvers).intersection( 

1001 [str(solver.directory) for solver in solvers] 

1002 ) 

1003 ] 

1004 if args.instance_sets: 

1005 instance_sets = [ 

1006 resolve_object_name( 

1007 instance_set, 

1008 gv.instance_set_nickname_mapping, 

1009 gv.settings().DEFAULT_instance_dir, 

1010 Instance_Set, 

1011 ) 

1012 for instance_set in args.instance_sets 

1013 ] 

1014 configuration_scenarios = [ 

1015 scenario 

1016 for scenario in configuration_scenarios 

1017 if scenario.instance_set.directory 

1018 in [instance_set.directory for instance_set in instance_sets] 

1019 ] 

1020 selection_scenarios = [ 

1021 scenario 

1022 for scenario in selection_scenarios 

1023 if set(scenario.instance_sets).intersection( 

1024 [str(instance_set.name) for instance_set in instance_sets] 

1025 ) 

1026 ] 

1027 parallel_portfolio_scenarios = [ 

1028 scenario 

1029 for scenario in parallel_portfolio_scenarios 

1030 if set(scenario.instance_sets).intersection( 

1031 [str(instance_set.name) for instance_set in instance_sets] 

1032 ) 

1033 ] 

1034 

1035 processed_configuration_scenarios = [] 

1036 processed_selection_scenarios = [] 

1037 possible_test_sets = [ 

1038 Instance_Set(possible_test_set) 

1039 for possible_test_set in gv.settings().DEFAULT_instance_dir.iterdir() 

1040 ] 

1041 for configuration_scenario in configuration_scenarios: 

1042 processed_configuration_scenarios.append( 

1043 ( 

1044 ConfigurationOutput( 

1045 configuration_scenario, performance_data, possible_test_sets 

1046 ), 

1047 configuration_scenario, 

1048 ) 

1049 ) 

1050 for selection_scenario in selection_scenarios: 

1051 processed_selection_scenarios.append( 

1052 (SelectionOutput(selection_scenario), selection_scenario) 

1053 ) 

1054 if ( 

1055 not configuration_scenarios 

1056 and not selection_scenarios 

1057 and not parallel_portfolio_scenarios 

1058 ): 

1059 print("No scenarios found. Exiting.") 

1060 sys.exit(-1) 

1061 raw_output = gv.settings().DEFAULT_output_analysis / "JSON" 

1062 if raw_output.exists(): # Clean 

1063 shutil.rmtree(raw_output) 

1064 raw_output.mkdir() 

1065 

1066 # Write JSON 

1067 output_json = {} 

1068 for output, configuration_scenario in processed_configuration_scenarios: 

1069 output_json[configuration_scenario.name] = output.serialise() 

1070 for output, selection_scenario in processed_selection_scenarios: 

1071 output_json[selection_scenario.name] = output.serialise() 

1072 # TODO: We do not have an output object for parallel portfolios 

1073 

1074 raw_output_json = raw_output / "output.json" 

1075 with raw_output_json.open("w") as f: 

1076 json.dump(output_json, f, indent=4) 

1077 

1078 print(f"Machine readable output written to: {raw_output_json}") 

1079 

1080 if args.only_json: # Done 

1081 sys.exit(0) 

1082 

1083 # TODO: Group scenarios based on: 

1084 # - Configuration / Selection / Parallel Portfolio 

1085 # - Training Instance Set / Testing Instance Set 

1086 # - Configurators can be merged as long as we can match their budgets clearly 

1087 report_directory = gv.settings().DEFAULT_output_analysis / "report" 

1088 if report_directory.exists(): # Clean it 

1089 shutil.rmtree(report_directory) 

1090 report_directory.mkdir() 

1091 target_path = report_directory / "report" 

1092 report = pl.document.Document( 

1093 default_filepath=str(target_path), document_options=["british"] 

1094 ) 

1095 bibpath = gv.settings().bibliography_path 

1096 newbibpath = report_directory / "report.bib" 

1097 shutil.copy(bibpath, newbibpath) 

1098 # BUGFIX for unknown package load in PyLatex 

1099 lastpage_package = pl.package.Package("lastpage") 

1100 if lastpage_package in report.packages: 

1101 report.packages.remove(lastpage_package) 

1102 report.packages.append( 

1103 pl.package.Package( 

1104 "geometry", 

1105 options=[ 

1106 "verbose", 

1107 "tmargin=3.5cm", 

1108 "bmargin=3.5cm", 

1109 "lmargin=3cm", 

1110 "rmargin=3cm", 

1111 ], 

1112 ) 

1113 ) 

1114 # Unsafe command for \emph{Sparkle} 

1115 report.preamble.extend( 

1116 [ 

1117 pl.UnsafeCommand("title", r"\emph{Sparkle} Algorithm Portfolio report"), 

1118 pl.UnsafeCommand( 

1119 "author", 

1120 r"Generated by \emph{Sparkle} " 

1121 f"(version: {__sparkle_version__})", 

1122 ), 

1123 ] 

1124 ) 

1125 report.append(pl.Command("maketitle")) 

1126 report.append(pl.Section("Introduction")) 

1127 # TODO: A quick overview to the introduction on whats considered in the report 

1128 # regarding Solvers, Instance Sets and Feature Extractors 

1129 solver_tool = ( 

1130 "RunSolver" if gv.settings().DEFAULT_runsolver_exec.exists() else "PyRunSolver" 

1131 ) 

1132 report.append( 

1133 pl.UnsafeCommand( 

1134 r"emph{Sparkle}~\cite{Hoos15} is a multi-agent problem-solving platform based on" 

1135 r" Programming by Optimisation (PbO)~\cite{Hoos12}, and would provide a number " 

1136 "of effective algorithm optimisation techniques (such as automated algorithm " 

1137 "configuration, portfolio-based algorithm selection, etc.) to accelerate the " 

1138 f"existing solvers. All computation and memory measurements are done by {solver_tool}." 

1139 ) 

1140 ) 

1141 

1142 for scenario_output, scenario in processed_configuration_scenarios: 

1143 generate_configuration_section(report, scenario, scenario_output) 

1144 

1145 for scenario_output, scenario in processed_selection_scenarios: 

1146 generate_selection_section(report, scenario, scenario_output) 

1147 

1148 for parallel_dataframe in parallel_portfolio_scenarios: 

1149 generate_parallel_portfolio_section(report, parallel_dataframe) 

1150 

1151 # Check if user wants to add appendix and 

1152 settings = gv.settings(args) 

1153 if settings.appendices: 

1154 generate_appendix(report, performance_data, feature_data) 

1155 

1156 # Adding bibliography 

1157 report.append(pl.NewPage()) # Ensure it starts on new page 

1158 report.append(pl.Command("bibliographystyle", arguments=["plain"])) 

1159 report.append(pl.Command("bibliography", arguments=[str(newbibpath)])) 

1160 # Generate the report .tex and .pdf 

1161 report.generate_pdf(target_path, clean=False, clean_tex=False, compiler="pdflatex") 

1162 # TODO: This should be done by PyLatex. Generate the bib and regenerate the report 

1163 # Reference for the (terrible) solution: https://tex.stackexchange.com/ 

1164 # questions/63852/question-mark-or-bold-citation-key-instead-of-citation-number 

1165 import subprocess 

1166 

1167 # Run BibTex silently 

1168 subprocess.run( 

1169 ["bibtex", newbibpath.with_suffix("")], 

1170 stdout=subprocess.DEVNULL, 

1171 stderr=subprocess.DEVNULL, 

1172 ) 

1173 report.generate_pdf(target_path, clean=False, clean_tex=False, compiler="pdflatex") 

1174 report.generate_pdf(target_path, clean=False, clean_tex=False, compiler="pdflatex") 

1175 print(f"Report generated at {target_path}.pdf") 

1176 sys.exit(0) 

1177 

1178 

1179if __name__ == "__main__": 

1180 main(sys.argv[1:])