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:
2025-12-29 12:30:59 -05:00
parent 82f36689b7
commit eabcc4c3ca
120 changed files with 1127 additions and 637 deletions

View 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',
]

View 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)

View 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}")

View 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
)

File diff suppressed because it is too large Load Diff

View 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")

View 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
)

View 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)

View 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)