Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:55

0001 #!/usr/bin/env python3
0002 """
0003 Example: run dRICH optimization with AID2E optimizer and scheduler.
0004 Each Ax trial is submitted as one scheduler job calling run_trial(),
0005 which then uses DAGExecutor to run drich_eval.py stages and return
0006 metrics. After each batch of trials is completed, the objectives are returned
0007 to Ax and next batch is suggested.
0008 """
0009 
0010 import argparse
0011 import json
0012 import shlex
0013 
0014 from ax.service.utils.report_utils import exp_to_df
0015 
0016 from aid2e.utilities import (
0017     build_optimizer_from_config,
0018     build_scheduler_from_config,
0019     build_workflow_executor_from_config,
0020 )
0021 from aid2e.utilities.configurations import (
0022     BranchDefinition,
0023     JobDefinition,
0024     StageDefinition,
0025     WorkflowDefinition,
0026 )
0027 from drich_utils import load_drich_config, make_paths
0028 
0029 
0030 # AID2E trial workflow setup
0031 
0032 def load_trial_config(config_path, output_dir):
0033     """Load config and prepare DAGExecutor paths."""
0034 
0035     config_path, cfg, _ = load_drich_config(config_path)
0036     paths = make_paths(output_dir)
0037     return config_path, cfg, paths
0038 
0039 
0040 def build_trial_workflow(cfg, config_path, paths, trial_index):
0041     """Create the one-trial DAG in the stage order from workflow.yml."""
0042 
0043     source_workflow = cfg.workflows.workflows[0]
0044     source_branch = source_workflow.branches[0]
0045     trial_id = str(trial_index)
0046     result_json = paths.results_dir / f"out-{trial_index}.json"
0047     base_payload = {
0048         "trial_index": trial_index,
0049         "output_dir": shlex.quote(str(paths.output_root)),
0050         "config_path": shlex.quote(str(config_path)),
0051         "result_json": str(result_json),
0052     }
0053 
0054     def make_stage(source):
0055         source_job = source.jobs[0]
0056         stage_resources = dict(source.scheduler.parameters) if source.scheduler else {}
0057         jobs = [
0058             JobDefinition(
0059                 name=source_job.name,
0060                 command=source_job.command,
0061                 rule=source_job.rule,
0062                 payload={**source_job.payload, **base_payload},
0063                 resources={**stage_resources, **source_job.resources},
0064                 outputs=source_job.outputs,
0065             )
0066         ]
0067         return StageDefinition(
0068             name=source.name,
0069             jobs=jobs,
0070             job_factory=source.job_factory,
0071             scheduler=source.scheduler,
0072             parallelism=source.parallelism,
0073             outputs=source.outputs,
0074         )
0075 
0076     return WorkflowDefinition(
0077         name=f"drich_trial_{trial_id}",
0078         stack_type=source_workflow.stack_type,
0079         branches=[
0080             BranchDefinition(
0081                 name=source_branch.name,
0082                 stages=[make_stage(stage) for stage in source_branch.stages],
0083                 scheduler=source_branch.scheduler,
0084             )
0085         ],
0086         scheduler=source_workflow.scheduler,
0087     )
0088 
0089 
0090 def run_trial(config_path, output_dir, trial_index, design_point):
0091     """Execute one trial workflow and return its objective metrics."""
0092 
0093     config_path, cfg, paths = load_trial_config(config_path, output_dir)
0094 
0095     workflow = build_trial_workflow(cfg, config_path, paths, trial_index)
0096     executor = build_workflow_executor_from_config(
0097         workflow,
0098         problem_cfg=cfg.problem,
0099         scheduler_cfg=cfg.scheduler,
0100         base_output_dir=str(paths.output_root),
0101         log_level="WARNING",
0102     )
0103     executor.execute(design_point)
0104 
0105     result_path = paths.results_dir / f"out-{trial_index}.json"
0106     if not result_path.exists():
0107         raise RuntimeError(f"DAGExecutor did not write objective result file: {result_path}")
0108     return json.loads(result_path.read_text())
0109 
0110 
0111 # AID2E optimizer setup
0112 
0113 def configure_optimizer(run_config, max_trials=None):
0114     """Build the AID2E optimizer."""
0115 
0116     cfg = run_config["cfg"]
0117 
0118     if max_trials is not None:
0119         cfg.optimizer.parameters = {**cfg.optimizer.parameters, "n_iterations": max_trials}
0120 
0121     optimizer = build_optimizer_from_config(cfg.problem, cfg.optimizer)
0122     ax_config = optimizer.config
0123     sobol_trials = min(ax_config.n_initial_samples, ax_config.n_iterations)
0124 
0125     optimizer_state = {
0126         "objectives": optimizer.objective_names,
0127         "ax_config": ax_config,
0128         "optimizer": optimizer,
0129         "sobol_trials": sobol_trials,
0130     }
0131     return optimizer_state
0132 
0133 
0134 def run_trial_batch(assignments, phase_name, batch_id, run_config, optimizer_state, trial_state):
0135     """Submit trial workflow jobs and collect completed metrics."""
0136 
0137     output_dir = run_config["output_dir"]
0138     active_trials = {trial_index: design_point for trial_index, design_point in assignments}
0139 
0140     job_definitions = []
0141     for trial_index, design_point in assignments:
0142         job_definitions.append(
0143             {
0144                 "job_id": str(trial_index),
0145                 "name": f"trial_{trial_index}",
0146                 "function": run_trial,
0147                 "params": {
0148                     "config_path": str(run_config["config_path"]),
0149                     "output_dir": str(output_dir),
0150                     "trial_index": trial_index,
0151                     "design_point": design_point,
0152                 },
0153             }
0154         )
0155 
0156     stage_name = f"{phase_name.lower()}_batch_{batch_id}_trials"
0157 
0158     # The outer scheduler stage runs one trial workflow per Ax candidate.
0159     scheduler = build_scheduler_from_config(run_config["cfg"].scheduler)
0160     parallelism_policy = {"max_concurrent": optimizer_state["ax_config"].batch_size}
0161     try:
0162         result = scheduler.run_stage(
0163             stage_name,
0164             job_definitions,
0165             parallelism_policy=parallelism_policy,
0166             working_dir=str(output_dir),
0167         )
0168     finally:
0169         scheduler.shutdown()
0170 
0171     # Failed trial jobs are marked failed in Ax; successful trials continue.
0172     if not result.success:
0173         failed_trials = {
0174             int(status.job_id)
0175             for status in result.job_statuses
0176             if status.status != "completed" and str(status.job_id).isdigit()
0177         }
0178         if not failed_trials:
0179             raise RuntimeError(result.error_message or f"stage failed: {stage_name}")
0180         for trial_index in sorted(failed_trials & set(active_trials)):
0181             optimizer_state["optimizer"].set_trial_status(
0182                 trial_index=trial_index,
0183                 status="failed",
0184                 parameters=active_trials[trial_index],
0185                 metadata={"reason": result.error_message},
0186             )
0187             trial_state["failed_by_trial"].add(trial_index)
0188             del active_trials[trial_index]
0189 
0190     completed_statuses = {
0191         int(status.job_id): status
0192         for status in result.job_statuses
0193         if status.status == "completed" and str(status.job_id).isdigit()
0194     }
0195 
0196     # run_trial() returns metrics and also writes an objective JSON artifact.
0197     batch_results = {}
0198     for trial_index, design_point in active_trials.items():
0199         outputs = completed_statuses[trial_index].outputs or {}
0200         batch_results[trial_index] = (design_point, outputs["result"])
0201     return batch_results
0202 
0203 
0204 # Run Optimization Loop
0205 
0206 def run_optimization(run_config, optimizer_state):
0207     trial_state = {"errors_by_trial": {}, "failed_by_trial": set()}
0208     trial_index = 0
0209 
0210     # Sobol and Bayesian counts
0211     phases = [
0212         ("Sobol", optimizer_state["sobol_trials"]),
0213         ("Bayes", optimizer_state["ax_config"].n_iterations - optimizer_state["sobol_trials"]),
0214     ]
0215 
0216     for phase_name, n_trials in phases:
0217         if n_trials <= 0:
0218             continue
0219         batch_id, completed = 0, 0
0220 
0221         while completed < n_trials:
0222             batch_id += 1
0223             n_new = min(optimizer_state["ax_config"].batch_size, n_trials - completed)
0224 
0225             # Ask Ax/AID2E for the next batch of design points.
0226             design_points = optimizer_state["optimizer"].suggest_candidates(n_candidates=n_new)
0227             assignments = list(zip(range(trial_index, trial_index + n_new), design_points))
0228             trial_index += len(assignments)
0229 
0230             # Run each design point as one trial workflow job.
0231             batch_results = run_trial_batch(assignments, phase_name, batch_id, run_config, optimizer_state, trial_state)
0232             completed += len(assignments) - len(batch_results)
0233 
0234             # Scheduler-level failures are skipped unless the configured tolerance is exceeded.
0235             failed = len(trial_state["failed_by_trial"])
0236             if failed > run_config["max_failed_trials"]:
0237                 raise RuntimeError(f"Too many failed trials: {failed} failed, max_failed_trials={run_config['max_failed_trials']}")
0238 
0239             # Update Ax with the objective metrics declared in workflow.yml.
0240             for idx, (design_point, raw_metrics) in batch_results.items():
0241                 metrics = {name: float(raw_metrics[name]) for name in optimizer_state["objectives"]}
0242                 trial_state["errors_by_trial"][idx] = {
0243                     name: float(raw_metrics[f"{name}_sem"]) for name in optimizer_state["objectives"]
0244                 }
0245                 optimizer_state["optimizer"].update_with_results(
0246                     idx, design_point, metrics
0247                 )
0248                 completed += 1
0249 
0250             # Save after each batch for long Slurm runs.
0251             if batch_results:
0252                 df = exp_to_df(optimizer_state["optimizer"].experiment)
0253                 for name in optimizer_state["objectives"]:
0254                     df[f"{name}_sem"] = df["trial_index"].map(
0255                         lambda idx: trial_state["errors_by_trial"].get(int(idx), {}).get(name)
0256                     )
0257                 df.sort_values("trial_index").to_csv(run_config["results_csv"], index=False)
0258 
0259                 pareto_trials = []
0260                 for trial in optimizer_state["optimizer"].get_pareto_front():
0261                     pareto_trials.append(
0262                         {
0263                             "trial_index": trial.index,
0264                             "parameters": trial.parameters,
0265                             "metrics": trial.metrics,
0266                         }
0267                     )
0268                 run_config["pareto_front_json"].write_text(json.dumps(pareto_trials, indent=2))
0269                 optimizer_state["optimizer"].save_optimization_results(run_config["optimization_results_json"])
0270             elif trial_state["failed_by_trial"]:
0271                 optimizer_state["optimizer"].save_optimization_results(run_config["optimization_results_json"])
0272 
0273 
0274 # Main
0275 
0276 def main(argv=None) -> int:
0277     parser = argparse.ArgumentParser(description="Run example optimization from workflow.yml")
0278     parser.add_argument("--config", default="examples/drich/workflow.yml", help="Path to workflow YAML")
0279     parser.add_argument("--max-trials", type=int, default=None, help="Optional cap on total trials")
0280     args = parser.parse_args(argv)
0281 
0282     config_path, cfg, eval_config = load_drich_config(args.config)
0283     paths = make_paths(cfg.problem.output_location)
0284     paths.output_root.mkdir(parents=True, exist_ok=True)
0285     run_config = {
0286         "config_path": config_path,
0287         "cfg": cfg,
0288         "output_dir": paths.output_root,
0289         "max_failed_trials": int(eval_config.get("max_failed_trials", 0)),
0290         "optimization_results_json": paths.output_root / "drich_optimization_results.json",
0291         "pareto_front_json": paths.output_root / f"{eval_config['output_name']}_pareto_front.json",
0292         "results_csv": paths.output_root / f"{eval_config['output_name']}.csv",
0293     }
0294     run_optimization(run_config, configure_optimizer(run_config, max_trials=args.max_trials))
0295     return 0
0296 
0297 
0298 if __name__ == "__main__":
0299     raise SystemExit(main())