Coverage for src/sparkle/selector/selector.py: 87%

160 statements  

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

1"""File to handle a Selector for selecting Solvers.""" 

2 

3from __future__ import annotations 

4import random 

5from pathlib import Path 

6 

7 

8from sklearn.base import ClassifierMixin, RegressorMixin 

9from asf.cli import cli_train as asf_cli 

10from asf.predictors import AbstractPredictor 

11from asf.selectors.abstract_model_based_selector import AbstractModelBasedSelector 

12 

13 

14import runrunner as rrr 

15from runrunner import Runner, Run 

16import pandas as pd 

17 

18from sparkle.types import SparkleObjective, resolve_objective 

19from sparkle.structures import FeatureDataFrame, PerformanceDataFrame 

20from sparkle.instance import InstanceSet 

21 

22 

23class Selector: 

24 """The Selector class for handling Algorithm Selection.""" 

25 

26 selector_cli = Path(__file__).parent / "selector_cli.py" 

27 

28 def __init__( 

29 self: Selector, 

30 selector_class: AbstractModelBasedSelector, 

31 model_class: AbstractPredictor | ClassifierMixin | RegressorMixin, 

32 ) -> None: 

33 """Initialize the Selector object. 

34 

35 Args: 

36 selector_class: The (name of) Selector class to construct. 

37 model_class: The (name of) model class the selector will use. 

38 """ 

39 if isinstance(selector_class, str): # Resolve class name 

40 from asf import selectors 

41 

42 selector_class = getattr(selectors, selector_class) 

43 if isinstance(model_class, str): # Resolve class name 

44 from sklearn import ensemble 

45 

46 model_class = getattr(ensemble, model_class) 

47 

48 self.selector_class = selector_class 

49 self.model_class = model_class 

50 

51 @property 

52 def name(self: Selector) -> str: 

53 """Return the name of the selector.""" 

54 return f"{self.selector_class.__name__}_{self.model_class.__name__}" 

55 

56 def construct( 

57 self: Selector, 

58 selection_scenario: SelectionScenario, 

59 run_on: Runner = Runner.SLURM, 

60 job_name: str = None, 

61 sbatch_options: list[str] = None, 

62 slurm_prepend: str | list[str] | Path = None, 

63 base_dir: Path = Path(), 

64 ) -> Run: 

65 """Construct the Selector. 

66 

67 Args: 

68 selection_scenario: The scenario to construct the Selector for. 

69 run_on: Which runner to use. Defaults to slurm. 

70 job_name: Name to give the construction job when submitting. 

71 sbatch_options: Additional options to pass to sbatch. 

72 slurm_prepend: Slurm script to prepend to the sbatch 

73 base_dir: The base directory to run the Selector in. 

74 

75 Returns: 

76 The construction Run 

77 """ 

78 selection_scenario.create_scenario() 

79 selector = self.selector_class( 

80 model_class=self.model_class, 

81 budget=selection_scenario.solver_cutoff, 

82 maximize=not selection_scenario.objective.minimise, 

83 ) 

84 cmd = asf_cli.build_cli_command( 

85 selector, 

86 selection_scenario.feature_target_path, 

87 selection_scenario.performance_target_path, 

88 selection_scenario.selector_file_path, 

89 ) 

90 cmd = [" ".join([str(c) for c in cmd])] 

91 

92 job_name = job_name or f"Selector Construction {selection_scenario.name}" 

93 construct = rrr.add_to_queue( 

94 runner=run_on, 

95 cmd=cmd, 

96 name=job_name, 

97 base_dir=base_dir, 

98 sbatch_options=sbatch_options, 

99 prepend=slurm_prepend, 

100 ) 

101 

102 if run_on == Runner.LOCAL: 

103 construct.wait() 

104 if not selection_scenario.selector_file_path.is_file(): 

105 print(f"Selector construction of {self.name} failed!") 

106 return construct 

107 

108 def run( 

109 self: Selector, 

110 selector_path: Path, 

111 instance_set: str, 

112 instance_name: str, 

113 feature_data: FeatureDataFrame, 

114 ) -> list: 

115 """Run the Selector, returning the prediction schedule upon success. 

116 

117 Args: 

118 selector_path: The path to the selector to run. 

119 instance_set: The name of the set the instance belongs to. Required because 

120 the feature data is keyed on the (set, instance) pair, so a bare instance 

121 name (or file path) could resolve to the wrong set. 

122 instance_name: The name of the instance to predict for. 

123 feature_data: The instance feature data to use. 

124 """ 

125 instance_features = feature_data.get_instance( 

126 instance_set, instance_name, as_dataframe=True 

127 ) 

128 # ASF was trained on plain instance names, drop InstanceSet level 

129 instance_features = instance_features.droplevel( 

130 FeatureDataFrame.instance_set_index_dim 

131 ) 

132 selector = self.selector_class.load(selector_path) 

133 schedule = selector.predict(instance_features) 

134 if schedule is None: 

135 print(f"ERROR: Selector {self.name} failed predict schedule!") 

136 return None 

137 # ASF presents result as schedule per instance, we only use one in this setting 

138 schedule = schedule[instance_name] 

139 for index, (solver, time) in enumerate(schedule): 

140 # Split solver name back into solver and config id 

141 # NOTE: There is an issue with this incase the Solver name has an "_" in its name... We need to change the delimiter to different character(s) 

142 solver_name, conf_index = solver.split("_", maxsplit=1) 

143 schedule[index] = (solver_name, conf_index, time) 

144 return schedule 

145 

146 def run_cli( 

147 self: Selector, 

148 scenario_path: Path, 

149 instance_set: InstanceSet | list[Path], 

150 feature_data: Path, 

151 run_on: Runner = Runner.LOCAL, 

152 sbatch_options: list[str] = None, 

153 slurm_prepend: str | list[str] | Path = None, 

154 job_name: str = None, 

155 dependencies: list[Run] = None, 

156 log_dir: Path = None, 

157 ) -> Run: 

158 """Run the Selector CLI and write result to the Scenario PerformanceDataFrame. 

159 

160 Args: 

161 scenario_path: The path to the scenario with the Selector to run. 

162 instance_set: The instance set to run the Selector on. 

163 feature_data: The instance feature data to use. 

164 run_on: Which runner to use. Defaults to slurm. 

165 sbatch_options: Additional options to pass to sbatch. 

166 slurm_prepend: Slurm script to prepend to the sbatch 

167 job_name: Name to give the Slurm job when submitting. 

168 dependencies: List of dependencies to add to the job. 

169 log_dir: The directory to write logs to. 

170 

171 Returns: 

172 The Run object. 

173 """ 

174 # NOTE: The selector object and the scenario selector could differ which could 

175 # cause unintended behaviour (e.g. running a different selector than desired) 

176 instances = ( 

177 instance_set if isinstance(instance_set, list) else instance_set.instances 

178 ) 

179 commands = [ 

180 f"python3 {Selector.selector_cli} " 

181 f"--selector-scenario {scenario_path} " 

182 f"--instance {instance} " 

183 f"--feature-data {feature_data} " 

184 f"--log-dir {log_dir} " 

185 f"--seed {random.randint(0, 2**32 - 1)}" 

186 for instance in instances 

187 ] 

188 

189 job_name = ( 

190 f"Run Selector {self.name} on {len(instances)} instances" 

191 if not job_name 

192 else job_name 

193 ) 

194 import subprocess 

195 

196 r = rrr.add_to_queue( 

197 cmd=commands, 

198 name=job_name, 

199 stdout=None if run_on == Runner.LOCAL else subprocess.PIPE, # Print 

200 stderr=None if run_on == Runner.LOCAL else subprocess.PIPE, # Print 

201 base_dir=log_dir, 

202 runner=run_on, 

203 sbatch_options=sbatch_options, 

204 prepend=slurm_prepend, 

205 dependencies=dependencies, 

206 ) 

207 if run_on == Runner.LOCAL: 

208 r.wait() 

209 return r 

210 

211 

212class SelectionScenario: 

213 """A scenario for a Selector.""" 

214 

215 __selector_solver_name__ = "portfolio_selector" 

216 

217 def __init__( 

218 self: SelectionScenario, 

219 parent_directory: Path, 

220 selector: Selector, 

221 objective: SparkleObjective, 

222 performance_data: PerformanceDataFrame | Path, 

223 feature_data: FeatureDataFrame | Path, 

224 feature_extractors: list[str] = None, 

225 solver_cutoff: int | float = None, 

226 extractor_cutoff: int | float = None, 

227 ablate: bool = False, 

228 subdir_path: Path = None, 

229 ) -> None: 

230 """Initialize a scenario for a selector.""" 

231 self.selector: Selector = selector 

232 self.objective: SparkleObjective = objective 

233 self.solver_cutoff: float = solver_cutoff 

234 self.extractor_cutoff: float = extractor_cutoff 

235 if subdir_path is not None: 

236 self.directory = parent_directory / subdir_path 

237 elif isinstance(performance_data, PerformanceDataFrame): 

238 self.directory: Path = ( 

239 parent_directory 

240 / selector.name 

241 / "_".join([Path(s).name for s in performance_data.solvers]) 

242 ) 

243 else: 

244 self.directory = performance_data.parent 

245 self.name = f"{selector.name} on {self.directory.name}" 

246 self.selector_file_path: Path = self.directory / "portfolio_selector" 

247 self.scenario_file: Path = self.directory / "scenario.txt" 

248 self.selector_performance_path: Path = ( 

249 self.directory / "selector_performance.csv" 

250 ) 

251 if self.selector_performance_path.exists(): 

252 self.selector_performance_data = PerformanceDataFrame( 

253 self.selector_performance_path 

254 ) 

255 else: # Create new performance data frame for selector, write to file later 

256 self.selector_performance_data = performance_data.clone() 

257 self.selector_performance_data.add_solver( 

258 SelectionScenario.__selector_solver_name__ 

259 ) 

260 

261 if isinstance(performance_data, PerformanceDataFrame): # Convert 

262 # Store (InstanceSet, Instance) pairs before collapsing index for ASF 

263 self.training_instance_pairs: list[tuple[str, str]] = list( 

264 dict.fromkeys( 

265 zip( 

266 performance_data.index.get_level_values( 

267 PerformanceDataFrame.index_instance_set 

268 ), 

269 performance_data.index.get_level_values( 

270 PerformanceDataFrame.index_instance 

271 ), 

272 ) 

273 ) 

274 ) 

275 # Convert the dataframes to Selector Format 

276 new_column_names: list[str] = [] 

277 for solver, config_id, _ in performance_data.columns: 

278 if f"{solver}_{config_id}" not in new_column_names: 

279 new_column_names.append(f"{solver}_{config_id}") 

280 self.performance_data = performance_data.drop( 

281 [PerformanceDataFrame.column_seed], axis=1, level=2 

282 ) 

283 self.performance_data = self.performance_data.droplevel( 

284 [ 

285 PerformanceDataFrame.column_configuration, 

286 PerformanceDataFrame.column_meta, 

287 ], 

288 axis=1, 

289 ) 

290 self.performance_data = self.performance_data.droplevel( 

291 PerformanceDataFrame.index_objective, axis=0 

292 ) 

293 self.performance_data.columns = new_column_names 

294 # Requires instances as index for both, columns as features / solvers 

295 # Drop InstanceSet and Run levels so ASF sees plain instance names 

296 self.performance_data.index = self.performance_data.index.droplevel( 

297 [PerformanceDataFrame.index_instance_set, PerformanceDataFrame.index_run] 

298 ) 

299 # Enforce data type to be numeric 

300 self.performance_data = self.performance_data.astype(float) 

301 self.performance_target_path = self.directory / "performance_data.csv" 

302 else: # Read from Path 

303 self.performance_data: pd.DataFrame = pd.read_csv( 

304 performance_data, index_col=0 

305 ) 

306 self.performance_target_path: Path = performance_data 

307 # The flat performance CSV is indexed by instance name only; recover the 

308 # (set, instance) training pairs by matching those names against the selector 

309 # performance data, which keeps the full pair index. Without this, a scenario 

310 # loaded from file would report zero training instances. 

311 training_names = set(self.performance_data.index) 

312 self.training_instance_pairs: list[tuple[str, str]] = [ 

313 (instance_set, instance_name) 

314 for instance_set, instance_name in self.selector_performance_data.instance_pairs 

315 if instance_name in training_names 

316 ] 

317 

318 if isinstance(feature_data, FeatureDataFrame): # Convert 

319 self.feature_extractors = feature_data.extractors 

320 # Features requires instances as index, columns as feature names 

321 feature_target = feature_data.copy() 

322 feature_target.columns = feature_target.columns.map( 

323 "_".join 

324 ) # Reduce Column Multi Index to single 

325 # Drop InstanceSet level so ASF sees plain instance names 

326 feature_target.index = feature_target.index.droplevel( 

327 FeatureDataFrame.instance_set_index_dim 

328 ) 

329 # ASF -> feature columns, instance rows 

330 self.feature_data: pd.DataFrame = feature_target.astype(float) 

331 self.feature_target_path: Path = self.directory / "feature_data.csv" 

332 else: # Read from Path 

333 self.feature_extractors = feature_extractors 

334 self.feature_data: pd.DataFrame = pd.read_csv(feature_data) 

335 self.feature_target_path: Path = feature_data 

336 

337 self.ablation_scenarios: list[SelectionScenario] = [] 

338 if ablate and len(self.performance_data.columns) > 2: 

339 for solver in self.performance_data.columns: 

340 solver_key, conf_id = solver.split("_", maxsplit=1) 

341 ablate_subdir = Path(f"ablated_{Path(solver).name}") 

342 ablated_directory = self.directory / ablate_subdir 

343 if (ablated_directory / "performance_data.csv").exists(): 

344 ablated_pd = ablated_directory / "performance_data.csv" 

345 elif isinstance(performance_data, PerformanceDataFrame): 

346 ablated_pd = performance_data.clone() 

347 ablated_pd.remove_configuration(solver_key, conf_id) 

348 else: # Note we could do this but it would be hacky? 

349 raise ValueError( 

350 "Cannot ablate scenario after loading from file! " 

351 "Requires original PerformanceDataFrame." 

352 ) 

353 

354 self.ablation_scenarios.append( 

355 SelectionScenario( 

356 parent_directory=self.directory, 

357 selector=selector, 

358 objective=objective, 

359 performance_data=ablated_pd, 

360 feature_data=feature_data, 

361 solver_cutoff=solver_cutoff, 

362 ablate=False, # If we set to true here, recursion would happen 

363 subdir_path=ablate_subdir, 

364 ) 

365 ) 

366 

367 @property 

368 def training_instances(self: SelectionScenario) -> list[tuple[str, str]]: 

369 """Get the training instances as (set_name, instance_name) pairs.""" 

370 return self.training_instance_pairs 

371 

372 @property 

373 def test_instances(self: SelectionScenario) -> list[tuple[str, str]]: 

374 """Get the test instances as (set_name, instance_name) pairs.""" 

375 training_set = set(self.training_instances) 

376 return [ 

377 instance_pair 

378 for instance_pair in self.selector_performance_data.instance_pairs 

379 if instance_pair not in training_set 

380 ] 

381 

382 @property 

383 def training_instance_sets(self: SelectionScenario) -> list[str]: 

384 """Get the training instance sets.""" 

385 return list( 

386 dict.fromkeys(instance_set for instance_set, _ in self.training_instances) 

387 ) 

388 

389 @property 

390 def test_instance_sets(self: SelectionScenario) -> list[str]: 

391 """Get the test instance sets.""" 

392 return list( 

393 dict.fromkeys(instance_set for instance_set, _ in self.test_instances) 

394 ) 

395 

396 @property 

397 def instance_sets(self: SelectionScenario) -> list[str]: 

398 """Get all the instance sets used in this scenario.""" 

399 return list( 

400 dict.fromkeys( 

401 instance_set 

402 for instance_set, _ in self.selector_performance_data.instance_pairs 

403 ) 

404 ) 

405 

406 @property 

407 def solvers(self: SelectionScenario) -> list[str]: 

408 """Get the solvers used for the selector.""" 

409 return self.performance_data.columns.to_list() 

410 

411 def create_scenario(self: SelectionScenario) -> None: 

412 """Prepare the scenario directories.""" 

413 self.directory.mkdir(parents=True, exist_ok=True) 

414 self.performance_data.to_csv(self.performance_target_path) 

415 self.feature_data.to_csv(self.feature_target_path) 

416 self.selector_performance_data.save_csv(self.selector_performance_path) 

417 self.create_scenario_file() 

418 

419 def create_scenario_file(self: SelectionScenario) -> None: 

420 """Create the scenario file. 

421 

422 Write the scenario to file. 

423 """ 

424 with self.scenario_file.open("w") as fout: 

425 fout.write(self.serialise()) 

426 

427 def serialise(self: SelectionScenario) -> dict: 

428 """Serialize the scenario.""" 

429 return ( 

430 f"selector: {self.selector.name}\n" 

431 f"solver_cutoff: {self.solver_cutoff}\n" 

432 f"extractor_cutoff: {self.extractor_cutoff}\n" 

433 f"ablate: {len(self.ablation_scenarios) > 0}\n" 

434 f"objective: {self.objective}\n" 

435 f"selector_performance_data: {self.selector_performance_path}\n" 

436 f"performance_data: {self.performance_target_path}\n" 

437 f"feature_data: {self.feature_target_path}\n" 

438 f"feature_extractors: {','.join(self.feature_extractors)}\n" 

439 ) 

440 

441 @staticmethod 

442 def from_file(scenario_file: Path) -> SelectionScenario: 

443 """Reads scenario file and initalises SelectorScenario.""" 

444 if not scenario_file.is_file() and (scenario_file / "scenario.txt").is_file(): 

445 scenario_file = scenario_file / "scenario.txt" # Resolve from directory 

446 values = { 

447 key: value.strip() 

448 for key, value in [ 

449 line.split(": ", maxsplit=1) for line in scenario_file.open() 

450 ] 

451 } 

452 selector_class, selector_model = values["selector"].split("_", maxsplit=1) 

453 import ast 

454 

455 selector = Selector(selector_class, selector_model) 

456 return SelectionScenario( 

457 parent_directory=scenario_file.parent, 

458 selector=selector, 

459 objective=resolve_objective(values["objective"]), 

460 performance_data=Path(values["performance_data"]), 

461 feature_data=Path(values["feature_data"]), 

462 feature_extractors=values["feature_extractors"].split(","), 

463 solver_cutoff=float(values["solver_cutoff"]), 

464 extractor_cutoff=float(values["extractor_cutoff"]), 

465 ablate=ast.literal_eval(values["ablate"]), 

466 )