Coverage for src/sparkle/platform/output/selection_output.py: 73%

75 statements  

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

1"""Sparkle class to organise configuration output.""" 

2 

3from __future__ import annotations 

4import operator 

5import json 

6from pathlib import Path 

7 

8from sparkle.selector import SelectionScenario 

9from sparkle.structures import PerformanceDataFrame 

10from sparkle.platform.output.structures import ( 

11 SelectionPerformance, 

12 SelectionSolverData, 

13) 

14 

15 

16def compute_selector_marginal_contribution( 

17 selection_scenario: SelectionScenario, 

18) -> list[tuple[str, float]]: 

19 """Compute the marginal contributions of solvers in the selector. 

20 

21 Args: 

22 performance_data: Performance data object 

23 feature_data_csv_path: Path to the CSV file with the feature data. 

24 selection_scenario: The selector scenario for which to compute 

25 marginal contribution. 

26 objective: Objective to compute the marginal contribution for. 

27 

28 Returns: 

29 A list of 4-tuples where every 4-tuple is of the form 

30 (solver_name, config_id, marginal contribution, best_performance). 

31 """ 

32 selector_performance = selection_scenario.objective.instance_aggregator( 

33 selection_scenario.selector_performance_data.get_value( 

34 SelectionScenario.__selector_solver_name__, 

35 instance_pair=selection_scenario.training_instances, 

36 objective=selection_scenario.objective.name, 

37 ) 

38 ) 

39 rank_list = [] 

40 compare = operator.lt if selection_scenario.objective.minimise else operator.gt 

41 # Compute contribution per solver 

42 for ablation_scenario in selection_scenario.ablation_scenarios: 

43 # Hacky way of getting the needed data on the ablation 

44 _, solver_name, config = ablation_scenario.directory.name.split("_", maxsplit=2) 

45 # Hacky way of reconstructing the solver id in the PDF 

46 solver = f"Solvers/{solver_name}" 

47 ablated_selector_performance = ablation_scenario.objective.instance_aggregator( 

48 ablation_scenario.selector_performance_data.get_value( 

49 SelectionScenario.__selector_solver_name__, 

50 instance_pair=ablation_scenario.training_instances, 

51 objective=ablation_scenario.objective.name, 

52 ) 

53 ) 

54 

55 # 1. If the performance remains equal, this solver did not contribute 

56 # 2. If there is a performance decay without this solver, it does contribute 

57 # 3. If there is a performance improvement, we have a bad portfolio selector 

58 if ablated_selector_performance == selector_performance: 

59 marginal_contribution = 0.0 

60 elif not compare(ablated_selector_performance, selector_performance): 

61 # The performance decreases, we have a contributing solver 

62 marginal_contribution = ablated_selector_performance / selector_performance 

63 else: 

64 print( 

65 "****** WARNING DUBIOUS SELECTOR/SOLVER: " 

66 f"The omission of solver {solver_name} ({config}) yields an " 

67 "improvement. The selector improves better without this solver. " 

68 "It may be usefull to construct a portfolio without this solver." 

69 ) 

70 marginal_contribution = 0.0 

71 

72 rank_list.append( 

73 (solver, config, marginal_contribution, ablated_selector_performance) 

74 ) 

75 

76 rank_list.sort(key=lambda contribution: contribution[2], reverse=True) 

77 return rank_list 

78 

79 

80class SelectionOutput: 

81 """Class that collects selection data and outputs it a JSON format.""" 

82 

83 def __init__( 

84 self: SelectionOutput, 

85 selection_scenario: SelectionScenario, 

86 ) -> None: 

87 """Initialize SelectionOutput class. 

88 

89 Args: 

90 selection_scenario: Path to selection output directory 

91 performance_data: The performance data used for the selector 

92 """ 

93 self.training_instances = selection_scenario.training_instances 

94 training_instance_sets = selection_scenario.training_instance_sets 

95 self.training_instance_sets = [ 

96 ( 

97 instance_set, 

98 sum(inst_set == instance_set for inst_set, _ in self.training_instances), 

99 ) 

100 for instance_set in training_instance_sets 

101 ] 

102 self.test_instances = selection_scenario.test_instances 

103 test_sets = selection_scenario.test_instance_sets 

104 self.test_sets = [ 

105 ( 

106 instance_set, 

107 sum(inst_set == instance_set for inst_set, _ in self.test_instances), 

108 ) 

109 for instance_set in test_sets 

110 ] 

111 self.cutoff_time = selection_scenario.solver_cutoff 

112 self.objective = selection_scenario.objective 

113 

114 solver_performance_data = selection_scenario.selector_performance_data.clone() 

115 solver_performance_data.remove_solver(SelectionScenario.__selector_solver_name__) 

116 

117 self.solver_performance_ranking = solver_performance_data.get_solver_ranking( 

118 instance_pairs=self.training_instances, objective=self.objective 

119 ) 

120 

121 self.solver_data = self.get_solver_data(solver_performance_data) 

122 self.solvers = {} 

123 for solver_conf in selection_scenario.performance_data.columns: 

124 solver, conf = solver_conf.split("_", maxsplit=1) 

125 if solver not in self.solvers: 

126 self.solvers[solver] = [] 

127 self.solvers[solver].append(conf) 

128 

129 self.sbs_performance = solver_performance_data.get_value( 

130 solver=self.solver_performance_ranking[0][0], 

131 configuration=self.solver_performance_ranking[0][1], 

132 instance_pair=self.training_instances, 

133 objective=self.objective.name, 

134 ) 

135 

136 # Collect marginal contribution data 

137 self.marginal_contribution_perfect = ( 

138 solver_performance_data.marginal_contribution( 

139 selection_scenario.objective, 

140 instance_pairs=self.training_instances, 

141 sort=True, 

142 ) 

143 ) 

144 

145 self.marginal_contribution_actual = compute_selector_marginal_contribution( 

146 selection_scenario 

147 ) 

148 # Collect performance data 

149 self.vbs_performance_data = solver_performance_data.best_instance_performance( 

150 instance_pairs=self.training_instances, 

151 objective=selection_scenario.objective, 

152 ) 

153 self.vbs_performance = selection_scenario.objective.instance_aggregator( 

154 self.vbs_performance_data 

155 ) 

156 

157 self.test_set_performance = {} if self.test_sets else None 

158 for test_set, _ in self.test_sets: 

159 test_set_instances = [ 

160 instance for instance in self.test_instances if instance[0] == test_set 

161 ] 

162 test_perf = selection_scenario.selector_performance_data.best_performance( 

163 exclude_solvers=[ 

164 solver 

165 for solver in selection_scenario.selector_performance_data.solvers 

166 if solver != SelectionScenario.__selector_solver_name__ 

167 ], 

168 instance_pairs=test_set_instances, 

169 objective=selection_scenario.objective, 

170 ) 

171 self.test_set_performance[test_set] = test_perf 

172 self.actual_performance_data = ( 

173 selection_scenario.selector_performance_data.get_value( 

174 solver=SelectionScenario.__selector_solver_name__, 

175 instance_pair=self.training_instances, 

176 objective=self.objective.name, 

177 ) 

178 ) 

179 self.actual_performance = self.objective.instance_aggregator( 

180 self.actual_performance_data 

181 ) 

182 

183 def get_solver_data( 

184 self: SelectionOutput, train_data: PerformanceDataFrame 

185 ) -> SelectionSolverData: 

186 """Initalise SelectionSolverData object.""" 

187 num_solvers = train_data.num_solvers 

188 return SelectionSolverData(self.solver_performance_ranking, num_solvers) 

189 

190 def serialise_solvers(self: SelectionOutput, sd: SelectionSolverData) -> dict: 

191 """Transform SelectionSolverData to dictionary format.""" 

192 return { 

193 "number_of_solvers": sd.num_solvers, 

194 "single_best_solver": sd.single_best_solver, 

195 "solver_ranking": [ 

196 {"solver_name": solver[0], "performance": solver[1]} 

197 for solver in sd.solver_performance_ranking 

198 ], 

199 } 

200 

201 def serialise_performance(self: SelectionOutput, sp: SelectionPerformance) -> dict: 

202 """Transform SelectionPerformance to dictionary format.""" 

203 return { 

204 "vbs_performance": sp.vbs_performance, 

205 "actual_performance": sp.actual_performance, 

206 "objective": self.objective.name, 

207 "metric": sp.metric, 

208 } 

209 

210 def serialise_instances( 

211 self: SelectionOutput, instance_pairs: list[tuple[str, str]] 

212 ) -> dict: 

213 """Transform Instances to dictionary format.""" 

214 instance_sets = dict.fromkeys(instance_set for instance_set, _ in instance_pairs) 

215 return { 

216 "number_of_instance_sets": len(instance_sets), 

217 "instance_sets": [ 

218 { 

219 "name": instance_set, 

220 "number_of_instances": len( 

221 [ 

222 inst_set 

223 for inst_set, _ in instance_pairs 

224 if inst_set == instance_set 

225 ] 

226 ), 

227 } 

228 for instance_set in instance_sets 

229 ], 

230 } 

231 

232 def serialise_marginal_contribution(self: SelectionOutput) -> dict: 

233 """Transform performance ranking to dictionary format.""" 

234 return { 

235 "marginal_contribution_actual": [ 

236 { 

237 "solver_name": ranking[0], 

238 "marginal_contribution": ranking[1], 

239 "best_performance": ranking[2], 

240 } 

241 for ranking in self.marginal_contribution_actual 

242 ], 

243 "marginal_contribution_perfect": [ 

244 { 

245 "solver_name": ranking[0], 

246 "marginal_contribution": ranking[1], 

247 "best_performance": ranking[2], 

248 } 

249 for ranking in self.marginal_contribution_perfect 

250 ], 

251 } 

252 

253 def serialise(self: SelectionOutput) -> dict: 

254 """Serialise the selection output.""" 

255 test_data = ( 

256 self.serialise_instances(self.test_instances) 

257 if self.test_instances 

258 else None 

259 ) 

260 return { 

261 "solvers": self.serialise_solvers(self.solver_data), 

262 "training_instances": self.serialise_instances(self.training_instances), 

263 "test_instances": test_data, 

264 "settings": {"cutoff_time": self.cutoff_time}, 

265 "marginal_contribution": self.serialise_marginal_contribution(), 

266 } 

267 

268 def write_output(self: SelectionOutput, output: Path) -> None: 

269 """Write data into a JSON file.""" 

270 output = output / "configuration.json" if output.is_dir() else output 

271 with output.open("w") as f: 

272 json.dump(self.serialise(), f, indent=4)