refactor: Major reorganization of optimization_engine module structure
BREAKING CHANGE: Module paths have been reorganized for better maintainability. Backwards compatibility aliases with deprecation warnings are provided. New Structure: - core/ - Optimization runners (runner, intelligent_optimizer, etc.) - processors/ - Data processing - surrogates/ - Neural network surrogates - nx/ - NX/Nastran integration (solver, updater, session_manager) - study/ - Study management (creator, wizard, state, reset) - reporting/ - Reports and analysis (visualizer, report_generator) - config/ - Configuration management (manager, builder) - utils/ - Utilities (logger, auto_doc, etc.) - future/ - Research/experimental code Migration: - ~200 import changes across 125 files - All __init__.py files use lazy loading to avoid circular imports - Backwards compatibility layer supports old import paths with warnings - All existing functionality preserved To migrate existing code: OLD: from optimization_engine.nx_solver import NXSolver NEW: from optimization_engine.nx.solver import NXSolver OLD: from optimization_engine.runner import OptimizationRunner NEW: from optimization_engine.core.runner import OptimizationRunner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
64
optimization_engine/core/__init__.py
Normal file
64
optimization_engine/core/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Optimization Engine Core
|
||||
========================
|
||||
|
||||
Main optimization runners and algorithm selection.
|
||||
|
||||
Modules:
|
||||
- runner: Main OptimizationRunner class
|
||||
- base_runner: BaseRunner abstract class
|
||||
- intelligent_optimizer: IMSO adaptive optimizer
|
||||
- method_selector: Algorithm selection logic
|
||||
- strategy_selector: Strategy portfolio management
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid circular dependencies
|
||||
def __getattr__(name):
|
||||
if name == 'OptimizationRunner':
|
||||
from .runner import OptimizationRunner
|
||||
return OptimizationRunner
|
||||
elif name == 'BaseRunner':
|
||||
from .base_runner import BaseRunner
|
||||
return BaseRunner
|
||||
elif name == 'NeuralOptimizationRunner':
|
||||
from .runner_with_neural import NeuralOptimizationRunner
|
||||
return NeuralOptimizationRunner
|
||||
elif name == 'IntelligentOptimizer':
|
||||
from .intelligent_optimizer import IntelligentOptimizer
|
||||
return IntelligentOptimizer
|
||||
elif name == 'IMSO':
|
||||
from .intelligent_optimizer import IMSO
|
||||
return IMSO
|
||||
elif name == 'MethodSelector':
|
||||
from .method_selector import MethodSelector
|
||||
return MethodSelector
|
||||
elif name == 'select_method':
|
||||
from .method_selector import select_method
|
||||
return select_method
|
||||
elif name == 'StrategySelector':
|
||||
from .strategy_selector import StrategySelector
|
||||
return StrategySelector
|
||||
elif name == 'StrategyPortfolio':
|
||||
from .strategy_portfolio import StrategyPortfolio
|
||||
return StrategyPortfolio
|
||||
elif name == 'GradientOptimizer':
|
||||
from .gradient_optimizer import GradientOptimizer
|
||||
return GradientOptimizer
|
||||
elif name == 'LBFGSPolisher':
|
||||
from .gradient_optimizer import LBFGSPolisher
|
||||
return LBFGSPolisher
|
||||
raise AttributeError(f"module 'optimization_engine.core' has no attribute '{name}'")
|
||||
|
||||
__all__ = [
|
||||
'OptimizationRunner',
|
||||
'BaseRunner',
|
||||
'NeuralOptimizationRunner',
|
||||
'IntelligentOptimizer',
|
||||
'IMSO',
|
||||
'MethodSelector',
|
||||
'select_method',
|
||||
'StrategySelector',
|
||||
'StrategyPortfolio',
|
||||
'GradientOptimizer',
|
||||
'LBFGSPolisher',
|
||||
]
|
||||
598
optimization_engine/core/base_runner.py
Normal file
598
optimization_engine/core/base_runner.py
Normal file
@@ -0,0 +1,598 @@
|
||||
"""
|
||||
BaseOptimizationRunner - Unified base class for all optimization studies.
|
||||
|
||||
This module eliminates ~4,200 lines of duplicated code across study run_optimization.py files
|
||||
by providing a config-driven optimization runner.
|
||||
|
||||
Usage:
|
||||
# In study's run_optimization.py (now ~50 lines instead of ~300):
|
||||
from optimization_engine.core.base_runner import ConfigDrivenRunner
|
||||
|
||||
runner = ConfigDrivenRunner(__file__)
|
||||
runner.run()
|
||||
|
||||
Or for custom extraction logic:
|
||||
from optimization_engine.core.base_runner import BaseOptimizationRunner
|
||||
|
||||
class MyStudyRunner(BaseOptimizationRunner):
|
||||
def extract_objectives(self, op2_file, dat_file, design_vars):
|
||||
# Custom extraction logic
|
||||
return {'mass': ..., 'stress': ..., 'stiffness': ...}
|
||||
|
||||
runner = MyStudyRunner(__file__)
|
||||
runner.run()
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, Tuple, List, Callable
|
||||
from abc import ABC, abstractmethod
|
||||
import importlib
|
||||
|
||||
import optuna
|
||||
from optuna.samplers import NSGAIISampler, TPESampler
|
||||
|
||||
|
||||
class ConfigNormalizer:
|
||||
"""
|
||||
Normalizes different config formats to a standard internal format.
|
||||
|
||||
Handles variations like:
|
||||
- 'parameter' vs 'name' for variable names
|
||||
- 'bounds' vs 'min'/'max' for ranges
|
||||
- 'goal' vs 'direction' for objective direction
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def normalize_config(config: Dict) -> Dict:
|
||||
"""Convert any config format to standardized format."""
|
||||
normalized = {
|
||||
'study_name': config.get('study_name', 'unnamed_study'),
|
||||
'description': config.get('description', ''),
|
||||
'design_variables': [],
|
||||
'objectives': [],
|
||||
'constraints': [],
|
||||
'simulation': {},
|
||||
'optimization': {},
|
||||
'neural_acceleration': config.get('neural_acceleration', {}),
|
||||
}
|
||||
|
||||
# Normalize design variables
|
||||
for var in config.get('design_variables', []):
|
||||
normalized['design_variables'].append({
|
||||
'name': var.get('parameter') or var.get('name'),
|
||||
'type': var.get('type', 'continuous'),
|
||||
'min': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[0] if 'bounds' in var else var.get('min', 0),
|
||||
'max': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[1] if 'bounds' in var else var.get('max', 1),
|
||||
'units': var.get('units', ''),
|
||||
'description': var.get('description', ''),
|
||||
})
|
||||
|
||||
# Normalize objectives
|
||||
for obj in config.get('objectives', []):
|
||||
normalized['objectives'].append({
|
||||
'name': obj.get('name'),
|
||||
'direction': obj.get('goal') or obj.get('direction', 'minimize'),
|
||||
'description': obj.get('description', ''),
|
||||
'extraction': obj.get('extraction', {}),
|
||||
})
|
||||
|
||||
# Normalize constraints
|
||||
for con in config.get('constraints', []):
|
||||
normalized['constraints'].append({
|
||||
'name': con.get('name'),
|
||||
'type': con.get('type', 'less_than'),
|
||||
'value': con.get('threshold') or con.get('value', 0),
|
||||
'units': con.get('units', ''),
|
||||
'description': con.get('description', ''),
|
||||
'extraction': con.get('extraction', {}),
|
||||
})
|
||||
|
||||
# Normalize simulation settings
|
||||
sim = config.get('simulation', {})
|
||||
normalized['simulation'] = {
|
||||
'prt_file': sim.get('prt_file') or sim.get('model_file', ''),
|
||||
'sim_file': sim.get('sim_file', ''),
|
||||
'fem_file': sim.get('fem_file', ''),
|
||||
'dat_file': sim.get('dat_file', ''),
|
||||
'op2_file': sim.get('op2_file', ''),
|
||||
'solution_name': sim.get('solution_name', 'Solution 1'),
|
||||
'solver': sim.get('solver', 'nastran'),
|
||||
}
|
||||
|
||||
# Normalize optimization settings
|
||||
opt = config.get('optimization', config.get('optimization_settings', {}))
|
||||
normalized['optimization'] = {
|
||||
'algorithm': opt.get('algorithm') or opt.get('sampler', 'NSGAIISampler'),
|
||||
'n_trials': opt.get('n_trials', 100),
|
||||
'population_size': opt.get('population_size', 20),
|
||||
'seed': opt.get('seed', 42),
|
||||
'timeout_per_trial': opt.get('timeout_per_trial', 600),
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class BaseOptimizationRunner(ABC):
|
||||
"""
|
||||
Abstract base class for optimization runners.
|
||||
|
||||
Subclasses must implement extract_objectives() to define how
|
||||
physics results are extracted from FEA output files.
|
||||
"""
|
||||
|
||||
def __init__(self, script_path: str, config_path: Optional[str] = None):
|
||||
"""
|
||||
Initialize the runner.
|
||||
|
||||
Args:
|
||||
script_path: Path to the study's run_optimization.py (__file__)
|
||||
config_path: Optional explicit path to config file
|
||||
"""
|
||||
self.study_dir = Path(script_path).parent
|
||||
self.config_path = Path(config_path) if config_path else self._find_config()
|
||||
self.model_dir = self.study_dir / "1_setup" / "model"
|
||||
self.results_dir = self.study_dir / "2_results"
|
||||
|
||||
# Load and normalize config
|
||||
with open(self.config_path, 'r') as f:
|
||||
self.raw_config = json.load(f)
|
||||
self.config = ConfigNormalizer.normalize_config(self.raw_config)
|
||||
|
||||
self.study_name = self.config['study_name']
|
||||
self.logger = None
|
||||
self.nx_solver = None
|
||||
|
||||
def _find_config(self) -> Path:
|
||||
"""Find the optimization config file."""
|
||||
candidates = [
|
||||
self.study_dir / "optimization_config.json",
|
||||
self.study_dir / "1_setup" / "optimization_config.json",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError(f"No optimization_config.json found in {self.study_dir}")
|
||||
|
||||
def _setup(self):
|
||||
"""Initialize solver and logger."""
|
||||
# Add project root to path
|
||||
project_root = self.study_dir.parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from optimization_engine.nx.solver import NXSolver
|
||||
from optimization_engine.utils.logger import get_logger
|
||||
|
||||
self.results_dir.mkdir(exist_ok=True)
|
||||
self.logger = get_logger(self.study_name, study_dir=self.results_dir)
|
||||
self.nx_solver = NXSolver(nastran_version="2506")
|
||||
|
||||
def sample_design_variables(self, trial: optuna.Trial) -> Dict[str, float]:
|
||||
"""Sample design variables from the config."""
|
||||
design_vars = {}
|
||||
for var in self.config['design_variables']:
|
||||
name = var['name']
|
||||
if var['type'] == 'integer':
|
||||
design_vars[name] = trial.suggest_int(name, int(var['min']), int(var['max']))
|
||||
else:
|
||||
design_vars[name] = trial.suggest_float(name, var['min'], var['max'])
|
||||
return design_vars
|
||||
|
||||
def run_simulation(self, design_vars: Dict[str, float]) -> Dict[str, Any]:
|
||||
"""Run the FEA simulation with given design variables."""
|
||||
sim_file = self.model_dir / self.config['simulation']['sim_file']
|
||||
|
||||
result = self.nx_solver.run_simulation(
|
||||
sim_file=sim_file,
|
||||
working_dir=self.model_dir,
|
||||
expression_updates=design_vars,
|
||||
solution_name=self.config['simulation'].get('solution_name'),
|
||||
cleanup=True
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
def extract_objectives(self, op2_file: Path, dat_file: Path,
|
||||
design_vars: Dict[str, float]) -> Dict[str, float]:
|
||||
"""
|
||||
Extract objective values from FEA results.
|
||||
|
||||
Args:
|
||||
op2_file: Path to OP2 results file
|
||||
dat_file: Path to DAT/BDF file
|
||||
design_vars: Design variable values for this trial
|
||||
|
||||
Returns:
|
||||
Dictionary of objective names to values
|
||||
"""
|
||||
pass
|
||||
|
||||
def check_constraints(self, objectives: Dict[str, float],
|
||||
op2_file: Path) -> Tuple[bool, Dict[str, float]]:
|
||||
"""
|
||||
Check if constraints are satisfied.
|
||||
|
||||
Returns:
|
||||
Tuple of (feasible, constraint_values)
|
||||
"""
|
||||
feasible = True
|
||||
constraint_values = {}
|
||||
|
||||
for con in self.config['constraints']:
|
||||
name = con['name']
|
||||
threshold = con['value']
|
||||
con_type = con['type']
|
||||
|
||||
# Try to get constraint value from objectives or extract
|
||||
if name in objectives:
|
||||
value = objectives[name]
|
||||
elif 'stress' in name.lower() and 'stress' in objectives:
|
||||
value = objectives['stress']
|
||||
elif 'displacement' in name.lower() and 'displacement' in objectives:
|
||||
value = objectives['displacement']
|
||||
else:
|
||||
# Need to extract separately
|
||||
value = 0 # Default
|
||||
|
||||
constraint_values[name] = value
|
||||
|
||||
if con_type == 'less_than' and value > threshold:
|
||||
feasible = False
|
||||
self.logger.warning(f' Constraint violation: {name} = {value:.2f} > {threshold}')
|
||||
elif con_type == 'greater_than' and value < threshold:
|
||||
feasible = False
|
||||
self.logger.warning(f' Constraint violation: {name} = {value:.2f} < {threshold}')
|
||||
|
||||
return feasible, constraint_values
|
||||
|
||||
def objective_function(self, trial: optuna.Trial) -> Tuple[float, ...]:
|
||||
"""
|
||||
Main objective function for Optuna optimization.
|
||||
|
||||
Returns tuple of objective values for multi-objective optimization.
|
||||
"""
|
||||
design_vars = self.sample_design_variables(trial)
|
||||
self.logger.trial_start(trial.number, design_vars)
|
||||
|
||||
try:
|
||||
# Run simulation
|
||||
result = self.run_simulation(design_vars)
|
||||
|
||||
if not result['success']:
|
||||
self.logger.trial_failed(trial.number, f"Simulation failed: {result.get('error', 'Unknown')}")
|
||||
return tuple([float('inf')] * len(self.config['objectives']))
|
||||
|
||||
op2_file = result['op2_file']
|
||||
dat_file = self.model_dir / self.config['simulation']['dat_file']
|
||||
|
||||
# Extract objectives
|
||||
objectives = self.extract_objectives(op2_file, dat_file, design_vars)
|
||||
|
||||
# Check constraints
|
||||
feasible, constraint_values = self.check_constraints(objectives, op2_file)
|
||||
|
||||
# Set user attributes
|
||||
for name, value in objectives.items():
|
||||
trial.set_user_attr(name, value)
|
||||
trial.set_user_attr('feasible', feasible)
|
||||
|
||||
self.logger.trial_complete(trial.number, objectives, constraint_values, feasible)
|
||||
|
||||
# Return objectives in order, converting maximize to minimize
|
||||
obj_values = []
|
||||
for obj_config in self.config['objectives']:
|
||||
name = obj_config['name']
|
||||
value = objectives.get(name, float('inf'))
|
||||
if obj_config['direction'] == 'maximize':
|
||||
value = -value # Negate for maximization
|
||||
obj_values.append(value)
|
||||
|
||||
return tuple(obj_values)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.trial_failed(trial.number, str(e))
|
||||
return tuple([float('inf')] * len(self.config['objectives']))
|
||||
|
||||
def get_sampler(self):
|
||||
"""Get the appropriate Optuna sampler based on config."""
|
||||
alg = self.config['optimization']['algorithm']
|
||||
pop_size = self.config['optimization']['population_size']
|
||||
seed = self.config['optimization']['seed']
|
||||
|
||||
if 'NSGA' in alg.upper():
|
||||
return NSGAIISampler(population_size=pop_size, seed=seed)
|
||||
elif 'TPE' in alg.upper():
|
||||
return TPESampler(seed=seed)
|
||||
else:
|
||||
return NSGAIISampler(population_size=pop_size, seed=seed)
|
||||
|
||||
def get_directions(self) -> List[str]:
|
||||
"""Get optimization directions for all objectives."""
|
||||
# All directions are 'minimize' since we negate maximize objectives
|
||||
return ['minimize'] * len(self.config['objectives'])
|
||||
|
||||
def clean_nastran_files(self):
|
||||
"""Remove old Nastran solver output files."""
|
||||
patterns = ['*.op2', '*.f06', '*.log', '*.f04', '*.pch', '*.DBALL', '*.MASTER', '_temp*.txt']
|
||||
deleted = []
|
||||
|
||||
for pattern in patterns:
|
||||
for f in self.model_dir.glob(pattern):
|
||||
try:
|
||||
f.unlink()
|
||||
deleted.append(f)
|
||||
self.logger.info(f" Deleted: {f.name}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f" Failed to delete {f.name}: {e}")
|
||||
|
||||
return deleted
|
||||
|
||||
def print_study_info(self):
|
||||
"""Print study information to console."""
|
||||
print("\n" + "=" * 60)
|
||||
print(f" {self.study_name.upper()}")
|
||||
print("=" * 60)
|
||||
print(f"\nDescription: {self.config['description']}")
|
||||
print(f"\nDesign Variables ({len(self.config['design_variables'])}):")
|
||||
for var in self.config['design_variables']:
|
||||
print(f" - {var['name']}: {var['min']}-{var['max']} {var['units']}")
|
||||
print(f"\nObjectives ({len(self.config['objectives'])}):")
|
||||
for obj in self.config['objectives']:
|
||||
print(f" - {obj['name']}: {obj['direction']}")
|
||||
print(f"\nConstraints ({len(self.config['constraints'])}):")
|
||||
for c in self.config['constraints']:
|
||||
print(f" - {c['name']}: < {c['value']} {c['units']}")
|
||||
print()
|
||||
|
||||
def run(self, args=None):
|
||||
"""
|
||||
Main entry point for running optimization.
|
||||
|
||||
Args:
|
||||
args: Optional argparse Namespace. If None, will parse sys.argv
|
||||
"""
|
||||
if args is None:
|
||||
args = self.parse_args()
|
||||
|
||||
self._setup()
|
||||
|
||||
if args.clean:
|
||||
self.clean_nastran_files()
|
||||
|
||||
self.print_study_info()
|
||||
|
||||
# Determine number of trials and storage
|
||||
if args.discover:
|
||||
n_trials = 1
|
||||
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
|
||||
study_suffix = "_discover"
|
||||
elif args.validate:
|
||||
n_trials = 1
|
||||
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
|
||||
study_suffix = "_validate"
|
||||
elif args.test:
|
||||
n_trials = 3
|
||||
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
|
||||
study_suffix = "_test"
|
||||
else:
|
||||
n_trials = args.trials
|
||||
storage = f"sqlite:///{self.results_dir / 'study.db'}"
|
||||
study_suffix = ""
|
||||
|
||||
# Create or load study
|
||||
full_study_name = f"{self.study_name}{study_suffix}"
|
||||
|
||||
if args.resume and study_suffix == "":
|
||||
study = optuna.load_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
sampler=self.get_sampler()
|
||||
)
|
||||
print(f"\nResuming study with {len(study.trials)} existing trials...")
|
||||
else:
|
||||
study = optuna.create_study(
|
||||
study_name=full_study_name,
|
||||
storage=storage,
|
||||
sampler=self.get_sampler(),
|
||||
directions=self.get_directions(),
|
||||
load_if_exists=(study_suffix == "")
|
||||
)
|
||||
|
||||
# Run optimization
|
||||
if study_suffix == "":
|
||||
self.logger.study_start(self.study_name, n_trials,
|
||||
self.config['optimization']['algorithm'])
|
||||
|
||||
print(f"\nRunning {n_trials} trials...")
|
||||
study.optimize(
|
||||
self.objective_function,
|
||||
n_trials=n_trials,
|
||||
show_progress_bar=True
|
||||
)
|
||||
|
||||
# Report results
|
||||
n_complete = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
|
||||
|
||||
if study_suffix == "":
|
||||
self.logger.study_complete(self.study_name, len(study.trials), n_complete)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" COMPLETE!")
|
||||
print("=" * 60)
|
||||
print(f"\nTotal trials: {len(study.trials)}")
|
||||
print(f"Successful: {n_complete}")
|
||||
|
||||
if hasattr(study, 'best_trials'):
|
||||
print(f"Pareto front: {len(study.best_trials)} solutions")
|
||||
|
||||
if study_suffix == "":
|
||||
print("\nNext steps:")
|
||||
print(" 1. Run method selector:")
|
||||
print(f" python -m optimization_engine.method_selector {self.config_path.relative_to(self.study_dir)} 2_results/study.db")
|
||||
print(" 2. If turbo recommended, run neural acceleration")
|
||||
|
||||
return 0
|
||||
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description=f'{self.study_name} - Optimization')
|
||||
|
||||
stage_group = parser.add_mutually_exclusive_group()
|
||||
stage_group.add_argument('--discover', action='store_true', help='Discover model outputs (1 trial)')
|
||||
stage_group.add_argument('--validate', action='store_true', help='Run single validation trial')
|
||||
stage_group.add_argument('--test', action='store_true', help='Run 3-trial test')
|
||||
stage_group.add_argument('--run', action='store_true', help='Run full optimization')
|
||||
|
||||
parser.add_argument('--trials', type=int,
|
||||
default=self.config['optimization']['n_trials'],
|
||||
help='Number of trials')
|
||||
parser.add_argument('--resume', action='store_true', help='Resume existing study')
|
||||
parser.add_argument('--clean', action='store_true', help='Clean old files first')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not any([args.discover, args.validate, args.test, args.run]):
|
||||
print("No stage specified. Use --discover, --validate, --test, or --run")
|
||||
print("\nTypical workflow:")
|
||||
print(" 1. python run_optimization.py --discover # Discover model outputs")
|
||||
print(" 2. python run_optimization.py --validate # Single trial validation")
|
||||
print(" 3. python run_optimization.py --test # Quick 3-trial test")
|
||||
print(f" 4. python run_optimization.py --run --trials {self.config['optimization']['n_trials']} # Full run")
|
||||
sys.exit(1)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
class ConfigDrivenRunner(BaseOptimizationRunner):
|
||||
"""
|
||||
Fully config-driven optimization runner.
|
||||
|
||||
Automatically extracts objectives based on config file definitions.
|
||||
Supports standard extractors: mass, stress, displacement, stiffness.
|
||||
"""
|
||||
|
||||
def __init__(self, script_path: str, config_path: Optional[str] = None,
|
||||
element_type: str = 'auto'):
|
||||
"""
|
||||
Initialize config-driven runner.
|
||||
|
||||
Args:
|
||||
script_path: Path to the study's script (__file__)
|
||||
config_path: Optional explicit path to config
|
||||
element_type: Element type for stress extraction ('ctetra', 'cquad4', 'auto')
|
||||
"""
|
||||
super().__init__(script_path, config_path)
|
||||
self.element_type = element_type
|
||||
self._extractors_loaded = False
|
||||
self._extractors = {}
|
||||
|
||||
def _load_extractors(self):
|
||||
"""Lazy-load extractor functions."""
|
||||
if self._extractors_loaded:
|
||||
return
|
||||
|
||||
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
|
||||
from optimization_engine.extractors.extract_displacement import extract_displacement
|
||||
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
|
||||
|
||||
self._extractors = {
|
||||
'extract_mass_from_bdf': extract_mass_from_bdf,
|
||||
'extract_displacement': extract_displacement,
|
||||
'extract_solid_stress': extract_solid_stress,
|
||||
}
|
||||
self._extractors_loaded = True
|
||||
|
||||
def _detect_element_type(self, dat_file: Path) -> str:
|
||||
"""Auto-detect element type from BDF/DAT file."""
|
||||
if self.element_type != 'auto':
|
||||
return self.element_type
|
||||
|
||||
try:
|
||||
with open(dat_file, 'r') as f:
|
||||
content = f.read(50000) # Read first 50KB
|
||||
|
||||
if 'CTETRA' in content:
|
||||
return 'ctetra'
|
||||
elif 'CHEXA' in content:
|
||||
return 'chexa'
|
||||
elif 'CQUAD4' in content:
|
||||
return 'cquad4'
|
||||
elif 'CTRIA3' in content:
|
||||
return 'ctria3'
|
||||
else:
|
||||
return 'ctetra' # Default
|
||||
except Exception:
|
||||
return 'ctetra'
|
||||
|
||||
def extract_objectives(self, op2_file: Path, dat_file: Path,
|
||||
design_vars: Dict[str, float]) -> Dict[str, float]:
|
||||
"""
|
||||
Extract all objectives based on config.
|
||||
|
||||
Handles common objectives: mass, stress, displacement, stiffness
|
||||
"""
|
||||
self._load_extractors()
|
||||
objectives = {}
|
||||
|
||||
element_type = self._detect_element_type(dat_file)
|
||||
|
||||
for obj_config in self.config['objectives']:
|
||||
name = obj_config['name'].lower()
|
||||
|
||||
try:
|
||||
if 'mass' in name:
|
||||
objectives[obj_config['name']] = self._extractors['extract_mass_from_bdf'](str(dat_file))
|
||||
self.logger.info(f" {obj_config['name']}: {objectives[obj_config['name']]:.2f} kg")
|
||||
|
||||
elif 'stress' in name:
|
||||
stress_result = self._extractors['extract_solid_stress'](
|
||||
op2_file, subcase=1, element_type=element_type
|
||||
)
|
||||
# Convert kPa to MPa
|
||||
stress_mpa = stress_result.get('max_von_mises', float('inf')) / 1000.0
|
||||
objectives[obj_config['name']] = stress_mpa
|
||||
self.logger.info(f" {obj_config['name']}: {stress_mpa:.2f} MPa")
|
||||
|
||||
elif 'displacement' in name:
|
||||
disp_result = self._extractors['extract_displacement'](op2_file, subcase=1)
|
||||
objectives[obj_config['name']] = disp_result['max_displacement']
|
||||
self.logger.info(f" {obj_config['name']}: {disp_result['max_displacement']:.3f} mm")
|
||||
|
||||
elif 'stiffness' in name:
|
||||
disp_result = self._extractors['extract_displacement'](op2_file, subcase=1)
|
||||
max_disp = disp_result['max_displacement']
|
||||
applied_force = 1000.0 # N - standard assumption
|
||||
stiffness = applied_force / max(abs(max_disp), 1e-6)
|
||||
objectives[obj_config['name']] = stiffness
|
||||
objectives['displacement'] = max_disp # Store for constraint check
|
||||
self.logger.info(f" {obj_config['name']}: {stiffness:.1f} N/mm")
|
||||
self.logger.info(f" displacement: {max_disp:.3f} mm")
|
||||
|
||||
else:
|
||||
self.logger.warning(f" Unknown objective: {name}")
|
||||
objectives[obj_config['name']] = float('inf')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" Failed to extract {name}: {e}")
|
||||
objectives[obj_config['name']] = float('inf')
|
||||
|
||||
return objectives
|
||||
|
||||
|
||||
def create_runner(script_path: str, element_type: str = 'auto') -> ConfigDrivenRunner:
|
||||
"""
|
||||
Factory function to create a ConfigDrivenRunner.
|
||||
|
||||
Args:
|
||||
script_path: Path to the study's run_optimization.py (__file__)
|
||||
element_type: Element type for stress extraction
|
||||
|
||||
Returns:
|
||||
Configured runner ready to execute
|
||||
"""
|
||||
return ConfigDrivenRunner(script_path, element_type=element_type)
|
||||
776
optimization_engine/core/gradient_optimizer.py
Normal file
776
optimization_engine/core/gradient_optimizer.py
Normal file
@@ -0,0 +1,776 @@
|
||||
"""
|
||||
L-BFGS Gradient-Based Optimizer for Surrogate Models
|
||||
|
||||
This module exploits the differentiability of trained neural network surrogates
|
||||
to perform gradient-based optimization using L-BFGS and other second-order methods.
|
||||
|
||||
Key Advantages over Derivative-Free Methods:
|
||||
- 100-1000x faster convergence for local refinement
|
||||
- Exploits the smooth landscape learned by the surrogate
|
||||
- Can find precise local optima that sampling-based methods miss
|
||||
|
||||
Usage:
|
||||
from optimization_engine.core.gradient_optimizer import GradientOptimizer
|
||||
from optimization_engine.processors.surrogates.generic_surrogate import GenericSurrogate
|
||||
|
||||
# Load trained surrogate
|
||||
surrogate = GenericSurrogate(config)
|
||||
surrogate.load("surrogate_best.pt")
|
||||
|
||||
# Create gradient optimizer
|
||||
optimizer = GradientOptimizer(surrogate, objective_weights=[5.0, 5.0, 1.0])
|
||||
|
||||
# Find optimal designs from multiple starting points
|
||||
results = optimizer.optimize(
|
||||
starting_points=top_10_candidates,
|
||||
method='lbfgs',
|
||||
n_iterations=100
|
||||
)
|
||||
|
||||
Reference:
|
||||
- "Physics-informed deep learning for simultaneous surrogate modeling and
|
||||
PDE-constrained optimization" - ScienceDirect 2023
|
||||
- "Neural Network Surrogate and Projected Gradient Descent for Fast and
|
||||
Reliable Finite Element Model Calibration" - arXiv 2024
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.optim import LBFGS, Adam, SGD
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptimizationResult:
|
||||
"""Result from gradient-based optimization."""
|
||||
|
||||
# Final solution
|
||||
params: Dict[str, float]
|
||||
objectives: Dict[str, float]
|
||||
weighted_sum: float
|
||||
|
||||
# Convergence info
|
||||
converged: bool
|
||||
n_iterations: int
|
||||
n_function_evals: int
|
||||
final_gradient_norm: float
|
||||
|
||||
# Trajectory (optional)
|
||||
history: List[Dict] = field(default_factory=list)
|
||||
|
||||
# Starting point info
|
||||
starting_params: Dict[str, float] = field(default_factory=dict)
|
||||
starting_weighted_sum: float = 0.0
|
||||
improvement: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'params': self.params,
|
||||
'objectives': self.objectives,
|
||||
'weighted_sum': self.weighted_sum,
|
||||
'converged': self.converged,
|
||||
'n_iterations': self.n_iterations,
|
||||
'n_function_evals': self.n_function_evals,
|
||||
'final_gradient_norm': self.final_gradient_norm,
|
||||
'starting_weighted_sum': self.starting_weighted_sum,
|
||||
'improvement': self.improvement
|
||||
}
|
||||
|
||||
|
||||
class GradientOptimizer:
|
||||
"""
|
||||
Gradient-based optimizer exploiting differentiable surrogates.
|
||||
|
||||
Supports:
|
||||
- L-BFGS (recommended for surrogate optimization)
|
||||
- Adam (good for noisy landscapes)
|
||||
- Projected gradient descent (for bound constraints)
|
||||
|
||||
The key insight is that once we have a trained neural network surrogate,
|
||||
we can compute exact gradients via backpropagation, enabling much faster
|
||||
convergence than derivative-free methods like TPE or CMA-ES.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
surrogate,
|
||||
objective_weights: Optional[List[float]] = None,
|
||||
objective_directions: Optional[List[str]] = None,
|
||||
constraints: Optional[List[Dict]] = None,
|
||||
device: str = 'auto'
|
||||
):
|
||||
"""
|
||||
Initialize gradient optimizer.
|
||||
|
||||
Args:
|
||||
surrogate: Trained GenericSurrogate or compatible model
|
||||
objective_weights: Weight for each objective in weighted sum
|
||||
objective_directions: 'minimize' or 'maximize' for each objective
|
||||
constraints: List of constraint dicts with 'name', 'type', 'threshold'
|
||||
device: 'auto', 'cuda', or 'cpu'
|
||||
"""
|
||||
if not TORCH_AVAILABLE:
|
||||
raise ImportError("PyTorch required for gradient optimization")
|
||||
|
||||
self.surrogate = surrogate
|
||||
self.model = surrogate.model
|
||||
self.normalization = surrogate.normalization
|
||||
|
||||
self.var_names = surrogate.design_var_names
|
||||
self.obj_names = surrogate.objective_names
|
||||
self.bounds = surrogate.design_var_bounds
|
||||
|
||||
self.n_vars = len(self.var_names)
|
||||
self.n_objs = len(self.obj_names)
|
||||
|
||||
# Device setup
|
||||
if device == 'auto':
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
# Move model to device
|
||||
self.model = self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
# Objective configuration
|
||||
if objective_weights is None:
|
||||
objective_weights = [1.0] * self.n_objs
|
||||
self.weights = torch.tensor(objective_weights, dtype=torch.float32, device=self.device)
|
||||
|
||||
if objective_directions is None:
|
||||
objective_directions = ['minimize'] * self.n_objs
|
||||
# Convert directions to signs: minimize=+1, maximize=-1
|
||||
self.signs = torch.tensor(
|
||||
[1.0 if d == 'minimize' else -1.0 for d in objective_directions],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
|
||||
self.constraints = constraints or []
|
||||
|
||||
# Precompute bound tensors for projection
|
||||
self.bounds_low = torch.tensor(
|
||||
[self.bounds[name][0] for name in self.var_names],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
self.bounds_high = torch.tensor(
|
||||
[self.bounds[name][1] for name in self.var_names],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
|
||||
# Normalization tensors
|
||||
self.design_mean = torch.tensor(
|
||||
self.normalization['design_mean'],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
self.design_std = torch.tensor(
|
||||
self.normalization['design_std'],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
self.obj_mean = torch.tensor(
|
||||
self.normalization['objective_mean'],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
self.obj_std = torch.tensor(
|
||||
self.normalization['objective_std'],
|
||||
dtype=torch.float32, device=self.device
|
||||
)
|
||||
|
||||
def _normalize_params(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Normalize parameters for model input."""
|
||||
return (x - self.design_mean) / self.design_std
|
||||
|
||||
def _denormalize_objectives(self, y_norm: torch.Tensor) -> torch.Tensor:
|
||||
"""Denormalize model output to actual objective values."""
|
||||
return y_norm * self.obj_std + self.obj_mean
|
||||
|
||||
def _project_to_bounds(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""Project parameters to feasible bounds."""
|
||||
return torch.clamp(x, self.bounds_low, self.bounds_high)
|
||||
|
||||
def _compute_objective(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
Compute weighted objective from parameters.
|
||||
|
||||
Args:
|
||||
x: Parameter tensor (raw, not normalized)
|
||||
|
||||
Returns:
|
||||
(weighted_sum, objectives): Scalar loss and objective vector
|
||||
"""
|
||||
# Project to bounds
|
||||
x_bounded = self._project_to_bounds(x)
|
||||
|
||||
# Normalize and predict
|
||||
x_norm = self._normalize_params(x_bounded)
|
||||
y_norm = self.model(x_norm.unsqueeze(0)).squeeze(0)
|
||||
|
||||
# Denormalize objectives
|
||||
objectives = self._denormalize_objectives(y_norm)
|
||||
|
||||
# Weighted sum with direction signs
|
||||
weighted_sum = (objectives * self.signs * self.weights).sum()
|
||||
|
||||
return weighted_sum, objectives
|
||||
|
||||
def _tensor_to_params(self, x: torch.Tensor) -> Dict[str, float]:
|
||||
"""Convert parameter tensor to dictionary."""
|
||||
x_np = x.detach().cpu().numpy()
|
||||
return {name: float(x_np[i]) for i, name in enumerate(self.var_names)}
|
||||
|
||||
def _params_to_tensor(self, params: Dict[str, float]) -> torch.Tensor:
|
||||
"""Convert parameter dictionary to tensor."""
|
||||
values = [params.get(name, (self.bounds[name][0] + self.bounds[name][1]) / 2)
|
||||
for name in self.var_names]
|
||||
return torch.tensor(values, dtype=torch.float32, device=self.device)
|
||||
|
||||
def _objectives_to_dict(self, objectives: torch.Tensor) -> Dict[str, float]:
|
||||
"""Convert objectives tensor to dictionary."""
|
||||
obj_np = objectives.detach().cpu().numpy()
|
||||
return {name: float(obj_np[i]) for i, name in enumerate(self.obj_names)}
|
||||
|
||||
def optimize_single(
|
||||
self,
|
||||
starting_point: Dict[str, float],
|
||||
method: str = 'lbfgs',
|
||||
n_iterations: int = 100,
|
||||
lr: float = 0.1,
|
||||
tolerance: float = 1e-7,
|
||||
record_history: bool = False,
|
||||
verbose: bool = False
|
||||
) -> OptimizationResult:
|
||||
"""
|
||||
Optimize from a single starting point.
|
||||
|
||||
Args:
|
||||
starting_point: Initial parameter values
|
||||
method: 'lbfgs', 'adam', or 'sgd'
|
||||
n_iterations: Maximum iterations
|
||||
lr: Learning rate (for Adam/SGD) or line search step (for L-BFGS)
|
||||
tolerance: Convergence tolerance for gradient norm
|
||||
record_history: Store optimization trajectory
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
OptimizationResult with optimized parameters
|
||||
"""
|
||||
# Initialize parameter tensor with gradient tracking
|
||||
x = self._params_to_tensor(starting_point).clone().requires_grad_(True)
|
||||
|
||||
# Get starting objective
|
||||
with torch.no_grad():
|
||||
start_ws, start_obj = self._compute_objective(x)
|
||||
start_ws_val = start_ws.item()
|
||||
|
||||
# Setup optimizer
|
||||
if method.lower() == 'lbfgs':
|
||||
optimizer = LBFGS(
|
||||
[x],
|
||||
lr=lr,
|
||||
max_iter=20,
|
||||
max_eval=25,
|
||||
tolerance_grad=tolerance,
|
||||
tolerance_change=1e-9,
|
||||
history_size=10,
|
||||
line_search_fn='strong_wolfe'
|
||||
)
|
||||
elif method.lower() == 'adam':
|
||||
optimizer = Adam([x], lr=lr)
|
||||
elif method.lower() == 'sgd':
|
||||
optimizer = SGD([x], lr=lr, momentum=0.9)
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}. Use 'lbfgs', 'adam', or 'sgd'")
|
||||
|
||||
history = []
|
||||
n_evals = 0
|
||||
converged = False
|
||||
final_grad_norm = float('inf')
|
||||
|
||||
def closure():
|
||||
nonlocal n_evals
|
||||
optimizer.zero_grad()
|
||||
loss, _ = self._compute_objective(x)
|
||||
loss.backward()
|
||||
n_evals += 1
|
||||
return loss
|
||||
|
||||
# Optimization loop
|
||||
for iteration in range(n_iterations):
|
||||
if method.lower() == 'lbfgs':
|
||||
# L-BFGS does multiple function evals per step
|
||||
loss = optimizer.step(closure)
|
||||
else:
|
||||
# Adam/SGD: single step
|
||||
optimizer.zero_grad()
|
||||
loss, objectives = self._compute_objective(x)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
n_evals += 1
|
||||
|
||||
# Project to bounds after step
|
||||
with torch.no_grad():
|
||||
x.data = self._project_to_bounds(x.data)
|
||||
|
||||
# Check gradient norm
|
||||
if x.grad is not None:
|
||||
grad_norm = x.grad.norm().item()
|
||||
else:
|
||||
grad_norm = float('inf')
|
||||
|
||||
final_grad_norm = grad_norm
|
||||
|
||||
# Record history
|
||||
if record_history:
|
||||
with torch.no_grad():
|
||||
ws, obj = self._compute_objective(x)
|
||||
history.append({
|
||||
'iteration': iteration,
|
||||
'weighted_sum': ws.item(),
|
||||
'objectives': self._objectives_to_dict(obj),
|
||||
'params': self._tensor_to_params(x),
|
||||
'grad_norm': grad_norm
|
||||
})
|
||||
|
||||
if verbose and iteration % 10 == 0:
|
||||
with torch.no_grad():
|
||||
ws, _ = self._compute_objective(x)
|
||||
print(f" Iter {iteration:3d}: WS={ws.item():.4f}, |grad|={grad_norm:.2e}")
|
||||
|
||||
# Convergence check
|
||||
if grad_norm < tolerance:
|
||||
converged = True
|
||||
if verbose:
|
||||
print(f" Converged at iteration {iteration} (grad_norm={grad_norm:.2e})")
|
||||
break
|
||||
|
||||
# Final evaluation
|
||||
with torch.no_grad():
|
||||
x_final = self._project_to_bounds(x)
|
||||
final_ws, final_obj = self._compute_objective(x_final)
|
||||
|
||||
final_params = self._tensor_to_params(x_final)
|
||||
final_objectives = self._objectives_to_dict(final_obj)
|
||||
final_ws_val = final_ws.item()
|
||||
|
||||
improvement = start_ws_val - final_ws_val
|
||||
|
||||
return OptimizationResult(
|
||||
params=final_params,
|
||||
objectives=final_objectives,
|
||||
weighted_sum=final_ws_val,
|
||||
converged=converged,
|
||||
n_iterations=iteration + 1,
|
||||
n_function_evals=n_evals,
|
||||
final_gradient_norm=final_grad_norm,
|
||||
history=history,
|
||||
starting_params=starting_point,
|
||||
starting_weighted_sum=start_ws_val,
|
||||
improvement=improvement
|
||||
)
|
||||
|
||||
def optimize(
|
||||
self,
|
||||
starting_points: Union[List[Dict[str, float]], int] = 10,
|
||||
method: str = 'lbfgs',
|
||||
n_iterations: int = 100,
|
||||
lr: float = 0.1,
|
||||
tolerance: float = 1e-7,
|
||||
n_random_restarts: int = 0,
|
||||
return_all: bool = False,
|
||||
verbose: bool = True
|
||||
) -> Union[OptimizationResult, List[OptimizationResult]]:
|
||||
"""
|
||||
Optimize from multiple starting points and return best result.
|
||||
|
||||
Args:
|
||||
starting_points: List of starting dicts, or int for random starts
|
||||
method: Optimization method ('lbfgs', 'adam', 'sgd')
|
||||
n_iterations: Max iterations per start
|
||||
lr: Learning rate
|
||||
tolerance: Convergence tolerance
|
||||
n_random_restarts: Additional random starting points
|
||||
return_all: Return all results, not just best
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
Best OptimizationResult, or list of all results if return_all=True
|
||||
"""
|
||||
# Build starting points list
|
||||
if isinstance(starting_points, int):
|
||||
points = [self._random_starting_point() for _ in range(starting_points)]
|
||||
else:
|
||||
points = list(starting_points)
|
||||
|
||||
# Add random restarts
|
||||
for _ in range(n_random_restarts):
|
||||
points.append(self._random_starting_point())
|
||||
|
||||
if verbose:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"L-BFGS GRADIENT OPTIMIZATION")
|
||||
print(f"{'='*60}")
|
||||
print(f"Method: {method.upper()}")
|
||||
print(f"Starting points: {len(points)}")
|
||||
print(f"Max iterations per start: {n_iterations}")
|
||||
print(f"Tolerance: {tolerance:.0e}")
|
||||
|
||||
results = []
|
||||
start_time = time.time()
|
||||
|
||||
for i, start in enumerate(points):
|
||||
if verbose:
|
||||
var_preview = ", ".join(f"{k}={v:.2f}" for k, v in list(start.items())[:3])
|
||||
print(f"\n[{i+1}/{len(points)}] Starting from: {var_preview}...")
|
||||
|
||||
result = self.optimize_single(
|
||||
starting_point=start,
|
||||
method=method,
|
||||
n_iterations=n_iterations,
|
||||
lr=lr,
|
||||
tolerance=tolerance,
|
||||
record_history=False,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
|
||||
if verbose:
|
||||
status = "CONVERGED" if result.converged else f"iter={result.n_iterations}"
|
||||
print(f" Result: WS={result.weighted_sum:.4f} ({status})")
|
||||
print(f" Improvement: {result.improvement:.4f} ({result.improvement/max(result.starting_weighted_sum, 1e-6)*100:.1f}%)")
|
||||
|
||||
# Sort by weighted sum (ascending = better)
|
||||
results.sort(key=lambda r: r.weighted_sum)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if verbose:
|
||||
print(f"\n{'-'*60}")
|
||||
print(f"OPTIMIZATION COMPLETE")
|
||||
print(f"{'-'*60}")
|
||||
print(f"Time: {elapsed:.1f}s")
|
||||
print(f"Function evaluations: {sum(r.n_function_evals for r in results)}")
|
||||
print(f"\nBest result:")
|
||||
best = results[0]
|
||||
for name, val in best.params.items():
|
||||
bounds = self.bounds[name]
|
||||
pct = (val - bounds[0]) / (bounds[1] - bounds[0]) * 100
|
||||
print(f" {name}: {val:.4f} ({pct:.0f}% of range)")
|
||||
print(f"\nObjectives:")
|
||||
for name, val in best.objectives.items():
|
||||
print(f" {name}: {val:.4f}")
|
||||
print(f"\nWeighted sum: {best.weighted_sum:.4f}")
|
||||
print(f"Total improvement: {best.starting_weighted_sum - best.weighted_sum:.4f}")
|
||||
|
||||
if return_all:
|
||||
return results
|
||||
return results[0]
|
||||
|
||||
def _random_starting_point(self) -> Dict[str, float]:
|
||||
"""Generate random starting point within bounds."""
|
||||
params = {}
|
||||
for name in self.var_names:
|
||||
low, high = self.bounds[name]
|
||||
params[name] = np.random.uniform(low, high)
|
||||
return params
|
||||
|
||||
def grid_search_then_gradient(
|
||||
self,
|
||||
n_grid_samples: int = 100,
|
||||
n_top_for_gradient: int = 10,
|
||||
method: str = 'lbfgs',
|
||||
n_iterations: int = 100,
|
||||
verbose: bool = True
|
||||
) -> List[OptimizationResult]:
|
||||
"""
|
||||
Two-phase optimization: random grid search then gradient refinement.
|
||||
|
||||
This is often more effective than pure gradient descent because
|
||||
it identifies multiple promising basins before local refinement.
|
||||
|
||||
Args:
|
||||
n_grid_samples: Number of random samples for initial search
|
||||
n_top_for_gradient: Number of top candidates to refine with gradient
|
||||
method: Gradient optimization method
|
||||
n_iterations: Gradient iterations per candidate
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
List of results sorted by weighted sum
|
||||
"""
|
||||
if verbose:
|
||||
print(f"\n{'='*60}")
|
||||
print("HYBRID GRID + GRADIENT OPTIMIZATION")
|
||||
print(f"{'='*60}")
|
||||
print(f"Phase 1: {n_grid_samples} random samples")
|
||||
print(f"Phase 2: L-BFGS refinement of top {n_top_for_gradient}")
|
||||
|
||||
# Phase 1: Random grid search
|
||||
candidates = []
|
||||
|
||||
for i in range(n_grid_samples):
|
||||
params = self._random_starting_point()
|
||||
x = self._params_to_tensor(params)
|
||||
|
||||
with torch.no_grad():
|
||||
ws, obj = self._compute_objective(x)
|
||||
|
||||
candidates.append({
|
||||
'params': params,
|
||||
'weighted_sum': ws.item(),
|
||||
'objectives': self._objectives_to_dict(obj)
|
||||
})
|
||||
|
||||
if verbose and (i + 1) % (n_grid_samples // 5) == 0:
|
||||
print(f" Grid search: {i+1}/{n_grid_samples}")
|
||||
|
||||
# Sort by weighted sum
|
||||
candidates.sort(key=lambda c: c['weighted_sum'])
|
||||
|
||||
if verbose:
|
||||
print(f"\nTop {n_top_for_gradient} from grid search:")
|
||||
for i, c in enumerate(candidates[:n_top_for_gradient]):
|
||||
print(f" {i+1}. WS={c['weighted_sum']:.4f}")
|
||||
|
||||
# Phase 2: Gradient refinement
|
||||
top_starts = [c['params'] for c in candidates[:n_top_for_gradient]]
|
||||
|
||||
results = self.optimize(
|
||||
starting_points=top_starts,
|
||||
method=method,
|
||||
n_iterations=n_iterations,
|
||||
verbose=verbose
|
||||
)
|
||||
|
||||
# Return as list
|
||||
if isinstance(results, OptimizationResult):
|
||||
return [results]
|
||||
return results
|
||||
|
||||
|
||||
class MultiStartLBFGS:
|
||||
"""
|
||||
Convenience class for multi-start L-BFGS optimization.
|
||||
|
||||
Provides a simple interface for the common use case of:
|
||||
1. Load trained surrogate
|
||||
2. Run L-BFGS from multiple starting points
|
||||
3. Get best result
|
||||
"""
|
||||
|
||||
def __init__(self, surrogate_path: Path, config: Dict):
|
||||
"""
|
||||
Initialize from saved surrogate.
|
||||
|
||||
Args:
|
||||
surrogate_path: Path to surrogate_best.pt
|
||||
config: Optimization config dict
|
||||
"""
|
||||
from optimization_engine.processors.surrogates.generic_surrogate import GenericSurrogate
|
||||
|
||||
self.surrogate = GenericSurrogate(config)
|
||||
self.surrogate.load(surrogate_path)
|
||||
|
||||
# Extract weights from config
|
||||
weights = [obj.get('weight', 1.0) for obj in config.get('objectives', [])]
|
||||
directions = [obj.get('direction', 'minimize') for obj in config.get('objectives', [])]
|
||||
|
||||
self.optimizer = GradientOptimizer(
|
||||
surrogate=self.surrogate,
|
||||
objective_weights=weights,
|
||||
objective_directions=directions
|
||||
)
|
||||
|
||||
def find_optimum(
|
||||
self,
|
||||
n_starts: int = 20,
|
||||
n_iterations: int = 100,
|
||||
top_candidates: Optional[List[Dict[str, float]]] = None
|
||||
) -> OptimizationResult:
|
||||
"""
|
||||
Find optimal design.
|
||||
|
||||
Args:
|
||||
n_starts: Number of random starting points
|
||||
n_iterations: L-BFGS iterations per start
|
||||
top_candidates: Optional list of good starting candidates
|
||||
|
||||
Returns:
|
||||
Best OptimizationResult
|
||||
"""
|
||||
if top_candidates:
|
||||
return self.optimizer.optimize(
|
||||
starting_points=top_candidates,
|
||||
n_random_restarts=n_starts,
|
||||
n_iterations=n_iterations
|
||||
)
|
||||
else:
|
||||
return self.optimizer.optimize(
|
||||
starting_points=n_starts,
|
||||
n_iterations=n_iterations
|
||||
)
|
||||
|
||||
|
||||
def run_lbfgs_polish(
|
||||
study_dir: Path,
|
||||
n_starts: int = 20,
|
||||
use_top_fea: bool = True,
|
||||
n_iterations: int = 100,
|
||||
verbose: bool = True
|
||||
) -> List[OptimizationResult]:
|
||||
"""
|
||||
Run L-BFGS polish on a study with trained surrogate.
|
||||
|
||||
This is the recommended way to use gradient optimization:
|
||||
1. Load study config and trained surrogate
|
||||
2. Optionally use top FEA results as starting points
|
||||
3. Run L-BFGS refinement
|
||||
4. Return optimized candidates for FEA validation
|
||||
|
||||
Args:
|
||||
study_dir: Path to study directory
|
||||
n_starts: Number of starting points (random if use_top_fea=False)
|
||||
use_top_fea: Use top FEA results from study.db as starting points
|
||||
n_iterations: L-BFGS iterations
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
List of OptimizationResults sorted by weighted sum
|
||||
"""
|
||||
import optuna
|
||||
|
||||
study_dir = Path(study_dir)
|
||||
|
||||
# Find config
|
||||
config_path = study_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_path.exists():
|
||||
config_path = study_dir / "optimization_config.json"
|
||||
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Normalize config (simplified)
|
||||
if 'design_variables' in config:
|
||||
for var in config['design_variables']:
|
||||
if 'name' not in var and 'parameter' in var:
|
||||
var['name'] = var['parameter']
|
||||
if 'min' not in var and 'bounds' in var:
|
||||
var['min'], var['max'] = var['bounds']
|
||||
|
||||
# Load surrogate
|
||||
results_dir = study_dir / "3_results"
|
||||
if not results_dir.exists():
|
||||
results_dir = study_dir / "2_results"
|
||||
|
||||
surrogate_path = results_dir / "surrogate_best.pt"
|
||||
if not surrogate_path.exists():
|
||||
raise FileNotFoundError(f"No trained surrogate found at {surrogate_path}")
|
||||
|
||||
# Get starting points from FEA database
|
||||
starting_points = []
|
||||
|
||||
if use_top_fea:
|
||||
db_path = results_dir / "study.db"
|
||||
if db_path.exists():
|
||||
storage = f"sqlite:///{db_path}"
|
||||
study_name = config.get('study_name', 'unnamed_study')
|
||||
|
||||
try:
|
||||
study = optuna.load_study(study_name=study_name, storage=storage)
|
||||
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
|
||||
|
||||
# Sort by first objective (or weighted sum if available)
|
||||
completed.sort(key=lambda t: sum(t.values) if t.values else float('inf'))
|
||||
|
||||
for trial in completed[:n_starts]:
|
||||
starting_points.append(dict(trial.params))
|
||||
|
||||
if verbose:
|
||||
print(f"Loaded {len(starting_points)} starting points from FEA database")
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Warning: Could not load from database: {e}")
|
||||
|
||||
# Create optimizer
|
||||
weights = [obj.get('weight', 1.0) for obj in config.get('objectives', [])]
|
||||
directions = [obj.get('direction', 'minimize') for obj in config.get('objectives', [])]
|
||||
|
||||
from optimization_engine.processors.surrogates.generic_surrogate import GenericSurrogate
|
||||
|
||||
surrogate = GenericSurrogate(config)
|
||||
surrogate.load(surrogate_path)
|
||||
|
||||
optimizer = GradientOptimizer(
|
||||
surrogate=surrogate,
|
||||
objective_weights=weights,
|
||||
objective_directions=directions
|
||||
)
|
||||
|
||||
# Run optimization
|
||||
if starting_points:
|
||||
results = optimizer.optimize(
|
||||
starting_points=starting_points,
|
||||
n_random_restarts=max(0, n_starts - len(starting_points)),
|
||||
n_iterations=n_iterations,
|
||||
verbose=verbose,
|
||||
return_all=True
|
||||
)
|
||||
else:
|
||||
results = optimizer.optimize(
|
||||
starting_points=n_starts,
|
||||
n_iterations=n_iterations,
|
||||
verbose=verbose,
|
||||
return_all=True
|
||||
)
|
||||
|
||||
# Save results
|
||||
output_path = results_dir / "lbfgs_results.json"
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump([r.to_dict() for r in results], f, indent=2)
|
||||
|
||||
if verbose:
|
||||
print(f"\nResults saved to {output_path}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""CLI interface for L-BFGS optimization."""
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="L-BFGS Gradient Optimization on Surrogate")
|
||||
parser.add_argument("study_dir", type=str, help="Path to study directory")
|
||||
parser.add_argument("--n-starts", type=int, default=20, help="Number of starting points")
|
||||
parser.add_argument("--n-iterations", type=int, default=100, help="L-BFGS iterations per start")
|
||||
parser.add_argument("--use-top-fea", action="store_true", default=True,
|
||||
help="Use top FEA results as starting points")
|
||||
parser.add_argument("--random-only", action="store_true",
|
||||
help="Use only random starting points")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
results = run_lbfgs_polish(
|
||||
study_dir=Path(args.study_dir),
|
||||
n_starts=args.n_starts,
|
||||
use_top_fea=not args.random_only,
|
||||
n_iterations=args.n_iterations,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print(f"\nTop 5 L-BFGS results:")
|
||||
for i, r in enumerate(results[:5]):
|
||||
print(f"\n{i+1}. WS={r.weighted_sum:.4f}")
|
||||
for name, val in r.params.items():
|
||||
print(f" {name}: {val:.4f}")
|
||||
560
optimization_engine/core/intelligent_optimizer.py
Normal file
560
optimization_engine/core/intelligent_optimizer.py
Normal file
@@ -0,0 +1,560 @@
|
||||
"""
|
||||
Intelligent Multi-Strategy Optimizer - Protocol 10 Implementation.
|
||||
|
||||
This is the main orchestrator for Protocol 10: Intelligent Multi-Strategy
|
||||
Optimization (IMSO). It coordinates landscape analysis, strategy selection,
|
||||
and dynamic strategy switching to create a self-tuning optimization system.
|
||||
|
||||
Architecture:
|
||||
1. Landscape Analyzer: Characterizes the optimization problem
|
||||
2. Strategy Selector: Recommends best algorithm based on characteristics
|
||||
3. Strategy Portfolio Manager: Handles dynamic switching between strategies
|
||||
4. Adaptive Callbacks: Integrates with Optuna for runtime adaptation
|
||||
|
||||
This module enables Atomizer to automatically adapt to different FEA problem
|
||||
types without requiring manual algorithm configuration.
|
||||
|
||||
Usage:
|
||||
from optimization_engine.core.intelligent_optimizer import IntelligentOptimizer
|
||||
|
||||
optimizer = IntelligentOptimizer(
|
||||
study_name="my_study",
|
||||
study_dir=Path("results"),
|
||||
config=config_dict
|
||||
)
|
||||
|
||||
best_params = optimizer.optimize(
|
||||
objective_function=my_objective,
|
||||
n_trials=100
|
||||
)
|
||||
"""
|
||||
|
||||
import optuna
|
||||
from pathlib import Path
|
||||
from typing import Dict, Callable, Optional, Any
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from optimization_engine.reporting.landscape_analyzer import LandscapeAnalyzer, print_landscape_report
|
||||
from optimization_engine.core.strategy_selector import (
|
||||
IntelligentStrategySelector,
|
||||
create_sampler_from_config
|
||||
)
|
||||
from optimization_engine.core.strategy_portfolio import (
|
||||
StrategyTransitionManager,
|
||||
AdaptiveStrategyCallback
|
||||
)
|
||||
from optimization_engine.processors.surrogates.adaptive_surrogate import AdaptiveExploitationCallback
|
||||
from optimization_engine.processors.adaptive_characterization import CharacterizationStoppingCriterion
|
||||
from optimization_engine.utils.realtime_tracking import create_realtime_callback
|
||||
|
||||
|
||||
class IntelligentOptimizer:
|
||||
"""
|
||||
Self-tuning multi-strategy optimizer for FEA problems.
|
||||
|
||||
This class implements Protocol 10: Intelligent Multi-Strategy Optimization.
|
||||
It automatically:
|
||||
1. Analyzes problem characteristics
|
||||
2. Selects appropriate optimization algorithms
|
||||
3. Switches strategies dynamically based on performance
|
||||
4. Logs all decisions for transparency and learning
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
study_name: str,
|
||||
study_dir: Path,
|
||||
config: Dict,
|
||||
verbose: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize intelligent optimizer.
|
||||
|
||||
Args:
|
||||
study_name: Name for the optimization study
|
||||
study_dir: Directory to save optimization results
|
||||
config: Configuration dictionary with Protocol 10 settings
|
||||
verbose: Print detailed progress information
|
||||
"""
|
||||
self.study_name = study_name
|
||||
self.study_dir = Path(study_dir)
|
||||
self.config = config
|
||||
self.verbose = verbose
|
||||
|
||||
# Extract Protocol 10 configuration
|
||||
self.protocol_config = config.get('intelligent_optimization', {})
|
||||
self.enabled = self.protocol_config.get('enabled', True)
|
||||
|
||||
# Setup tracking directory
|
||||
self.tracking_dir = self.study_dir / "intelligent_optimizer"
|
||||
self.tracking_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize components
|
||||
self.landscape_analyzer = LandscapeAnalyzer(
|
||||
min_trials_for_analysis=self.protocol_config.get('min_analysis_trials', 10)
|
||||
)
|
||||
|
||||
self.strategy_selector = IntelligentStrategySelector(verbose=verbose)
|
||||
|
||||
self.transition_manager = StrategyTransitionManager(
|
||||
stagnation_window=self.protocol_config.get('stagnation_window', 10),
|
||||
min_improvement_threshold=self.protocol_config.get('min_improvement_threshold', 0.001),
|
||||
verbose=verbose,
|
||||
tracking_dir=self.tracking_dir
|
||||
)
|
||||
|
||||
# State tracking
|
||||
self.current_phase = "initialization"
|
||||
self.current_strategy = None
|
||||
self.landscape_cache = None
|
||||
self.recommendation_cache = None
|
||||
|
||||
# Optuna study (will be created in optimize())
|
||||
self.study: Optional[optuna.Study] = None
|
||||
self.directions: Optional[list] = None # Store study directions
|
||||
|
||||
# Protocol 13: Create realtime tracking callback
|
||||
self.realtime_callback = create_realtime_callback(
|
||||
tracking_dir=self.tracking_dir,
|
||||
optimizer_ref=self,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
# Protocol 11: Print multi-objective support notice
|
||||
if self.verbose:
|
||||
print(f"\n[Protocol 11] Multi-objective optimization: ENABLED")
|
||||
print(f"[Protocol 11] Supports single-objective and multi-objective studies")
|
||||
print(f"[Protocol 13] Real-time tracking: ENABLED (per-trial JSON writes)")
|
||||
|
||||
def optimize(
|
||||
self,
|
||||
objective_function: Callable,
|
||||
design_variables: Dict[str, tuple],
|
||||
n_trials: int = 100,
|
||||
target_value: Optional[float] = None,
|
||||
tolerance: float = 0.1,
|
||||
directions: Optional[list] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run intelligent multi-strategy optimization.
|
||||
|
||||
This is the main entry point that orchestrates the entire Protocol 10 process.
|
||||
|
||||
Args:
|
||||
objective_function: Function to minimize, signature: f(trial) -> float or tuple
|
||||
design_variables: Dict of {var_name: (low, high)} bounds
|
||||
n_trials: Total trial budget
|
||||
target_value: Target objective value (optional, for single-objective)
|
||||
tolerance: Acceptable error from target
|
||||
directions: List of 'minimize' or 'maximize' for multi-objective (e.g., ['minimize', 'minimize'])
|
||||
If None, defaults to single-objective minimization
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- best_params: Best parameter configuration found
|
||||
- best_value: Best objective value achieved (or tuple for multi-objective)
|
||||
- strategy_used: Final strategy used
|
||||
- landscape_analysis: Problem characterization
|
||||
- performance_summary: Strategy performance breakdown
|
||||
"""
|
||||
# Store directions for study creation
|
||||
self.directions = directions
|
||||
if not self.enabled:
|
||||
return self._run_fallback_optimization(
|
||||
objective_function, design_variables, n_trials
|
||||
)
|
||||
|
||||
# Stage 1: Adaptive Characterization
|
||||
self.current_phase = "characterization"
|
||||
if self.verbose:
|
||||
self._print_phase_header("STAGE 1: ADAPTIVE CHARACTERIZATION")
|
||||
|
||||
# Get characterization config
|
||||
char_config = self.protocol_config.get('characterization', {})
|
||||
min_trials = char_config.get('min_trials', 10)
|
||||
max_trials = char_config.get('max_trials', 30)
|
||||
confidence_threshold = char_config.get('confidence_threshold', 0.85)
|
||||
check_interval = char_config.get('check_interval', 5)
|
||||
|
||||
# Create stopping criterion
|
||||
stopping_criterion = CharacterizationStoppingCriterion(
|
||||
min_trials=min_trials,
|
||||
max_trials=max_trials,
|
||||
confidence_threshold=confidence_threshold,
|
||||
check_interval=check_interval,
|
||||
verbose=self.verbose,
|
||||
tracking_dir=self.tracking_dir
|
||||
)
|
||||
|
||||
# Create characterization study with random sampler (unbiased exploration)
|
||||
self.study = self._create_study(
|
||||
sampler=optuna.samplers.RandomSampler(),
|
||||
design_variables=design_variables
|
||||
)
|
||||
|
||||
# Run adaptive characterization
|
||||
while not stopping_criterion.should_stop(self.study):
|
||||
# Run batch of trials
|
||||
self.study.optimize(
|
||||
objective_function,
|
||||
n_trials=check_interval,
|
||||
callbacks=[self.realtime_callback]
|
||||
)
|
||||
|
||||
# Analyze landscape
|
||||
self.landscape_cache = self.landscape_analyzer.analyze(self.study)
|
||||
|
||||
# Update stopping criterion
|
||||
if self.landscape_cache.get('ready', False):
|
||||
completed_trials = [t for t in self.study.trials if t.state == optuna.trial.TrialState.COMPLETE]
|
||||
stopping_criterion.update(self.landscape_cache, len(completed_trials))
|
||||
|
||||
# Print characterization summary
|
||||
if self.verbose:
|
||||
print(stopping_criterion.get_summary_report())
|
||||
print_landscape_report(self.landscape_cache)
|
||||
|
||||
# Stage 2: Intelligent Strategy Selection
|
||||
self.current_phase = "strategy_selection"
|
||||
if self.verbose:
|
||||
self._print_phase_header("STAGE 2: STRATEGY SELECTION")
|
||||
|
||||
strategy, recommendation = self.strategy_selector.recommend_strategy(
|
||||
landscape=self.landscape_cache,
|
||||
trials_completed=len(self.study.trials),
|
||||
trials_budget=n_trials
|
||||
)
|
||||
|
||||
self.current_strategy = strategy
|
||||
self.recommendation_cache = recommendation
|
||||
|
||||
# Create new study with recommended strategy
|
||||
sampler = create_sampler_from_config(recommendation['sampler_config'])
|
||||
self.study = self._create_study(
|
||||
sampler=sampler,
|
||||
design_variables=design_variables,
|
||||
load_from_previous=True # Preserve initial trials
|
||||
)
|
||||
|
||||
# Setup adaptive callbacks
|
||||
callbacks = self._create_callbacks(target_value, tolerance)
|
||||
|
||||
# Stage 3: Adaptive Optimization with Monitoring
|
||||
self.current_phase = "adaptive_optimization"
|
||||
if self.verbose:
|
||||
self._print_phase_header("STAGE 3: ADAPTIVE OPTIMIZATION")
|
||||
|
||||
remaining_trials = n_trials - len(self.study.trials)
|
||||
|
||||
if remaining_trials > 0:
|
||||
# Add realtime tracking to callbacks
|
||||
all_callbacks = callbacks + [self.realtime_callback]
|
||||
self.study.optimize(
|
||||
objective_function,
|
||||
n_trials=remaining_trials,
|
||||
callbacks=all_callbacks
|
||||
)
|
||||
|
||||
# Generate final report
|
||||
results = self._compile_results()
|
||||
|
||||
if self.verbose:
|
||||
self._print_final_summary(results)
|
||||
|
||||
return results
|
||||
|
||||
def _create_study(
|
||||
self,
|
||||
sampler: optuna.samplers.BaseSampler,
|
||||
design_variables: Dict[str, tuple],
|
||||
load_from_previous: bool = False
|
||||
) -> optuna.Study:
|
||||
"""
|
||||
Create Optuna study with specified sampler.
|
||||
|
||||
Args:
|
||||
sampler: Optuna sampler to use
|
||||
design_variables: Parameter bounds
|
||||
load_from_previous: Load trials from previous study
|
||||
|
||||
Returns:
|
||||
Configured Optuna study
|
||||
"""
|
||||
# Create study storage
|
||||
storage_path = self.study_dir / "study.db"
|
||||
storage = f"sqlite:///{storage_path}"
|
||||
|
||||
if load_from_previous and storage_path.exists():
|
||||
# Load existing study and change sampler
|
||||
study = optuna.load_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
sampler=sampler
|
||||
)
|
||||
else:
|
||||
# Create new study (single or multi-objective)
|
||||
if self.directions is not None:
|
||||
# Multi-objective optimization
|
||||
study = optuna.create_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
directions=self.directions,
|
||||
sampler=sampler,
|
||||
load_if_exists=True
|
||||
)
|
||||
else:
|
||||
# Single-objective optimization (backward compatibility)
|
||||
study = optuna.create_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
direction='minimize',
|
||||
sampler=sampler,
|
||||
load_if_exists=True
|
||||
)
|
||||
|
||||
return study
|
||||
|
||||
def _create_callbacks(
|
||||
self,
|
||||
target_value: Optional[float],
|
||||
tolerance: float
|
||||
) -> list:
|
||||
"""Create list of Optuna callbacks for adaptive optimization."""
|
||||
callbacks = []
|
||||
|
||||
# Adaptive exploitation callback (from Protocol 8)
|
||||
adaptive_callback = AdaptiveExploitationCallback(
|
||||
target_value=target_value,
|
||||
tolerance=tolerance,
|
||||
min_confidence_for_exploitation=0.65,
|
||||
min_trials=15,
|
||||
verbose=self.verbose,
|
||||
tracking_dir=self.tracking_dir
|
||||
)
|
||||
callbacks.append(adaptive_callback)
|
||||
|
||||
# Strategy switching callback (Protocol 10)
|
||||
strategy_callback = AdaptiveStrategyCallback(
|
||||
transition_manager=self.transition_manager,
|
||||
landscape_analyzer=self.landscape_analyzer,
|
||||
strategy_selector=self.strategy_selector,
|
||||
reanalysis_interval=self.protocol_config.get('reanalysis_interval', 15)
|
||||
)
|
||||
callbacks.append(strategy_callback)
|
||||
|
||||
return callbacks
|
||||
|
||||
def _compile_results(self) -> Dict[str, Any]:
|
||||
"""Compile comprehensive optimization results (supports single and multi-objective)."""
|
||||
is_multi_objective = len(self.study.directions) > 1
|
||||
|
||||
if is_multi_objective:
|
||||
# Multi-objective: Return Pareto front info
|
||||
best_trials = self.study.best_trials
|
||||
if best_trials:
|
||||
# Select the first Pareto-optimal solution as representative
|
||||
representative_trial = best_trials[0]
|
||||
best_params = representative_trial.params
|
||||
best_value = representative_trial.values # Tuple of objectives
|
||||
best_trial_num = representative_trial.number
|
||||
else:
|
||||
best_params = {}
|
||||
best_value = None
|
||||
best_trial_num = None
|
||||
else:
|
||||
# Single-objective: Use standard Optuna API
|
||||
best_params = self.study.best_params
|
||||
best_value = self.study.best_value
|
||||
best_trial_num = self.study.best_trial.number
|
||||
|
||||
return {
|
||||
'best_params': best_params,
|
||||
'best_value': best_value,
|
||||
'best_trial': best_trial_num,
|
||||
'is_multi_objective': is_multi_objective,
|
||||
'pareto_front_size': len(self.study.best_trials) if is_multi_objective else 1,
|
||||
'total_trials': len(self.study.trials),
|
||||
'final_strategy': self.current_strategy,
|
||||
'landscape_analysis': self.landscape_cache,
|
||||
'strategy_recommendation': self.recommendation_cache,
|
||||
'transition_history': self.transition_manager.transition_history,
|
||||
'strategy_performance': {
|
||||
name: {
|
||||
'trials_used': perf.trials_used,
|
||||
'best_value': perf.best_value_achieved,
|
||||
'improvement_rate': perf.improvement_rate
|
||||
}
|
||||
for name, perf in self.transition_manager.strategy_history.items()
|
||||
},
|
||||
'protocol_used': 'Protocol 10: Intelligent Multi-Strategy Optimization'
|
||||
}
|
||||
|
||||
def _run_fallback_optimization(
|
||||
self,
|
||||
objective_function: Callable,
|
||||
design_variables: Dict[str, tuple],
|
||||
n_trials: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Fallback to standard TPE optimization if Protocol 10 is disabled (supports multi-objective)."""
|
||||
if self.verbose:
|
||||
print("\n Protocol 10 disabled - using standard TPE optimization\n")
|
||||
|
||||
sampler = optuna.samplers.TPESampler(multivariate=True, n_startup_trials=10)
|
||||
self.study = self._create_study(sampler, design_variables)
|
||||
|
||||
self.study.optimize(
|
||||
objective_function,
|
||||
n_trials=n_trials,
|
||||
callbacks=[self.realtime_callback]
|
||||
)
|
||||
|
||||
# Handle both single and multi-objective
|
||||
is_multi_objective = len(self.study.directions) > 1
|
||||
|
||||
if is_multi_objective:
|
||||
best_trials = self.study.best_trials
|
||||
if best_trials:
|
||||
representative_trial = best_trials[0]
|
||||
best_params = representative_trial.params
|
||||
best_value = representative_trial.values
|
||||
best_trial_num = representative_trial.number
|
||||
else:
|
||||
best_params = {}
|
||||
best_value = None
|
||||
best_trial_num = None
|
||||
else:
|
||||
best_params = self.study.best_params
|
||||
best_value = self.study.best_value
|
||||
best_trial_num = self.study.best_trial.number
|
||||
|
||||
return {
|
||||
'best_params': best_params,
|
||||
'best_value': best_value,
|
||||
'best_trial': best_trial_num,
|
||||
'is_multi_objective': is_multi_objective,
|
||||
'total_trials': len(self.study.trials),
|
||||
'protocol_used': 'Standard TPE (Protocol 10 disabled)'
|
||||
}
|
||||
|
||||
def _print_phase_header(self, phase_name: str):
|
||||
"""Print formatted phase transition header."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {phase_name}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
def _print_final_summary(self, results: Dict):
|
||||
"""Print comprehensive final optimization summary."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" OPTIMIZATION COMPLETE")
|
||||
print(f"{'='*70}")
|
||||
print(f" Protocol: {results['protocol_used']}")
|
||||
print(f" Total Trials: {results['total_trials']}")
|
||||
|
||||
# Handle both single and multi-objective best values
|
||||
best_value = results['best_value']
|
||||
if results.get('is_multi_objective', False):
|
||||
# Multi-objective: best_value is a tuple
|
||||
formatted_value = str(best_value) # Show as tuple
|
||||
print(f" Best Values (Pareto): {formatted_value} (Trial #{results['best_trial']})")
|
||||
else:
|
||||
# Single-objective: best_value is a scalar
|
||||
print(f" Best Value: {best_value:.6f} (Trial #{results['best_trial']})")
|
||||
|
||||
print(f" Final Strategy: {results.get('final_strategy', 'N/A').upper()}")
|
||||
|
||||
if results.get('transition_history'):
|
||||
print(f"\n Strategy Transitions: {len(results['transition_history'])}")
|
||||
for event in results['transition_history']:
|
||||
print(f" Trial #{event['trial_number']}: "
|
||||
f"{event['from_strategy']} → {event['to_strategy']}")
|
||||
|
||||
print(f"\n Best Parameters:")
|
||||
for param, value in results['best_params'].items():
|
||||
print(f" {param}: {value:.6f}")
|
||||
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
# Print strategy performance report
|
||||
if self.transition_manager.strategy_history:
|
||||
print(self.transition_manager.get_performance_report())
|
||||
|
||||
def save_intelligence_report(self, filepath: Optional[Path] = None):
|
||||
"""
|
||||
Save comprehensive intelligence report to JSON.
|
||||
|
||||
This report contains all decision-making data for transparency,
|
||||
debugging, and transfer learning to future optimizations.
|
||||
"""
|
||||
if filepath is None:
|
||||
filepath = self.tracking_dir / "intelligence_report.json"
|
||||
|
||||
report = {
|
||||
'study_name': self.study_name,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'configuration': self.protocol_config,
|
||||
'landscape_analysis': self.landscape_cache,
|
||||
'initial_recommendation': self.recommendation_cache,
|
||||
'final_strategy': self.current_strategy,
|
||||
'transition_history': self.transition_manager.transition_history,
|
||||
'strategy_performance': {
|
||||
name: {
|
||||
'trials_used': perf.trials_used,
|
||||
'best_value_achieved': perf.best_value_achieved,
|
||||
'improvement_rate': perf.improvement_rate,
|
||||
'last_used_trial': perf.last_used_trial
|
||||
}
|
||||
for name, perf in self.transition_manager.strategy_history.items()
|
||||
},
|
||||
'recommendation_history': self.strategy_selector.recommendation_history
|
||||
}
|
||||
|
||||
try:
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
if self.verbose:
|
||||
print(f"\n Intelligence report saved: {filepath}\n")
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"\n Warning: Failed to save intelligence report: {e}\n")
|
||||
|
||||
|
||||
# Convenience function for quick usage
|
||||
def create_intelligent_optimizer(
|
||||
study_name: str,
|
||||
study_dir: Path,
|
||||
config: Optional[Dict] = None,
|
||||
verbose: bool = True
|
||||
) -> IntelligentOptimizer:
|
||||
"""
|
||||
Factory function to create IntelligentOptimizer with sensible defaults.
|
||||
|
||||
Args:
|
||||
study_name: Name for the optimization study
|
||||
study_dir: Directory for results
|
||||
config: Optional configuration (uses defaults if None)
|
||||
verbose: Print progress
|
||||
|
||||
Returns:
|
||||
Configured IntelligentOptimizer instance
|
||||
"""
|
||||
if config is None:
|
||||
# Default Protocol 10 configuration
|
||||
config = {
|
||||
'intelligent_optimization': {
|
||||
'enabled': True,
|
||||
'characterization_trials': 15,
|
||||
'stagnation_window': 10,
|
||||
'min_improvement_threshold': 0.001,
|
||||
'min_analysis_trials': 10,
|
||||
'reanalysis_interval': 15
|
||||
}
|
||||
}
|
||||
|
||||
return IntelligentOptimizer(
|
||||
study_name=study_name,
|
||||
study_dir=study_dir,
|
||||
config=config,
|
||||
verbose=verbose
|
||||
)
|
||||
1325
optimization_engine/core/method_selector.py
Normal file
1325
optimization_engine/core/method_selector.py
Normal file
File diff suppressed because it is too large
Load Diff
819
optimization_engine/core/runner.py
Normal file
819
optimization_engine/core/runner.py
Normal file
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
Optimization Runner
|
||||
|
||||
Orchestrates the optimization loop:
|
||||
1. Load configuration
|
||||
2. Initialize Optuna study
|
||||
3. For each trial:
|
||||
- Update design variables in NX model
|
||||
- Run simulation
|
||||
- Extract results (OP2 file)
|
||||
- Return objective/constraint values to Optuna
|
||||
4. Save optimization history
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import optuna
|
||||
from optuna.samplers import TPESampler, CmaEsSampler, GPSampler
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
import pickle
|
||||
|
||||
from optimization_engine.plugins import HookManager
|
||||
from optimization_engine.processors.surrogates.training_data_exporter import create_exporter_from_config
|
||||
|
||||
|
||||
class OptimizationRunner:
|
||||
"""
|
||||
Main optimization runner that coordinates:
|
||||
- Optuna optimization loop
|
||||
- NX model parameter updates
|
||||
- Simulation execution
|
||||
- Result extraction
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path,
|
||||
model_updater: Callable,
|
||||
simulation_runner: Callable,
|
||||
result_extractors: Dict[str, Callable]
|
||||
):
|
||||
"""
|
||||
Initialize optimization runner.
|
||||
|
||||
Args:
|
||||
config_path: Path to optimization_config.json
|
||||
model_updater: Function(design_vars: Dict) -> None
|
||||
Updates NX model with new parameter values
|
||||
simulation_runner: Function() -> Path
|
||||
Runs simulation and returns path to result files
|
||||
result_extractors: Dict mapping extractor name to extraction function
|
||||
e.g., {'mass_extractor': extract_mass_func}
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.config = self._load_config()
|
||||
self.model_updater = model_updater
|
||||
self.simulation_runner = simulation_runner
|
||||
self.result_extractors = result_extractors
|
||||
|
||||
# Initialize storage
|
||||
self.history = []
|
||||
self.study = None
|
||||
self.best_params = None
|
||||
self.best_value = None
|
||||
|
||||
# Paths
|
||||
self.output_dir = self.config_path.parent / 'optimization_results'
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Initialize plugin/hook system
|
||||
self.hook_manager = HookManager()
|
||||
plugins_dir = Path(__file__).parent / 'plugins'
|
||||
if plugins_dir.exists():
|
||||
self.hook_manager.load_plugins_from_directory(plugins_dir)
|
||||
summary = self.hook_manager.get_summary()
|
||||
if summary['total_hooks'] > 0:
|
||||
print(f"Loaded {summary['enabled_hooks']}/{summary['total_hooks']} plugins")
|
||||
|
||||
# Initialize training data exporter (if enabled in config)
|
||||
self.training_data_exporter = create_exporter_from_config(self.config)
|
||||
if self.training_data_exporter:
|
||||
print(f"Training data export enabled: {self.training_data_exporter.export_dir}")
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load and validate optimization configuration."""
|
||||
with open(self.config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Validate required fields
|
||||
required = ['design_variables', 'objectives', 'optimization_settings']
|
||||
for field in required:
|
||||
if field not in config:
|
||||
raise ValueError(f"Missing required field in config: {field}")
|
||||
|
||||
return config
|
||||
|
||||
def _get_sampler(self, sampler_name: str):
|
||||
"""Get Optuna sampler instance with enhanced settings."""
|
||||
opt_settings = self.config.get('optimization_settings', {})
|
||||
|
||||
if sampler_name == 'TPE':
|
||||
# Enhanced TPE sampler for better exploration/exploitation balance
|
||||
return TPESampler(
|
||||
n_startup_trials=opt_settings.get('n_startup_trials', 20),
|
||||
n_ei_candidates=opt_settings.get('tpe_n_ei_candidates', 24),
|
||||
multivariate=opt_settings.get('tpe_multivariate', True),
|
||||
seed=42 # For reproducibility
|
||||
)
|
||||
elif sampler_name == 'CMAES':
|
||||
return CmaEsSampler(seed=42)
|
||||
elif sampler_name == 'GP':
|
||||
return GPSampler(seed=42)
|
||||
else:
|
||||
raise ValueError(f"Unknown sampler: {sampler_name}. Choose from ['TPE', 'CMAES', 'GP']")
|
||||
|
||||
def _get_precision(self, var_name: str, units: str) -> int:
|
||||
"""
|
||||
Get appropriate decimal precision based on units.
|
||||
|
||||
Args:
|
||||
var_name: Variable name
|
||||
units: Physical units (mm, degrees, MPa, etc.)
|
||||
|
||||
Returns:
|
||||
Number of decimal places
|
||||
"""
|
||||
precision_map = {
|
||||
'mm': 4,
|
||||
'millimeter': 4,
|
||||
'degrees': 4,
|
||||
'deg': 4,
|
||||
'mpa': 4,
|
||||
'gpa': 6,
|
||||
'kg': 3,
|
||||
'n': 2,
|
||||
'dimensionless': 6
|
||||
}
|
||||
|
||||
units_lower = units.lower() if units else 'dimensionless'
|
||||
return precision_map.get(units_lower, 4) # Default to 4 decimals
|
||||
|
||||
def _get_config_hash(self) -> str:
|
||||
"""
|
||||
Generate hash of critical configuration parameters.
|
||||
Used to detect if configuration has changed between study runs.
|
||||
|
||||
Returns:
|
||||
MD5 hash of design variables, objectives, and constraints
|
||||
"""
|
||||
# Extract critical config parts that affect optimization
|
||||
critical_config = {
|
||||
'design_variables': self.config.get('design_variables', []),
|
||||
'objectives': self.config.get('objectives', []),
|
||||
'constraints': self.config.get('constraints', [])
|
||||
}
|
||||
|
||||
config_str = json.dumps(critical_config, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
|
||||
def _get_study_metadata_path(self, study_name: str) -> Path:
|
||||
"""Get path to study metadata file."""
|
||||
return self.output_dir / f'study_{study_name}_metadata.json'
|
||||
|
||||
def _get_study_db_path(self, study_name: str) -> Path:
|
||||
"""Get path to Optuna study database."""
|
||||
return self.output_dir / f'study_{study_name}.db'
|
||||
|
||||
def _save_study_metadata(self, study_name: str, is_new: bool = False):
|
||||
"""
|
||||
Save study metadata for tracking and resumption.
|
||||
|
||||
Args:
|
||||
study_name: Name of the study
|
||||
is_new: Whether this is a new study (vs resumed)
|
||||
"""
|
||||
metadata_path = self._get_study_metadata_path(study_name)
|
||||
|
||||
# Load existing metadata if resuming
|
||||
if metadata_path.exists() and not is_new:
|
||||
with open(metadata_path, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
else:
|
||||
metadata = {
|
||||
'study_name': study_name,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'config_hash': self._get_config_hash(),
|
||||
'total_trials': 0,
|
||||
'resume_count': 0
|
||||
}
|
||||
|
||||
# Update metadata
|
||||
if self.study:
|
||||
metadata['total_trials'] = len(self.study.trials)
|
||||
metadata['last_updated'] = datetime.now().isoformat()
|
||||
if not is_new and 'created_at' in metadata:
|
||||
metadata['resume_count'] = metadata.get('resume_count', 0) + 1
|
||||
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
def _load_existing_study(self, study_name: str) -> Optional[optuna.Study]:
|
||||
"""
|
||||
Load an existing Optuna study from database.
|
||||
|
||||
Args:
|
||||
study_name: Name of the study to load
|
||||
|
||||
Returns:
|
||||
Loaded study or None if not found
|
||||
"""
|
||||
db_path = self._get_study_db_path(study_name)
|
||||
metadata_path = self._get_study_metadata_path(study_name)
|
||||
|
||||
if not db_path.exists():
|
||||
return None
|
||||
|
||||
# Check if metadata exists and validate config
|
||||
if metadata_path.exists():
|
||||
with open(metadata_path, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
current_hash = self._get_config_hash()
|
||||
stored_hash = metadata.get('config_hash', '')
|
||||
|
||||
if current_hash != stored_hash:
|
||||
print("\n" + "!"*60)
|
||||
print("WARNING: Configuration has changed since study was created!")
|
||||
print("!"*60)
|
||||
print("This may indicate:")
|
||||
print(" - Different design variables")
|
||||
print(" - Different objectives or constraints")
|
||||
print(" - Topology/geometry changes")
|
||||
print("\nRecommendation: Create a NEW study instead of resuming.")
|
||||
print("!"*60)
|
||||
|
||||
response = input("\nContinue anyway? (yes/no): ")
|
||||
if response.lower() not in ['yes', 'y']:
|
||||
print("Aborting. Please create a new study.")
|
||||
return None
|
||||
|
||||
# Load study from SQLite database
|
||||
storage = optuna.storages.RDBStorage(
|
||||
url=f"sqlite:///{db_path}",
|
||||
engine_kwargs={"connect_args": {"timeout": 10.0}}
|
||||
)
|
||||
|
||||
try:
|
||||
study = optuna.load_study(
|
||||
study_name=study_name,
|
||||
storage=storage
|
||||
)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"LOADED EXISTING STUDY: {study_name}")
|
||||
print("="*60)
|
||||
print(f"Trials completed: {len(study.trials)}")
|
||||
if len(study.trials) > 0:
|
||||
print(f"Best value so far: {study.best_value:.6f}")
|
||||
print(f"Best parameters:")
|
||||
for param, value in study.best_params.items():
|
||||
print(f" {param}: {value:.4f}")
|
||||
print("="*60)
|
||||
|
||||
# Load existing history
|
||||
history_json_path = self.output_dir / 'history.json'
|
||||
if history_json_path.exists():
|
||||
with open(history_json_path, 'r') as f:
|
||||
self.history = json.load(f)
|
||||
print(f"Loaded {len(self.history)} previous trials from history")
|
||||
|
||||
return study
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading study: {e}")
|
||||
return None
|
||||
|
||||
def list_studies(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all available studies in the output directory.
|
||||
|
||||
Returns:
|
||||
List of study metadata dictionaries
|
||||
"""
|
||||
studies = []
|
||||
|
||||
for metadata_file in self.output_dir.glob('study_*_metadata.json'):
|
||||
try:
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
studies.append(metadata)
|
||||
except Exception as e:
|
||||
print(f"Error reading {metadata_file}: {e}")
|
||||
|
||||
return sorted(studies, key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
|
||||
def _objective_function(self, trial: optuna.Trial) -> float:
|
||||
"""
|
||||
Optuna objective function.
|
||||
|
||||
This is called for each optimization trial.
|
||||
|
||||
Args:
|
||||
trial: Optuna trial object
|
||||
|
||||
Returns:
|
||||
Objective value (float) or tuple of values for multi-objective
|
||||
"""
|
||||
# 1. Sample design variables with appropriate precision
|
||||
design_vars = {}
|
||||
|
||||
# Handle both dict and list formats for design_variables
|
||||
if isinstance(self.config['design_variables'], dict):
|
||||
# New format: {var_name: {type, min, max, ...}}
|
||||
for var_name, var_info in self.config['design_variables'].items():
|
||||
if var_info['type'] == 'continuous':
|
||||
value = trial.suggest_float(
|
||||
var_name,
|
||||
var_info['min'],
|
||||
var_info['max']
|
||||
)
|
||||
# Round to appropriate precision
|
||||
precision = self._get_precision(var_name, var_info.get('units', ''))
|
||||
design_vars[var_name] = round(value, precision)
|
||||
elif var_info['type'] in ['discrete', 'integer']:
|
||||
design_vars[var_name] = trial.suggest_int(
|
||||
var_name,
|
||||
int(var_info['min']),
|
||||
int(var_info['max'])
|
||||
)
|
||||
else:
|
||||
# Old format: [{name, type, bounds, ...}]
|
||||
for dv in self.config['design_variables']:
|
||||
if dv['type'] == 'continuous':
|
||||
value = trial.suggest_float(
|
||||
dv['name'],
|
||||
dv['bounds'][0],
|
||||
dv['bounds'][1]
|
||||
)
|
||||
# Round to appropriate precision
|
||||
precision = self._get_precision(dv['name'], dv.get('units', ''))
|
||||
design_vars[dv['name']] = round(value, precision)
|
||||
elif dv['type'] == 'discrete':
|
||||
design_vars[dv['name']] = trial.suggest_int(
|
||||
dv['name'],
|
||||
int(dv['bounds'][0]),
|
||||
int(dv['bounds'][1])
|
||||
)
|
||||
|
||||
# Execute pre_solve hooks
|
||||
pre_solve_context = {
|
||||
'trial_number': trial.number,
|
||||
'design_variables': design_vars,
|
||||
'sim_file': self.config.get('sim_file', ''),
|
||||
'working_dir': str(Path.cwd()),
|
||||
'config': self.config,
|
||||
'output_dir': str(self.output_dir) # Add output_dir to context
|
||||
}
|
||||
self.hook_manager.execute_hooks('pre_solve', pre_solve_context, fail_fast=False)
|
||||
|
||||
# 2. Update NX model with new parameters
|
||||
try:
|
||||
self.model_updater(design_vars)
|
||||
except Exception as e:
|
||||
print(f"Error updating model: {e}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# Execute post_mesh hooks (after model update)
|
||||
post_mesh_context = {
|
||||
'trial_number': trial.number,
|
||||
'design_variables': design_vars,
|
||||
'sim_file': self.config.get('sim_file', ''),
|
||||
'working_dir': str(Path.cwd())
|
||||
}
|
||||
self.hook_manager.execute_hooks('post_mesh', post_mesh_context, fail_fast=False)
|
||||
|
||||
# 3. Run simulation
|
||||
try:
|
||||
result_path = self.simulation_runner()
|
||||
except Exception as e:
|
||||
print(f"Error running simulation: {e}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# Execute post_solve hooks
|
||||
post_solve_context = {
|
||||
'trial_number': trial.number,
|
||||
'design_variables': design_vars,
|
||||
'result_path': str(result_path) if result_path else '',
|
||||
'working_dir': str(Path.cwd()),
|
||||
'output_dir': str(self.output_dir) # Add output_dir to context
|
||||
}
|
||||
self.hook_manager.execute_hooks('post_solve', post_solve_context, fail_fast=False)
|
||||
|
||||
# 4. Extract results with appropriate precision
|
||||
extracted_results = {}
|
||||
for obj in self.config['objectives']:
|
||||
extractor_name = obj['extractor']
|
||||
if extractor_name not in self.result_extractors:
|
||||
raise ValueError(f"Missing result extractor: {extractor_name}")
|
||||
|
||||
extractor_func = self.result_extractors[extractor_name]
|
||||
try:
|
||||
result = extractor_func(result_path)
|
||||
metric_name = obj['metric']
|
||||
value = result[metric_name]
|
||||
# Round to appropriate precision based on units
|
||||
precision = self._get_precision(obj['name'], obj.get('units', ''))
|
||||
extracted_results[obj['name']] = round(value, precision)
|
||||
except Exception as e:
|
||||
print(f"Error extracting {obj['name']}: {e}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# Extract constraints with appropriate precision
|
||||
for const in self.config.get('constraints', []):
|
||||
extractor_name = const['extractor']
|
||||
if extractor_name not in self.result_extractors:
|
||||
raise ValueError(f"Missing result extractor: {extractor_name}")
|
||||
|
||||
extractor_func = self.result_extractors[extractor_name]
|
||||
try:
|
||||
result = extractor_func(result_path)
|
||||
metric_name = const['metric']
|
||||
value = result[metric_name]
|
||||
# Round to appropriate precision based on units
|
||||
precision = self._get_precision(const['name'], const.get('units', ''))
|
||||
extracted_results[const['name']] = round(value, precision)
|
||||
except Exception as e:
|
||||
print(f"Error extracting {const['name']}: {e}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# Execute post_extraction hooks
|
||||
post_extraction_context = {
|
||||
'trial_number': trial.number,
|
||||
'design_variables': design_vars,
|
||||
'extracted_results': extracted_results,
|
||||
'result_path': str(result_path) if result_path else '',
|
||||
'working_dir': str(Path.cwd()),
|
||||
'output_dir': str(self.output_dir) # Add output_dir to context
|
||||
}
|
||||
self.hook_manager.execute_hooks('post_extraction', post_extraction_context, fail_fast=False)
|
||||
|
||||
# Export training data (if enabled)
|
||||
if self.training_data_exporter:
|
||||
# Determine .dat and .op2 file paths from result_path
|
||||
# NX naming: sim_name-solution_N.dat and sim_name-solution_N.op2
|
||||
if result_path:
|
||||
sim_dir = Path(result_path).parent if Path(result_path).is_file() else Path(result_path)
|
||||
sim_name = self.config.get('sim_file', '').replace('.sim', '')
|
||||
|
||||
# Try to find the .dat and .op2 files
|
||||
# Typically: sim_name-solution_1.dat and sim_name-solution_1.op2
|
||||
dat_files = list(sim_dir.glob(f"{Path(sim_name).stem}*.dat"))
|
||||
op2_files = list(sim_dir.glob(f"{Path(sim_name).stem}*.op2"))
|
||||
|
||||
if dat_files and op2_files:
|
||||
simulation_files = {
|
||||
'dat_file': dat_files[0], # Use first match
|
||||
'op2_file': op2_files[0]
|
||||
}
|
||||
|
||||
self.training_data_exporter.export_trial(
|
||||
trial_number=trial.number,
|
||||
design_variables=design_vars,
|
||||
results=extracted_results,
|
||||
simulation_files=simulation_files
|
||||
)
|
||||
|
||||
# 5. Evaluate constraints
|
||||
for const in self.config.get('constraints', []):
|
||||
value = extracted_results[const['name']]
|
||||
limit = const['limit']
|
||||
|
||||
if const['type'] == 'upper_bound' and value > limit:
|
||||
# Constraint violated - prune trial or penalize
|
||||
print(f"Constraint violated: {const['name']} = {value:.4f} > {limit:.4f}")
|
||||
raise optuna.TrialPruned()
|
||||
elif const['type'] == 'lower_bound' and value < limit:
|
||||
print(f"Constraint violated: {const['name']} = {value:.4f} < {limit:.4f}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# 6. Calculate weighted objective
|
||||
# For multi-objective: weighted sum approach
|
||||
total_objective = 0.0
|
||||
for obj in self.config['objectives']:
|
||||
value = extracted_results[obj['name']]
|
||||
weight = obj.get('weight', 1.0)
|
||||
direction = obj.get('direction', 'minimize')
|
||||
|
||||
# Normalize by weight
|
||||
if direction == 'minimize':
|
||||
total_objective += weight * value
|
||||
else: # maximize
|
||||
total_objective -= weight * value
|
||||
|
||||
# Execute custom_objective hooks (can modify total_objective)
|
||||
custom_objective_context = {
|
||||
'trial_number': trial.number,
|
||||
'design_variables': design_vars,
|
||||
'extracted_results': extracted_results,
|
||||
'total_objective': total_objective,
|
||||
'working_dir': str(Path.cwd())
|
||||
}
|
||||
custom_results = self.hook_manager.execute_hooks('custom_objective', custom_objective_context, fail_fast=False)
|
||||
|
||||
# Allow hooks to override objective value
|
||||
for result in custom_results:
|
||||
if result and 'total_objective' in result:
|
||||
total_objective = result['total_objective']
|
||||
print(f"Custom objective hook modified total_objective to {total_objective:.6f}")
|
||||
break # Use first hook that provides override
|
||||
|
||||
# 7. Store results in history
|
||||
history_entry = {
|
||||
'trial_number': trial.number,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'design_variables': design_vars,
|
||||
'objectives': {obj['name']: extracted_results[obj['name']] for obj in self.config['objectives']},
|
||||
'constraints': {const['name']: extracted_results[const['name']] for const in self.config.get('constraints', [])},
|
||||
'total_objective': total_objective
|
||||
}
|
||||
self.history.append(history_entry)
|
||||
|
||||
# Save history after each trial
|
||||
self._save_history()
|
||||
|
||||
print(f"\nTrial {trial.number} completed:")
|
||||
print(f" Design vars: {design_vars}")
|
||||
print(f" Objectives: {history_entry['objectives']}")
|
||||
print(f" Total objective: {total_objective:.6f}")
|
||||
|
||||
return total_objective
|
||||
|
||||
def run(
|
||||
self,
|
||||
study_name: Optional[str] = None,
|
||||
n_trials: Optional[int] = None,
|
||||
resume: bool = False
|
||||
) -> optuna.Study:
|
||||
"""
|
||||
Run the optimization.
|
||||
|
||||
Args:
|
||||
study_name: Optional name for the study. If None, generates timestamp-based name.
|
||||
n_trials: Number of trials to run. If None, uses config value.
|
||||
When resuming, this is ADDITIONAL trials to run.
|
||||
resume: If True, attempts to resume existing study. If False, creates new study.
|
||||
|
||||
Returns:
|
||||
Completed Optuna study
|
||||
|
||||
Examples:
|
||||
# New study with 50 trials
|
||||
runner.run(study_name="bracket_opt_v1", n_trials=50)
|
||||
|
||||
# Resume existing study for 25 more trials
|
||||
runner.run(study_name="bracket_opt_v1", n_trials=25, resume=True)
|
||||
|
||||
# New study after topology change
|
||||
runner.run(study_name="bracket_opt_v2", n_trials=50)
|
||||
"""
|
||||
if study_name is None:
|
||||
study_name = f"optimization_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
# Get optimization settings
|
||||
settings = self.config['optimization_settings']
|
||||
if n_trials is None:
|
||||
n_trials = settings.get('n_trials', 100)
|
||||
sampler_name = settings.get('sampler', 'TPE')
|
||||
|
||||
# Try to load existing study if resume=True
|
||||
if resume:
|
||||
existing_study = self._load_existing_study(study_name)
|
||||
if existing_study is not None:
|
||||
self.study = existing_study
|
||||
trials_completed = len(self.study.trials)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"RESUMING OPTIMIZATION: {study_name}")
|
||||
print("="*60)
|
||||
print(f"Trials already completed: {trials_completed}")
|
||||
print(f"Additional trials to run: {n_trials}")
|
||||
print(f"Total trials after completion: {trials_completed + n_trials}")
|
||||
print("="*60)
|
||||
|
||||
# Save metadata indicating this is a resume
|
||||
self._save_study_metadata(study_name, is_new=False)
|
||||
else:
|
||||
print(f"\nNo existing study '{study_name}' found. Creating new study instead.")
|
||||
resume = False
|
||||
|
||||
# Create new study if not resuming or if resume failed
|
||||
if not resume or self.study is None:
|
||||
# Create storage for persistence
|
||||
db_path = self._get_study_db_path(study_name)
|
||||
storage = optuna.storages.RDBStorage(
|
||||
url=f"sqlite:///{db_path}",
|
||||
engine_kwargs={"connect_args": {"timeout": 10.0}}
|
||||
)
|
||||
|
||||
sampler = self._get_sampler(sampler_name)
|
||||
self.study = optuna.create_study(
|
||||
study_name=study_name,
|
||||
direction='minimize', # Total weighted objective is always minimized
|
||||
sampler=sampler,
|
||||
storage=storage,
|
||||
load_if_exists=False # Force new study
|
||||
)
|
||||
|
||||
print("="*60)
|
||||
print(f"STARTING NEW OPTIMIZATION: {study_name}")
|
||||
print("="*60)
|
||||
print(f"Design Variables: {len(self.config['design_variables'])}")
|
||||
print(f"Objectives: {len(self.config['objectives'])}")
|
||||
print(f"Constraints: {len(self.config.get('constraints', []))}")
|
||||
print(f"Trials: {n_trials}")
|
||||
print(f"Sampler: {sampler_name}")
|
||||
print("="*60)
|
||||
|
||||
# Save metadata for new study
|
||||
self._save_study_metadata(study_name, is_new=True)
|
||||
|
||||
# Run optimization
|
||||
start_time = time.time()
|
||||
self.study.optimize(self._objective_function, n_trials=n_trials)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Get best results
|
||||
self.best_params = self.study.best_params
|
||||
self.best_value = self.study.best_value
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("OPTIMIZATION COMPLETE")
|
||||
print("="*60)
|
||||
print(f"Time for this run: {elapsed_time:.1f} seconds ({elapsed_time/60:.1f} minutes)")
|
||||
print(f"Total trials completed: {len(self.study.trials)}")
|
||||
print(f"Best objective value: {self.best_value:.6f}")
|
||||
print(f"Best parameters:")
|
||||
for param, value in self.best_params.items():
|
||||
print(f" {param}: {value:.4f}")
|
||||
print("="*60)
|
||||
|
||||
# Save metadata and final results
|
||||
self._save_study_metadata(study_name)
|
||||
self._save_final_results()
|
||||
|
||||
# Finalize training data export (if enabled)
|
||||
if self.training_data_exporter:
|
||||
self.training_data_exporter.finalize()
|
||||
print(f"Training data export finalized: {self.training_data_exporter.trial_count} trials exported")
|
||||
|
||||
# Post-processing: Visualization and Model Cleanup
|
||||
self._run_post_processing()
|
||||
|
||||
return self.study
|
||||
|
||||
def _save_history(self):
|
||||
"""Save optimization history to CSV and JSON."""
|
||||
# Save as JSON
|
||||
history_json_path = self.output_dir / 'history.json'
|
||||
with open(history_json_path, 'w') as f:
|
||||
json.dump(self.history, f, indent=2)
|
||||
|
||||
# Save as CSV (flattened)
|
||||
if self.history:
|
||||
# Flatten nested dicts for CSV
|
||||
rows = []
|
||||
for entry in self.history:
|
||||
row = {
|
||||
'trial_number': entry['trial_number'],
|
||||
'timestamp': entry['timestamp'],
|
||||
'total_objective': entry['total_objective']
|
||||
}
|
||||
# Add design variables
|
||||
for var_name, var_value in entry['design_variables'].items():
|
||||
row[f'dv_{var_name}'] = var_value
|
||||
# Add objectives
|
||||
for obj_name, obj_value in entry['objectives'].items():
|
||||
row[f'obj_{obj_name}'] = obj_value
|
||||
# Add constraints
|
||||
for const_name, const_value in entry['constraints'].items():
|
||||
row[f'const_{const_name}'] = const_value
|
||||
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
csv_path = self.output_dir / 'history.csv'
|
||||
df.to_csv(csv_path, index=False)
|
||||
|
||||
def _save_final_results(self):
|
||||
"""Save final optimization results summary."""
|
||||
if self.study is None:
|
||||
return
|
||||
|
||||
summary = {
|
||||
'study_name': self.study.study_name,
|
||||
'best_value': self.best_value,
|
||||
'best_params': self.best_params,
|
||||
'n_trials': len(self.study.trials),
|
||||
'configuration': self.config,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
summary_path = self.output_dir / 'optimization_summary.json'
|
||||
with open(summary_path, 'w') as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
print(f"\nResults saved to: {self.output_dir}")
|
||||
print(f" - history.json")
|
||||
print(f" - history.csv")
|
||||
print(f" - optimization_summary.json")
|
||||
|
||||
def _run_post_processing(self):
|
||||
"""
|
||||
Run post-processing tasks: visualization and model cleanup.
|
||||
|
||||
Based on config settings in 'post_processing' section:
|
||||
- generate_plots: Generate matplotlib visualizations
|
||||
- cleanup_models: Delete CAD/FEM files for non-top trials
|
||||
"""
|
||||
post_config = self.config.get('post_processing', {})
|
||||
|
||||
if not post_config:
|
||||
return # No post-processing configured
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("POST-PROCESSING")
|
||||
print("="*60)
|
||||
|
||||
# 1. Generate Visualization Plots
|
||||
if post_config.get('generate_plots', False):
|
||||
print("\nGenerating visualization plots...")
|
||||
try:
|
||||
from optimization_engine.reporting.visualizer import OptimizationVisualizer
|
||||
|
||||
formats = post_config.get('plot_formats', ['png', 'pdf'])
|
||||
visualizer = OptimizationVisualizer(self.output_dir)
|
||||
visualizer.generate_all_plots(save_formats=formats)
|
||||
summary = visualizer.generate_plot_summary()
|
||||
|
||||
print(f" Plots generated: {len(formats)} format(s)")
|
||||
print(f" Improvement: {summary['improvement_percent']:.1f}%")
|
||||
print(f" Location: {visualizer.plots_dir}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARNING: Plot generation failed: {e}")
|
||||
print(" Continuing with optimization results...")
|
||||
|
||||
# 2. Model Cleanup
|
||||
if post_config.get('cleanup_models', False):
|
||||
print("\nCleaning up trial models...")
|
||||
try:
|
||||
from optimization_engine.nx.model_cleanup import ModelCleanup
|
||||
|
||||
keep_n = post_config.get('keep_top_n_models', 10)
|
||||
dry_run = post_config.get('cleanup_dry_run', False)
|
||||
|
||||
cleaner = ModelCleanup(self.output_dir)
|
||||
stats = cleaner.cleanup_models(keep_top_n=keep_n, dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would delete {stats['files_deleted']} files")
|
||||
print(f" [DRY RUN] Would free {stats['space_freed_mb']:.1f} MB")
|
||||
else:
|
||||
print(f" Deleted {stats['files_deleted']} files from {stats['cleaned_trials']} trials")
|
||||
print(f" Space freed: {stats['space_freed_mb']:.1f} MB")
|
||||
print(f" Kept top {stats['kept_trials']} trial models")
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARNING: Model cleanup failed: {e}")
|
||||
print(" All trial files retained...")
|
||||
|
||||
print("="*60 + "\n")
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# This would be replaced with actual NX integration functions
|
||||
def dummy_model_updater(design_vars: Dict[str, float]):
|
||||
"""Dummy function - would update NX model."""
|
||||
print(f"Updating model with: {design_vars}")
|
||||
|
||||
def dummy_simulation_runner() -> Path:
|
||||
"""Dummy function - would run NX simulation."""
|
||||
print("Running simulation...")
|
||||
time.sleep(0.5) # Simulate work
|
||||
return Path("examples/bracket/bracket_sim1-solution_1.op2")
|
||||
|
||||
def dummy_mass_extractor(result_path: Path) -> Dict[str, float]:
|
||||
"""Dummy function - would extract from OP2."""
|
||||
import random
|
||||
return {'total_mass': 0.4 + random.random() * 0.1}
|
||||
|
||||
def dummy_stress_extractor(result_path: Path) -> Dict[str, float]:
|
||||
"""Dummy function - would extract from OP2."""
|
||||
import random
|
||||
return {'max_von_mises': 150.0 + random.random() * 50.0}
|
||||
|
||||
def dummy_displacement_extractor(result_path: Path) -> Dict[str, float]:
|
||||
"""Dummy function - would extract from OP2."""
|
||||
import random
|
||||
return {'max_displacement': 0.8 + random.random() * 0.3}
|
||||
|
||||
# Create runner
|
||||
runner = OptimizationRunner(
|
||||
config_path=Path("examples/bracket/optimization_config.json"),
|
||||
model_updater=dummy_model_updater,
|
||||
simulation_runner=dummy_simulation_runner,
|
||||
result_extractors={
|
||||
'mass_extractor': dummy_mass_extractor,
|
||||
'stress_extractor': dummy_stress_extractor,
|
||||
'displacement_extractor': dummy_displacement_extractor
|
||||
}
|
||||
)
|
||||
|
||||
# Run optimization
|
||||
study = runner.run(study_name="test_bracket_optimization")
|
||||
516
optimization_engine/core/runner_with_neural.py
Normal file
516
optimization_engine/core/runner_with_neural.py
Normal file
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
Neural-Enhanced Optimization Runner
|
||||
|
||||
Extends the base OptimizationRunner with neural network surrogate capabilities
|
||||
from AtomizerField for super-efficient optimization.
|
||||
|
||||
Features:
|
||||
- Automatic neural surrogate integration when models are available
|
||||
- Hybrid optimization with smart FEA/NN switching
|
||||
- Confidence-based fallback to FEA
|
||||
- Speedup tracking and reporting
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable, Tuple
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
import optuna
|
||||
|
||||
from optimization_engine.core.runner import OptimizationRunner
|
||||
from optimization_engine.processors.surrogates.neural_surrogate import (
|
||||
create_surrogate_from_config,
|
||||
create_hybrid_optimizer_from_config,
|
||||
NeuralSurrogate,
|
||||
HybridOptimizer
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NeuralOptimizationRunner(OptimizationRunner):
|
||||
"""
|
||||
Extended optimization runner with neural network surrogate support.
|
||||
|
||||
Seamlessly integrates AtomizerField neural models to achieve 600x-500,000x
|
||||
speedup over traditional FEA-based optimization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path,
|
||||
model_updater: Callable,
|
||||
simulation_runner: Callable,
|
||||
result_extractors: Dict[str, Callable]
|
||||
):
|
||||
"""
|
||||
Initialize neural-enhanced optimization runner.
|
||||
|
||||
Args:
|
||||
config_path: Path to optimization_config.json
|
||||
model_updater: Function to update NX model parameters
|
||||
simulation_runner: Function to run FEA simulation
|
||||
result_extractors: Dictionary of result extraction functions
|
||||
"""
|
||||
# Initialize base class
|
||||
super().__init__(config_path, model_updater, simulation_runner, result_extractors)
|
||||
|
||||
# Initialize neural surrogate components
|
||||
self.neural_surrogate: Optional[NeuralSurrogate] = None
|
||||
self.hybrid_optimizer: Optional[HybridOptimizer] = None
|
||||
self.neural_speedup_tracker = []
|
||||
|
||||
# Try to initialize neural components
|
||||
self._initialize_neural_components()
|
||||
|
||||
def _initialize_neural_components(self):
|
||||
"""Initialize neural surrogate and hybrid optimizer if configured."""
|
||||
try:
|
||||
# Create neural surrogate from config
|
||||
self.neural_surrogate = create_surrogate_from_config(self.config)
|
||||
|
||||
if self.neural_surrogate:
|
||||
logger.info("✓ Neural surrogate initialized successfully")
|
||||
logger.info(f" Model: {self.neural_surrogate.model_checkpoint}")
|
||||
logger.info(f" Confidence threshold: {self.neural_surrogate.confidence_threshold}")
|
||||
|
||||
# Create hybrid optimizer for smart FEA/NN switching
|
||||
self.hybrid_optimizer = create_hybrid_optimizer_from_config(self.config)
|
||||
|
||||
if self.hybrid_optimizer:
|
||||
logger.info("✓ Hybrid optimizer initialized")
|
||||
logger.info(f" Exploration trials: {self.hybrid_optimizer.exploration_trials}")
|
||||
logger.info(f" Validation frequency: {self.hybrid_optimizer.validation_frequency}")
|
||||
else:
|
||||
logger.info("Neural surrogate not configured - using standard FEA optimization")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize neural components: {e}")
|
||||
logger.info("Falling back to standard FEA optimization")
|
||||
|
||||
def _objective_function_with_neural(self, trial: optuna.Trial) -> float:
|
||||
"""
|
||||
Enhanced objective function with neural network surrogate support.
|
||||
|
||||
Attempts to use neural network for fast prediction, falls back to FEA
|
||||
when confidence is low or validation is needed.
|
||||
|
||||
Args:
|
||||
trial: Optuna trial object
|
||||
|
||||
Returns:
|
||||
Objective value (float)
|
||||
"""
|
||||
# Sample design variables (same as base class)
|
||||
design_vars = self._sample_design_variables(trial)
|
||||
|
||||
# Decide whether to use neural network or FEA
|
||||
use_neural = False
|
||||
nn_prediction = None
|
||||
nn_confidence = 0.0
|
||||
|
||||
if self.neural_surrogate and self.hybrid_optimizer:
|
||||
# Check if hybrid optimizer recommends using NN
|
||||
if self.hybrid_optimizer.should_use_nn(trial.number):
|
||||
# Try neural network prediction
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Get case data for the current model
|
||||
case_data = self._prepare_case_data(design_vars)
|
||||
|
||||
# Get neural network prediction
|
||||
predictions, confidence, used_nn = self.neural_surrogate.predict(
|
||||
design_vars,
|
||||
case_data=case_data
|
||||
)
|
||||
|
||||
if used_nn and predictions is not None:
|
||||
# Successfully used neural network
|
||||
nn_time = time.time() - start_time
|
||||
use_neural = True
|
||||
nn_prediction = predictions
|
||||
nn_confidence = confidence
|
||||
|
||||
logger.info(f"Trial {trial.number}: Used neural network (confidence: {confidence:.2%}, time: {nn_time:.3f}s)")
|
||||
|
||||
# Track speedup
|
||||
self.neural_speedup_tracker.append({
|
||||
'trial': trial.number,
|
||||
'nn_time': nn_time,
|
||||
'confidence': confidence
|
||||
})
|
||||
else:
|
||||
logger.info(f"Trial {trial.number}: Neural confidence too low ({confidence:.2%}), using FEA")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Trial {trial.number}: Neural prediction failed: {e}, using FEA")
|
||||
|
||||
# Execute hooks and get results
|
||||
if use_neural and nn_prediction is not None:
|
||||
# Use neural network results
|
||||
extracted_results = self._process_neural_results(nn_prediction, design_vars)
|
||||
|
||||
# Skip model update and simulation since we used NN
|
||||
result_path = None
|
||||
|
||||
else:
|
||||
# Fall back to standard FEA (using base class method)
|
||||
return super()._objective_function(trial)
|
||||
|
||||
# Process constraints and objectives (same as base class)
|
||||
return self._evaluate_objectives_and_constraints(
|
||||
trial, design_vars, extracted_results, result_path
|
||||
)
|
||||
|
||||
def _sample_design_variables(self, trial: optuna.Trial) -> Dict[str, float]:
|
||||
"""
|
||||
Sample design variables from trial (extracted from base class).
|
||||
|
||||
Args:
|
||||
trial: Optuna trial object
|
||||
|
||||
Returns:
|
||||
Dictionary of design variable values
|
||||
"""
|
||||
design_vars = {}
|
||||
|
||||
# Handle both dict and list formats for design_variables
|
||||
if isinstance(self.config['design_variables'], dict):
|
||||
for var_name, var_info in self.config['design_variables'].items():
|
||||
if var_info['type'] == 'continuous':
|
||||
value = trial.suggest_float(
|
||||
var_name,
|
||||
var_info['min'],
|
||||
var_info['max']
|
||||
)
|
||||
precision = self._get_precision(var_name, var_info.get('units', ''))
|
||||
design_vars[var_name] = round(value, precision)
|
||||
elif var_info['type'] in ['discrete', 'integer']:
|
||||
design_vars[var_name] = trial.suggest_int(
|
||||
var_name,
|
||||
int(var_info['min']),
|
||||
int(var_info['max'])
|
||||
)
|
||||
else:
|
||||
# Old format
|
||||
for dv in self.config['design_variables']:
|
||||
if dv['type'] == 'continuous':
|
||||
value = trial.suggest_float(
|
||||
dv['name'],
|
||||
dv['bounds'][0],
|
||||
dv['bounds'][1]
|
||||
)
|
||||
precision = self._get_precision(dv['name'], dv.get('units', ''))
|
||||
design_vars[dv['name']] = round(value, precision)
|
||||
elif dv['type'] == 'discrete':
|
||||
design_vars[dv['name']] = trial.suggest_int(
|
||||
dv['name'],
|
||||
int(dv['bounds'][0]),
|
||||
int(dv['bounds'][1])
|
||||
)
|
||||
|
||||
return design_vars
|
||||
|
||||
def _prepare_case_data(self, design_vars: Dict[str, float]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Prepare case-specific data for neural network prediction.
|
||||
|
||||
This includes mesh file paths, boundary conditions, loads, etc.
|
||||
|
||||
Args:
|
||||
design_vars: Current design variable values
|
||||
|
||||
Returns:
|
||||
Case data dictionary or None
|
||||
"""
|
||||
try:
|
||||
case_data = {
|
||||
'fem_file': self.config.get('fem_file', ''),
|
||||
'sim_file': self.config.get('sim_file', ''),
|
||||
'design_variables': design_vars,
|
||||
# Add any case-specific data needed by the neural network
|
||||
}
|
||||
|
||||
# Add boundary conditions if specified
|
||||
if 'boundary_conditions' in self.config:
|
||||
case_data['boundary_conditions'] = self.config['boundary_conditions']
|
||||
|
||||
# Add loads if specified
|
||||
if 'loads' in self.config:
|
||||
case_data['loads'] = self.config['loads']
|
||||
|
||||
return case_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not prepare case data: {e}")
|
||||
return None
|
||||
|
||||
def _process_neural_results(
|
||||
self,
|
||||
nn_prediction: Dict[str, Any],
|
||||
design_vars: Dict[str, float]
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Process neural network predictions into extracted results format.
|
||||
|
||||
Args:
|
||||
nn_prediction: Raw neural network predictions
|
||||
design_vars: Current design variable values
|
||||
|
||||
Returns:
|
||||
Dictionary of extracted results matching objective/constraint names
|
||||
"""
|
||||
extracted_results = {}
|
||||
|
||||
# Map neural network outputs to objective/constraint names
|
||||
for obj in self.config['objectives']:
|
||||
obj_name = obj['name']
|
||||
|
||||
# Try to find matching prediction
|
||||
if obj_name in nn_prediction:
|
||||
value = nn_prediction[obj_name]
|
||||
elif 'metric' in obj and obj['metric'] in nn_prediction:
|
||||
value = nn_prediction[obj['metric']]
|
||||
else:
|
||||
# Try common mappings
|
||||
metric_mappings = {
|
||||
'max_stress': ['max_von_mises_stress', 'stress', 'von_mises'],
|
||||
'max_displacement': ['max_displacement', 'displacement', 'disp'],
|
||||
'mass': ['mass', 'weight'],
|
||||
'volume': ['volume'],
|
||||
'compliance': ['compliance', 'strain_energy'],
|
||||
'frequency': ['frequency', 'natural_frequency', 'freq']
|
||||
}
|
||||
|
||||
value = None
|
||||
for mapped_names in metric_mappings.get(obj_name, []):
|
||||
if mapped_names in nn_prediction:
|
||||
value = nn_prediction[mapped_names]
|
||||
break
|
||||
|
||||
if value is None:
|
||||
raise ValueError(f"Could not find neural prediction for objective '{obj_name}'")
|
||||
|
||||
# Apply appropriate precision
|
||||
precision = self._get_precision(obj_name, obj.get('units', ''))
|
||||
extracted_results[obj_name] = round(float(value), precision)
|
||||
|
||||
# Process constraints similarly
|
||||
for const in self.config.get('constraints', []):
|
||||
const_name = const['name']
|
||||
|
||||
if const_name in nn_prediction:
|
||||
value = nn_prediction[const_name]
|
||||
elif 'metric' in const and const['metric'] in nn_prediction:
|
||||
value = nn_prediction[const['metric']]
|
||||
else:
|
||||
# Try to reuse objective values if constraint uses same metric
|
||||
if const_name in extracted_results:
|
||||
value = extracted_results[const_name]
|
||||
else:
|
||||
raise ValueError(f"Could not find neural prediction for constraint '{const_name}'")
|
||||
|
||||
precision = self._get_precision(const_name, const.get('units', ''))
|
||||
extracted_results[const_name] = round(float(value), precision)
|
||||
|
||||
return extracted_results
|
||||
|
||||
def _evaluate_objectives_and_constraints(
|
||||
self,
|
||||
trial: optuna.Trial,
|
||||
design_vars: Dict[str, float],
|
||||
extracted_results: Dict[str, float],
|
||||
result_path: Optional[Path]
|
||||
) -> float:
|
||||
"""
|
||||
Evaluate objectives and constraints (extracted from base class).
|
||||
|
||||
Args:
|
||||
trial: Optuna trial object
|
||||
design_vars: Design variable values
|
||||
extracted_results: Extracted simulation/NN results
|
||||
result_path: Path to result files (None if using NN)
|
||||
|
||||
Returns:
|
||||
Total objective value
|
||||
"""
|
||||
# Export training data if using FEA
|
||||
if self.training_data_exporter and result_path:
|
||||
self._export_training_data(trial.number, design_vars, extracted_results, result_path)
|
||||
|
||||
# Evaluate constraints
|
||||
for const in self.config.get('constraints', []):
|
||||
value = extracted_results[const['name']]
|
||||
limit = const['limit']
|
||||
|
||||
if const['type'] == 'upper_bound' and value > limit:
|
||||
logger.info(f"Constraint violated: {const['name']} = {value:.4f} > {limit:.4f}")
|
||||
raise optuna.TrialPruned()
|
||||
elif const['type'] == 'lower_bound' and value < limit:
|
||||
logger.info(f"Constraint violated: {const['name']} = {value:.4f} < {limit:.4f}")
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# Calculate weighted objective
|
||||
total_objective = 0.0
|
||||
for obj in self.config['objectives']:
|
||||
value = extracted_results[obj['name']]
|
||||
weight = obj.get('weight', 1.0)
|
||||
direction = obj.get('direction', 'minimize')
|
||||
|
||||
if direction == 'minimize':
|
||||
total_objective += weight * value
|
||||
else: # maximize
|
||||
total_objective -= weight * value
|
||||
|
||||
# Store in history
|
||||
history_entry = {
|
||||
'trial_number': trial.number,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'design_variables': design_vars,
|
||||
'objectives': {obj['name']: extracted_results[obj['name']] for obj in self.config['objectives']},
|
||||
'constraints': {const['name']: extracted_results[const['name']] for const in self.config.get('constraints', [])},
|
||||
'total_objective': total_objective,
|
||||
'used_neural': result_path is None # Track if NN was used
|
||||
}
|
||||
self.history.append(history_entry)
|
||||
|
||||
# Save history
|
||||
self._save_history()
|
||||
|
||||
logger.info(f"Trial {trial.number} completed:")
|
||||
logger.info(f" Design vars: {design_vars}")
|
||||
logger.info(f" Objectives: {history_entry['objectives']}")
|
||||
logger.info(f" Total objective: {total_objective:.6f}")
|
||||
if history_entry.get('used_neural'):
|
||||
logger.info(f" Method: Neural Network")
|
||||
|
||||
return total_objective
|
||||
|
||||
def run(
|
||||
self,
|
||||
study_name: Optional[str] = None,
|
||||
n_trials: Optional[int] = None,
|
||||
resume: bool = False
|
||||
) -> optuna.Study:
|
||||
"""
|
||||
Run neural-enhanced optimization.
|
||||
|
||||
Args:
|
||||
study_name: Optional study name
|
||||
n_trials: Number of trials to run
|
||||
resume: Whether to resume existing study
|
||||
|
||||
Returns:
|
||||
Completed Optuna study
|
||||
"""
|
||||
# Override objective function if neural surrogate is available
|
||||
if self.neural_surrogate:
|
||||
# Temporarily replace objective function
|
||||
original_objective = self._objective_function
|
||||
self._objective_function = self._objective_function_with_neural
|
||||
|
||||
try:
|
||||
# Run optimization using base class
|
||||
study = super().run(study_name, n_trials, resume)
|
||||
|
||||
# Print neural speedup summary if applicable
|
||||
if self.neural_speedup_tracker:
|
||||
self._print_speedup_summary()
|
||||
|
||||
return study
|
||||
|
||||
finally:
|
||||
# Restore original objective function if replaced
|
||||
if self.neural_surrogate:
|
||||
self._objective_function = original_objective
|
||||
|
||||
def _print_speedup_summary(self):
|
||||
"""Print summary of neural network speedup achieved."""
|
||||
if not self.neural_speedup_tracker:
|
||||
return
|
||||
|
||||
nn_trials = len(self.neural_speedup_tracker)
|
||||
total_trials = len(self.history)
|
||||
nn_percentage = (nn_trials / total_trials) * 100
|
||||
|
||||
avg_nn_time = np.mean([t['nn_time'] for t in self.neural_speedup_tracker])
|
||||
avg_confidence = np.mean([t['confidence'] for t in self.neural_speedup_tracker])
|
||||
|
||||
# Estimate FEA time (rough estimate if not tracked)
|
||||
estimated_fea_time = 30 * 60 # 30 minutes in seconds
|
||||
estimated_speedup = estimated_fea_time / avg_nn_time
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("NEURAL NETWORK SPEEDUP SUMMARY")
|
||||
print("="*60)
|
||||
print(f"Trials using neural network: {nn_trials}/{total_trials} ({nn_percentage:.1f}%)")
|
||||
print(f"Average NN inference time: {avg_nn_time:.3f} seconds")
|
||||
print(f"Average NN confidence: {avg_confidence:.1%}")
|
||||
print(f"Estimated speedup: {estimated_speedup:.0f}x")
|
||||
print(f"Time saved: ~{(estimated_fea_time - avg_nn_time) * nn_trials / 3600:.1f} hours")
|
||||
print("="*60)
|
||||
|
||||
def update_neural_model(self, new_checkpoint: Path):
|
||||
"""
|
||||
Update the neural network model checkpoint.
|
||||
|
||||
Useful for updating to a newly trained model during optimization.
|
||||
|
||||
Args:
|
||||
new_checkpoint: Path to new model checkpoint
|
||||
"""
|
||||
if self.neural_surrogate:
|
||||
try:
|
||||
self.neural_surrogate.load_model(new_checkpoint)
|
||||
logger.info(f"Updated neural model to: {new_checkpoint}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update neural model: {e}")
|
||||
|
||||
def train_neural_model(self, training_data_dir: Path, epochs: int = 100):
|
||||
"""
|
||||
Train a new neural model on collected data.
|
||||
|
||||
Args:
|
||||
training_data_dir: Directory containing training data
|
||||
epochs: Number of training epochs
|
||||
"""
|
||||
if self.hybrid_optimizer:
|
||||
try:
|
||||
model_path = self.hybrid_optimizer.train_surrogate_model(training_data_dir, epochs)
|
||||
|
||||
# Update to use the newly trained model
|
||||
if model_path and self.neural_surrogate:
|
||||
self.update_neural_model(model_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to train neural model: {e}")
|
||||
|
||||
|
||||
def create_neural_runner(
|
||||
config_path: Path,
|
||||
model_updater: Callable,
|
||||
simulation_runner: Callable,
|
||||
result_extractors: Dict[str, Callable]
|
||||
) -> NeuralOptimizationRunner:
|
||||
"""
|
||||
Factory function to create a neural-enhanced optimization runner.
|
||||
|
||||
Args:
|
||||
config_path: Path to optimization configuration
|
||||
model_updater: Function to update model parameters
|
||||
simulation_runner: Function to run simulation
|
||||
result_extractors: Dictionary of result extraction functions
|
||||
|
||||
Returns:
|
||||
NeuralOptimizationRunner instance
|
||||
"""
|
||||
return NeuralOptimizationRunner(
|
||||
config_path,
|
||||
model_updater,
|
||||
simulation_runner,
|
||||
result_extractors
|
||||
)
|
||||
433
optimization_engine/core/strategy_portfolio.py
Normal file
433
optimization_engine/core/strategy_portfolio.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Strategy Portfolio Manager - Dynamic multi-strategy optimization.
|
||||
|
||||
This module manages dynamic switching between optimization strategies during a run.
|
||||
It detects stagnation, evaluates alternative strategies, and orchestrates transitions
|
||||
to maintain optimization progress.
|
||||
|
||||
Part of Protocol 10: Intelligent Multi-Strategy Optimization (IMSO)
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import optuna
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyPerformance:
|
||||
"""Track performance metrics for a strategy."""
|
||||
strategy_name: str
|
||||
trials_used: int
|
||||
best_value_achieved: float
|
||||
improvement_rate: float # Improvement per trial
|
||||
last_used_trial: int
|
||||
avg_trial_time: float = 0.0
|
||||
|
||||
|
||||
class StrategyTransitionManager:
|
||||
"""
|
||||
Manages transitions between optimization strategies.
|
||||
|
||||
Implements intelligent strategy switching based on:
|
||||
1. Stagnation detection
|
||||
2. Landscape characteristics
|
||||
3. Strategy performance history
|
||||
4. User-defined transition rules
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stagnation_window: int = 10,
|
||||
min_improvement_threshold: float = 0.001,
|
||||
verbose: bool = True,
|
||||
tracking_dir: Optional[Path] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
stagnation_window: Number of trials to check for stagnation
|
||||
min_improvement_threshold: Minimum relative improvement to avoid stagnation
|
||||
verbose: Print transition decisions
|
||||
tracking_dir: Directory to save transition logs
|
||||
"""
|
||||
self.stagnation_window = stagnation_window
|
||||
self.min_improvement = min_improvement_threshold
|
||||
self.verbose = verbose
|
||||
self.tracking_dir = tracking_dir
|
||||
|
||||
# Track strategy performance
|
||||
self.strategy_history: Dict[str, StrategyPerformance] = {}
|
||||
self.current_strategy: Optional[str] = None
|
||||
self.transition_history: List[Dict] = []
|
||||
|
||||
# Initialize tracking files
|
||||
if tracking_dir:
|
||||
self.tracking_dir = Path(tracking_dir)
|
||||
self.tracking_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.transition_log_file = self.tracking_dir / "strategy_transitions.json"
|
||||
self.performance_log_file = self.tracking_dir / "strategy_performance.json"
|
||||
|
||||
# Load existing history
|
||||
self._load_transition_history()
|
||||
|
||||
def should_switch_strategy(
|
||||
self,
|
||||
study: optuna.Study,
|
||||
landscape: Optional[Dict] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Determine if strategy should be switched.
|
||||
|
||||
Args:
|
||||
study: Optuna study
|
||||
landscape: Current landscape analysis (optional)
|
||||
|
||||
Returns:
|
||||
(should_switch, reason)
|
||||
"""
|
||||
completed_trials = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
|
||||
|
||||
if len(completed_trials) < self.stagnation_window:
|
||||
return False, "Insufficient trials for stagnation analysis"
|
||||
|
||||
# Check for stagnation in recent trials
|
||||
recent_trials = completed_trials[-self.stagnation_window:]
|
||||
is_stagnant, stagnation_reason = self._detect_stagnation(recent_trials)
|
||||
|
||||
if is_stagnant:
|
||||
return True, stagnation_reason
|
||||
|
||||
# Check if landscape changed (would require re-analysis)
|
||||
if landscape and self._landscape_changed(landscape):
|
||||
return True, "Landscape characteristics changed - re-evaluating strategy"
|
||||
|
||||
# Check if current strategy hit its theoretical limit
|
||||
if self._strategy_exhausted(study, landscape):
|
||||
return True, "Current strategy reached convergence limit"
|
||||
|
||||
return False, "Strategy performing adequately"
|
||||
|
||||
def _detect_stagnation(self, recent_trials: List) -> Tuple[bool, str]:
|
||||
"""
|
||||
Detect if optimization has stagnated.
|
||||
|
||||
Stagnation indicators:
|
||||
1. No improvement in best value
|
||||
2. High variance in recent objectives (thrashing)
|
||||
3. Repeated similar parameter configurations
|
||||
|
||||
[Protocol 11] Multi-objective NOT supported - stagnation detection
|
||||
requires a single objective value. Skip for multi-objective studies.
|
||||
"""
|
||||
if len(recent_trials) < 3:
|
||||
return False, ""
|
||||
|
||||
# [Protocol 11] Skip stagnation detection for multi-objective
|
||||
# Multi-objective has a Pareto front, not a single "best value"
|
||||
if recent_trials and recent_trials[0].values is not None:
|
||||
# Multi-objective trial (has .values instead of .value)
|
||||
return False, "[Protocol 11] Stagnation detection skipped for multi-objective"
|
||||
|
||||
recent_values = [t.value for t in recent_trials]
|
||||
|
||||
# 1. Check for improvement in best value
|
||||
best_values = []
|
||||
current_best = float('inf')
|
||||
for value in recent_values:
|
||||
current_best = min(current_best, value)
|
||||
best_values.append(current_best)
|
||||
|
||||
# Calculate improvement over window
|
||||
if len(best_values) >= 2:
|
||||
initial_best = best_values[0]
|
||||
final_best = best_values[-1]
|
||||
|
||||
if initial_best > 0:
|
||||
relative_improvement = (initial_best - final_best) / initial_best
|
||||
else:
|
||||
relative_improvement = abs(final_best - initial_best)
|
||||
|
||||
if relative_improvement < self.min_improvement:
|
||||
return True, f"Stagnation detected: <{self.min_improvement:.1%} improvement in {self.stagnation_window} trials"
|
||||
|
||||
# 2. Check for thrashing (high variance without improvement)
|
||||
recent_variance = np.var(recent_values)
|
||||
recent_mean = np.mean(recent_values)
|
||||
|
||||
if recent_mean > 0:
|
||||
coefficient_of_variation = np.sqrt(recent_variance) / recent_mean
|
||||
|
||||
if coefficient_of_variation > 0.3: # High variance
|
||||
# If high variance but no improvement, we're thrashing
|
||||
if best_values[0] == best_values[-1]:
|
||||
return True, f"Thrashing detected: High variance ({coefficient_of_variation:.2f}) without improvement"
|
||||
|
||||
return False, ""
|
||||
|
||||
def _landscape_changed(self, landscape: Dict) -> bool:
|
||||
"""
|
||||
Detect if landscape characteristics changed significantly.
|
||||
|
||||
This would indicate we're in a different region of search space.
|
||||
"""
|
||||
# This is a placeholder - would need to track landscape history
|
||||
# For now, return False (no change detection)
|
||||
return False
|
||||
|
||||
def _strategy_exhausted(
|
||||
self,
|
||||
study: optuna.Study,
|
||||
landscape: Optional[Dict]
|
||||
) -> bool:
|
||||
"""
|
||||
Check if current strategy has reached its theoretical limit.
|
||||
|
||||
Different strategies have different convergence properties:
|
||||
- CMA-ES: Fast convergence but can get stuck in local minimum
|
||||
- TPE: Slower convergence but better global exploration
|
||||
- GP-BO: Sample efficient but plateaus after exploration
|
||||
"""
|
||||
if not self.current_strategy or not landscape:
|
||||
return False
|
||||
|
||||
# CMA-ES exhaustion: High convergence in smooth landscape
|
||||
if self.current_strategy == 'cmaes':
|
||||
if landscape.get('smoothness', 0) > 0.7:
|
||||
# Check if we've converged (low variance in recent trials)
|
||||
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
|
||||
if len(completed) >= 20:
|
||||
recent_params = []
|
||||
for trial in completed[-10:]:
|
||||
recent_params.append(list(trial.params.values()))
|
||||
|
||||
recent_params = np.array(recent_params)
|
||||
param_variance = np.var(recent_params, axis=0)
|
||||
|
||||
# If variance is very low, CMA-ES has converged
|
||||
if np.all(param_variance < 0.01):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def record_strategy_performance(
|
||||
self,
|
||||
strategy_name: str,
|
||||
study: optuna.Study,
|
||||
trial: optuna.trial.FrozenTrial
|
||||
):
|
||||
"""Record performance metrics for current strategy."""
|
||||
if strategy_name not in self.strategy_history:
|
||||
self.strategy_history[strategy_name] = StrategyPerformance(
|
||||
strategy_name=strategy_name,
|
||||
trials_used=0,
|
||||
best_value_achieved=float('inf'),
|
||||
improvement_rate=0.0,
|
||||
last_used_trial=0
|
||||
)
|
||||
|
||||
perf = self.strategy_history[strategy_name]
|
||||
perf.trials_used += 1
|
||||
perf.best_value_achieved = min(perf.best_value_achieved, trial.value)
|
||||
perf.last_used_trial = trial.number
|
||||
|
||||
# Calculate improvement rate
|
||||
if perf.trials_used > 1:
|
||||
initial_best = study.trials[max(0, trial.number - perf.trials_used)].value
|
||||
perf.improvement_rate = (initial_best - perf.best_value_achieved) / perf.trials_used
|
||||
|
||||
def execute_strategy_switch(
|
||||
self,
|
||||
study: optuna.Study,
|
||||
from_strategy: str,
|
||||
to_strategy: str,
|
||||
reason: str,
|
||||
trial_number: int
|
||||
):
|
||||
"""
|
||||
Execute strategy switch and log the transition.
|
||||
|
||||
Args:
|
||||
study: Optuna study
|
||||
from_strategy: Current strategy
|
||||
to_strategy: New strategy to switch to
|
||||
reason: Reason for switching
|
||||
trial_number: Current trial number
|
||||
"""
|
||||
transition_event = {
|
||||
'trial_number': trial_number,
|
||||
'from_strategy': from_strategy,
|
||||
'to_strategy': to_strategy,
|
||||
'reason': reason,
|
||||
'best_value_at_switch': study.best_value,
|
||||
'total_trials': len(study.trials),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
self.transition_history.append(transition_event)
|
||||
self.current_strategy = to_strategy
|
||||
|
||||
# Save transition log
|
||||
if self.tracking_dir:
|
||||
try:
|
||||
with open(self.transition_log_file, 'w') as f:
|
||||
json.dump(self.transition_history, f, indent=2)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Failed to save transition log: {e}")
|
||||
|
||||
if self.verbose:
|
||||
self._print_transition(transition_event)
|
||||
|
||||
def _print_transition(self, event: Dict):
|
||||
"""Print formatted transition announcement."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" STRATEGY TRANSITION")
|
||||
print(f"{'='*70}")
|
||||
print(f" Trial #{event['trial_number']}")
|
||||
print(f" {event['from_strategy'].upper()} -> {event['to_strategy'].upper()}")
|
||||
print(f" Reason: {event['reason']}")
|
||||
print(f" Best value at transition: {event['best_value_at_switch']:.6f}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
def _load_transition_history(self):
|
||||
"""Load existing transition history from file."""
|
||||
if self.transition_log_file and self.transition_log_file.exists():
|
||||
try:
|
||||
with open(self.transition_log_file, 'r') as f:
|
||||
self.transition_history = json.load(f)
|
||||
|
||||
# Restore current strategy from history
|
||||
if self.transition_history:
|
||||
self.current_strategy = self.transition_history[-1]['to_strategy']
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Failed to load transition history: {e}")
|
||||
|
||||
def save_performance_summary(self):
|
||||
"""Save strategy performance summary to file."""
|
||||
if not self.tracking_dir:
|
||||
return
|
||||
|
||||
summary = {
|
||||
'strategies': {
|
||||
name: asdict(perf)
|
||||
for name, perf in self.strategy_history.items()
|
||||
},
|
||||
'current_strategy': self.current_strategy,
|
||||
'total_transitions': len(self.transition_history)
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.performance_log_file, 'w') as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Failed to save performance summary: {e}")
|
||||
|
||||
def get_performance_report(self) -> str:
|
||||
"""Generate human-readable performance report."""
|
||||
if not self.strategy_history:
|
||||
return "No strategy performance data available"
|
||||
|
||||
report = "\n" + "="*70 + "\n"
|
||||
report += " STRATEGY PERFORMANCE SUMMARY\n"
|
||||
report += "="*70 + "\n"
|
||||
|
||||
for name, perf in self.strategy_history.items():
|
||||
report += f"\n {name.upper()}:\n"
|
||||
report += f" Trials used: {perf.trials_used}\n"
|
||||
report += f" Best value: {perf.best_value_achieved:.6f}\n"
|
||||
report += f" Improvement rate: {perf.improvement_rate:.6f} per trial\n"
|
||||
report += f" Last used: Trial #{perf.last_used_trial}\n"
|
||||
|
||||
if self.transition_history:
|
||||
report += f"\n TRANSITIONS: {len(self.transition_history)}\n"
|
||||
for event in self.transition_history:
|
||||
report += f" Trial #{event['trial_number']}: "
|
||||
report += f"{event['from_strategy']} → {event['to_strategy']}\n"
|
||||
report += f" Reason: {event['reason']}\n"
|
||||
|
||||
report += "="*70 + "\n"
|
||||
|
||||
return report
|
||||
|
||||
|
||||
class AdaptiveStrategyCallback:
|
||||
"""
|
||||
Optuna callback that manages adaptive strategy switching.
|
||||
|
||||
This callback integrates with the IntelligentOptimizer to:
|
||||
1. Monitor strategy performance
|
||||
2. Detect when switching is needed
|
||||
3. Coordinate with landscape analyzer and strategy selector
|
||||
4. Execute transitions
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transition_manager: StrategyTransitionManager,
|
||||
landscape_analyzer,
|
||||
strategy_selector,
|
||||
reanalysis_interval: int = 15
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
transition_manager: StrategyTransitionManager instance
|
||||
landscape_analyzer: LandscapeAnalyzer instance
|
||||
strategy_selector: IntelligentStrategySelector instance
|
||||
reanalysis_interval: How often to re-analyze landscape
|
||||
"""
|
||||
self.transition_manager = transition_manager
|
||||
self.landscape_analyzer = landscape_analyzer
|
||||
self.strategy_selector = strategy_selector
|
||||
self.reanalysis_interval = reanalysis_interval
|
||||
|
||||
self.last_landscape = None
|
||||
self.last_recommendation = None
|
||||
|
||||
def __call__(self, study: optuna.Study, trial: optuna.trial.FrozenTrial):
|
||||
"""Called after each trial completes."""
|
||||
if trial.state != optuna.trial.TrialState.COMPLETE:
|
||||
return
|
||||
|
||||
current_strategy = self.transition_manager.current_strategy
|
||||
|
||||
# Record performance
|
||||
if current_strategy:
|
||||
self.transition_manager.record_strategy_performance(
|
||||
current_strategy, study, trial
|
||||
)
|
||||
|
||||
# Periodically re-analyze landscape
|
||||
if trial.number % self.reanalysis_interval == 0:
|
||||
self.last_landscape = self.landscape_analyzer.analyze(study)
|
||||
|
||||
# Check if we should switch
|
||||
should_switch, reason = self.transition_manager.should_switch_strategy(
|
||||
study, self.last_landscape
|
||||
)
|
||||
|
||||
if should_switch and self.last_landscape:
|
||||
# Get new strategy recommendation
|
||||
new_strategy, details = self.strategy_selector.recommend_strategy(
|
||||
landscape=self.last_landscape,
|
||||
trials_completed=trial.number,
|
||||
current_best_value=study.best_value
|
||||
)
|
||||
|
||||
# Only switch if recommendation is different
|
||||
if new_strategy != current_strategy:
|
||||
self.transition_manager.execute_strategy_switch(
|
||||
study=study,
|
||||
from_strategy=current_strategy or 'initial',
|
||||
to_strategy=new_strategy,
|
||||
reason=reason,
|
||||
trial_number=trial.number
|
||||
)
|
||||
|
||||
# Note: Actual sampler change requires study recreation
|
||||
# This is logged for the IntelligentOptimizer to act on
|
||||
self.last_recommendation = (new_strategy, details)
|
||||
419
optimization_engine/core/strategy_selector.py
Normal file
419
optimization_engine/core/strategy_selector.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Strategy Selector - Intelligent optimization strategy recommendation.
|
||||
|
||||
This module implements decision logic to recommend the best optimization strategy
|
||||
based on landscape characteristics. Uses expert knowledge and empirical heuristics
|
||||
to match problem types to appropriate algorithms.
|
||||
|
||||
Part of Protocol 10: Intelligent Multi-Strategy Optimization (IMSO)
|
||||
"""
|
||||
|
||||
import optuna
|
||||
from typing import Dict, Optional, Tuple
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class IntelligentStrategySelector:
|
||||
"""
|
||||
Selects optimal optimization strategy based on problem characteristics.
|
||||
|
||||
Decision tree combines:
|
||||
1. Landscape analysis (smoothness, multimodality, noise)
|
||||
2. Problem dimensionality
|
||||
3. Trial budget and evaluation cost
|
||||
4. Historical performance data (if available)
|
||||
"""
|
||||
|
||||
def __init__(self, verbose: bool = True):
|
||||
"""
|
||||
Args:
|
||||
verbose: Print recommendation explanations
|
||||
"""
|
||||
self.verbose = verbose
|
||||
self.recommendation_history = []
|
||||
|
||||
def recommend_strategy(
|
||||
self,
|
||||
landscape: Dict,
|
||||
trials_completed: int = 0,
|
||||
trials_budget: Optional[int] = None,
|
||||
current_best_value: Optional[float] = None
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Recommend optimization strategy based on problem characteristics.
|
||||
|
||||
Args:
|
||||
landscape: Output from LandscapeAnalyzer.analyze()
|
||||
trials_completed: Number of trials completed so far
|
||||
trials_budget: Total trial budget (if known)
|
||||
current_best_value: Current best objective value
|
||||
|
||||
Returns:
|
||||
(strategy_name, recommendation_details)
|
||||
|
||||
strategy_name: One of ['tpe', 'cmaes', 'gp_bo', 'random', 'hybrid_gp_cmaes']
|
||||
recommendation_details: Dict with confidence, reasoning, and sampler config
|
||||
"""
|
||||
# Handle None landscape (multi-objective optimization)
|
||||
if landscape is None:
|
||||
# Multi-objective: Use NSGA-II/NSGA-III based on trial count
|
||||
return self._recommend_multiobjective_strategy(trials_completed)
|
||||
|
||||
if not landscape.get('ready', False):
|
||||
# Not enough data, use random exploration
|
||||
return self._recommend_random_exploration(trials_completed)
|
||||
|
||||
# Extract key characteristics
|
||||
landscape_type = landscape.get('landscape_type', 'unknown')
|
||||
smoothness = landscape.get('smoothness', 0.5)
|
||||
multimodal = landscape.get('multimodal', False)
|
||||
noise_level = landscape.get('noise_level', 0.0)
|
||||
dimensionality = landscape.get('dimensionality', 2)
|
||||
correlation_strength = landscape['parameter_correlation'].get('overall_strength', 0.3)
|
||||
|
||||
# Use characterization trial count for strategy decisions (not total trials)
|
||||
# This prevents premature algorithm selection when many trials were pruned
|
||||
char_trials = landscape.get('total_trials', trials_completed)
|
||||
|
||||
# Decision tree for strategy selection
|
||||
strategy, details = self._apply_decision_tree(
|
||||
landscape_type=landscape_type,
|
||||
smoothness=smoothness,
|
||||
multimodal=multimodal,
|
||||
noise_level=noise_level,
|
||||
dimensionality=dimensionality,
|
||||
correlation_strength=correlation_strength,
|
||||
trials_completed=char_trials # Use characterization trials, not total
|
||||
)
|
||||
|
||||
# Add landscape info to recommendation
|
||||
details['landscape_analysis'] = {
|
||||
'type': landscape_type,
|
||||
'smoothness': smoothness,
|
||||
'multimodal': multimodal,
|
||||
'dimensionality': dimensionality
|
||||
}
|
||||
|
||||
# Log recommendation
|
||||
self._log_recommendation(strategy, details, trials_completed)
|
||||
|
||||
if self.verbose:
|
||||
self._print_recommendation(strategy, details)
|
||||
|
||||
return strategy, details
|
||||
|
||||
def _apply_decision_tree(
|
||||
self,
|
||||
landscape_type: str,
|
||||
smoothness: float,
|
||||
multimodal: bool,
|
||||
noise_level: float,
|
||||
dimensionality: int,
|
||||
correlation_strength: float,
|
||||
trials_completed: int
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Apply expert decision tree for strategy selection.
|
||||
|
||||
Decision logic based on optimization algorithm strengths:
|
||||
|
||||
CMA-ES:
|
||||
- Best for: Smooth unimodal landscapes, correlated parameters
|
||||
- Strengths: Fast local convergence, handles parameter correlations
|
||||
- Weaknesses: Poor for multimodal, needs reasonable initialization
|
||||
|
||||
GP-BO (Gaussian Process Bayesian Optimization):
|
||||
- Best for: Smooth landscapes, expensive evaluations, low-dimensional
|
||||
- Strengths: Sample efficient, good uncertainty quantification
|
||||
- Weaknesses: Scales poorly >10D, expensive surrogate training
|
||||
|
||||
TPE (Tree-structured Parzen Estimator):
|
||||
- Best for: General purpose, multimodal, moderate dimensional
|
||||
- Strengths: Handles multimodality, scales to ~50D, robust
|
||||
- Weaknesses: Slower convergence than CMA-ES on smooth problems
|
||||
|
||||
Hybrid GP→CMA-ES:
|
||||
- Best for: Smooth landscapes needing global+local search
|
||||
- Strengths: GP finds basin, CMA-ES refines locally
|
||||
- Weaknesses: More complex, needs transition logic
|
||||
"""
|
||||
|
||||
# CASE 1: High noise - use robust methods
|
||||
if noise_level > 0.5:
|
||||
return 'tpe', {
|
||||
'confidence': 0.85,
|
||||
'reasoning': 'High noise detected - TPE is more robust to noisy evaluations',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 15, # More exploration for noisy problems
|
||||
'n_ei_candidates': 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 2: Smooth unimodal with strong correlation - CMA-ES excels
|
||||
if landscape_type == 'smooth_unimodal' and correlation_strength > 0.5:
|
||||
return 'cmaes', {
|
||||
'confidence': 0.92,
|
||||
'reasoning': f'Smooth unimodal landscape with strong parameter correlation ({correlation_strength:.2f}) - CMA-ES will converge quickly',
|
||||
'sampler_config': {
|
||||
'type': 'CmaEsSampler',
|
||||
'params': {
|
||||
'restart_strategy': 'ipop', # Increasing population restart
|
||||
'with_margin': True # Use margin for constraint handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 3: Smooth but multimodal - Hybrid GP→CMA-ES or GP-BO
|
||||
if landscape_type == 'smooth_multimodal':
|
||||
if dimensionality <= 5 and trials_completed < 30:
|
||||
# Early stage: GP-BO for exploration
|
||||
return 'gp_bo', {
|
||||
'confidence': 0.78,
|
||||
'reasoning': f'Smooth multimodal landscape, {dimensionality}D - GP-BO for intelligent exploration, plan CMA-ES refinement later',
|
||||
'sampler_config': {
|
||||
'type': 'GPSampler', # Custom implementation needed
|
||||
'params': {
|
||||
'acquisition': 'EI', # Expected Improvement
|
||||
'n_initial_points': 10
|
||||
}
|
||||
},
|
||||
'transition_plan': {
|
||||
'switch_to': 'cmaes',
|
||||
'when': 'error < 1.0 OR trials > 40'
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Later stage or higher dimensional: TPE
|
||||
return 'tpe', {
|
||||
'confidence': 0.75,
|
||||
'reasoning': f'Smooth multimodal landscape - TPE handles multiple modes well',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 10,
|
||||
'n_ei_candidates': 32 # More exploitation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 4: Smooth unimodal, low-dimensional, expensive - GP-BO then CMA-ES
|
||||
if landscape_type == 'smooth_unimodal' and dimensionality <= 5:
|
||||
if trials_completed < 25:
|
||||
return 'gp_bo', {
|
||||
'confidence': 0.82,
|
||||
'reasoning': f'Smooth {dimensionality}D landscape - GP-BO for sample-efficient exploration',
|
||||
'sampler_config': {
|
||||
'type': 'GPSampler',
|
||||
'params': {
|
||||
'acquisition': 'EI',
|
||||
'n_initial_points': 8
|
||||
}
|
||||
},
|
||||
'transition_plan': {
|
||||
'switch_to': 'cmaes',
|
||||
'when': 'error < 2.0 OR trials > 25'
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Switch to CMA-ES for final refinement
|
||||
return 'cmaes', {
|
||||
'confidence': 0.88,
|
||||
'reasoning': 'Switching to CMA-ES for final local refinement',
|
||||
'sampler_config': {
|
||||
'type': 'CmaEsSampler',
|
||||
'params': {
|
||||
'restart_strategy': 'ipop',
|
||||
'with_margin': True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 5: Rugged multimodal - TPE is most robust
|
||||
if landscape_type == 'rugged_multimodal' or multimodal:
|
||||
return 'tpe', {
|
||||
'confidence': 0.80,
|
||||
'reasoning': 'Rugged/multimodal landscape - TPE is robust to multiple local optima',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 12,
|
||||
'n_ei_candidates': 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 6: Rugged unimodal - TPE with more exploration
|
||||
if landscape_type == 'rugged_unimodal':
|
||||
return 'tpe', {
|
||||
'confidence': 0.72,
|
||||
'reasoning': 'Rugged landscape - TPE with extended exploration',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 15,
|
||||
'n_ei_candidates': 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CASE 7: High dimensional (>5D) - TPE scales best
|
||||
if dimensionality > 5:
|
||||
return 'tpe', {
|
||||
'confidence': 0.77,
|
||||
'reasoning': f'High dimensionality ({dimensionality}D) - TPE scales well to moderate dimensions',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': min(20, dimensionality * 3),
|
||||
'n_ei_candidates': 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# DEFAULT: TPE as safe general-purpose choice
|
||||
return 'tpe', {
|
||||
'confidence': 0.65,
|
||||
'reasoning': 'Default robust strategy - TPE works well for most problems',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 10,
|
||||
'n_ei_candidates': 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _recommend_random_exploration(self, trials_completed: int) -> Tuple[str, Dict]:
|
||||
"""Recommend random exploration when insufficient data for analysis."""
|
||||
return 'random', {
|
||||
'confidence': 1.0,
|
||||
'reasoning': f'Insufficient data ({trials_completed} trials) - using random exploration for landscape characterization',
|
||||
'sampler_config': {
|
||||
'type': 'RandomSampler',
|
||||
'params': {}
|
||||
}
|
||||
}
|
||||
|
||||
def _recommend_multiobjective_strategy(self, trials_completed: int) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Recommend strategy for multi-objective optimization.
|
||||
|
||||
For multi-objective problems, landscape analysis is not applicable.
|
||||
Use NSGA-II (default) or TPE with multivariate support.
|
||||
"""
|
||||
# Start with random for initial exploration
|
||||
if trials_completed < 8:
|
||||
return 'random', {
|
||||
'confidence': 1.0,
|
||||
'reasoning': f'Multi-objective: Random exploration for initial {trials_completed}/8 trials',
|
||||
'sampler_config': {
|
||||
'type': 'RandomSampler',
|
||||
'params': {}
|
||||
}
|
||||
}
|
||||
|
||||
# After initial exploration, use TPE with multivariate support
|
||||
# (NSGA-II sampler is already used at study creation level)
|
||||
return 'tpe', {
|
||||
'confidence': 0.95,
|
||||
'reasoning': f'Multi-objective: TPE with multivariate support for Pareto front exploration ({trials_completed} trials)',
|
||||
'sampler_config': {
|
||||
'type': 'TPESampler',
|
||||
'params': {
|
||||
'multivariate': True,
|
||||
'n_startup_trials': 8,
|
||||
'n_ei_candidates': 24,
|
||||
'constant_liar': True # Better for parallel multi-objective
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _log_recommendation(self, strategy: str, details: Dict, trial_number: int):
|
||||
"""Log recommendation for learning and transfer."""
|
||||
self.recommendation_history.append({
|
||||
'trial_number': trial_number,
|
||||
'strategy': strategy,
|
||||
'confidence': details.get('confidence', 0.0),
|
||||
'reasoning': details.get('reasoning', ''),
|
||||
'landscape': details.get('landscape_analysis', {})
|
||||
})
|
||||
|
||||
def _print_recommendation(self, strategy: str, details: Dict):
|
||||
"""Print formatted recommendation."""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" STRATEGY RECOMMENDATION")
|
||||
print(f"{'='*70}")
|
||||
print(f" Recommended: {strategy.upper()}")
|
||||
print(f" Confidence: {details['confidence']:.1%}")
|
||||
print(f" Reasoning: {details['reasoning']}")
|
||||
|
||||
if 'transition_plan' in details:
|
||||
plan = details['transition_plan']
|
||||
print(f"\n TRANSITION PLAN:")
|
||||
print(f" Switch to: {plan['switch_to'].upper()}")
|
||||
print(f" When: {plan['when']}")
|
||||
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
def save_recommendation_history(self, filepath: Path):
|
||||
"""Save recommendation history to JSON for learning."""
|
||||
try:
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(self.recommendation_history, f, indent=2)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Failed to save recommendation history: {e}")
|
||||
|
||||
def load_recommendation_history(self, filepath: Path):
|
||||
"""Load previous recommendation history."""
|
||||
try:
|
||||
if filepath.exists():
|
||||
with open(filepath, 'r') as f:
|
||||
self.recommendation_history = json.load(f)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Failed to load recommendation history: {e}")
|
||||
|
||||
|
||||
def create_sampler_from_config(config: Dict) -> optuna.samplers.BaseSampler:
|
||||
"""
|
||||
Create Optuna sampler from configuration dictionary.
|
||||
|
||||
Args:
|
||||
config: Sampler configuration from strategy recommendation
|
||||
|
||||
Returns:
|
||||
Configured Optuna sampler
|
||||
"""
|
||||
sampler_type = config.get('type', 'TPESampler')
|
||||
params = config.get('params', {})
|
||||
|
||||
if sampler_type == 'TPESampler':
|
||||
return optuna.samplers.TPESampler(**params)
|
||||
|
||||
elif sampler_type == 'CmaEsSampler':
|
||||
return optuna.samplers.CmaEsSampler(**params)
|
||||
|
||||
elif sampler_type == 'RandomSampler':
|
||||
return optuna.samplers.RandomSampler(**params)
|
||||
|
||||
elif sampler_type == 'GPSampler':
|
||||
# GP-BO not directly available in Optuna
|
||||
# Would need custom implementation or use skopt integration
|
||||
print(" Warning: GP-BO sampler not yet implemented, falling back to TPE")
|
||||
return optuna.samplers.TPESampler(multivariate=True, n_startup_trials=10)
|
||||
|
||||
else:
|
||||
# Default fallback
|
||||
print(f" Warning: Unknown sampler type {sampler_type}, using TPE")
|
||||
return optuna.samplers.TPESampler(multivariate=True, n_startup_trials=10)
|
||||
Reference in New Issue
Block a user