Files
Atomizer/optimization_engine/adaptive_surrogate.py

394 lines
16 KiB
Python
Raw Normal View History

"""
Adaptive surrogate modeling with confidence-based exploration-exploitation transitions.
This module implements state-of-the-art Bayesian optimization strategies that
dynamically adjust exploration vs exploitation based on surrogate model confidence.
"""
import numpy as np
from typing import Optional, Dict, List
import optuna
from scipy.stats import variation
import json
from pathlib import Path
class SurrogateConfidenceMetrics:
"""Calculate confidence metrics for surrogate model quality.
STUDY-AWARE: Uses study.trials directly instead of session-based history
to properly track confidence across multiple optimization runs.
"""
def __init__(self, min_trials_for_confidence: int = 15):
self.min_trials = min_trials_for_confidence
def update(self, study: optuna.Study, trial: optuna.trial.FrozenTrial):
"""Update metrics after each trial (no-op for study-aware implementation)."""
pass # Study-aware: we read directly from study.trials
def calculate_confidence(self, study: optuna.Study) -> Dict[str, float]:
"""
Calculate comprehensive surrogate confidence metrics.
STUDY-AWARE: Uses ALL completed trials from the study database,
not just trials from the current session.
PROTOCOL 11: Multi-objective studies are NOT supported by adaptive
characterization. Return immediately with max confidence to skip
characterization phase.
Returns:
Dict with confidence scores:
- 'overall_confidence': 0-1 score, where 1 = high confidence
- 'convergence_score': How stable recent improvements are
- 'exploration_coverage': How well parameter space is covered
- 'prediction_stability': How consistent the model predictions are
"""
# [Protocol 11] Multi-objective NOT supported by adaptive characterization
is_multi_objective = len(study.directions) > 1
if is_multi_objective:
return {
'overall_confidence': 1.0, # Skip characterization
'convergence_score': 1.0,
'exploration_coverage': 1.0,
'prediction_stability': 1.0,
'ready_for_exploitation': True, # Go straight to NSGA-II
'total_trials': len(study.trials),
'message': '[Protocol 11] Multi-objective: skipping adaptive characterization, using NSGA-II directly'
}
# Get ALL completed trials from study (study-aware)
all_trials = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
if len(all_trials) < self.min_trials:
return {
'overall_confidence': 0.0,
'convergence_score': 0.0,
'exploration_coverage': 0.0,
'prediction_stability': 0.0,
'ready_for_exploitation': False,
'total_trials': len(all_trials),
'message': f'Need {self.min_trials - len(all_trials)} more trials for confidence assessment (currently {len(all_trials)} trials)'
}
# 1. Convergence Score - are we finding consistent improvements?
recent_window = 10
recent_trials = all_trials[-recent_window:]
recent_values = [t.value for t in recent_trials] # Safe: single-objective only
# Calculate improvement rate
improvements = []
for i in range(1, len(recent_values)):
if recent_values[i] < recent_values[i-1]:
improvement = (recent_values[i-1] - recent_values[i]) / abs(recent_values[i-1])
improvements.append(improvement)
# If we're consistently finding improvements, convergence is good
if improvements:
avg_improvement = np.mean(improvements)
improvement_consistency = 1.0 - variation(improvements) if len(improvements) > 1 else 0.5
convergence_score = min(1.0, avg_improvement * improvement_consistency * 10)
else:
convergence_score = 0.0
# 2. Exploration Coverage - how well have we covered parameter space?
# Use coefficient of variation for each parameter
param_names = list(all_trials[0].params.keys()) if all_trials else []
coverage_scores = []
for param in param_names:
values = [t.params[param] for t in all_trials]
# Get parameter bounds
distribution = all_trials[0].distributions[param]
param_range = distribution.high - distribution.low
# Calculate spread relative to bounds
spread = max(values) - min(values)
coverage = spread / param_range
coverage_scores.append(coverage)
exploration_coverage = np.mean(coverage_scores)
# 3. Prediction Stability - are recent trials clustered in good regions?
recent_best_values = []
current_best = float('inf')
for trial in recent_trials:
current_best = min(current_best, trial.value)
recent_best_values.append(current_best)
# If best hasn't improved much recently, model is stable
if len(recent_best_values) > 1:
best_stability = 1.0 - (recent_best_values[0] - recent_best_values[-1]) / (recent_best_values[0] + 1e-10)
prediction_stability = max(0.0, min(1.0, best_stability))
else:
prediction_stability = 0.0
# 4. Overall Confidence - weighted combination
overall_confidence = (
0.4 * convergence_score +
0.3 * exploration_coverage +
0.3 * prediction_stability
)
# Decision: Ready for intensive exploitation?
ready_for_exploitation = (
overall_confidence >= 0.65 and
exploration_coverage >= 0.5 and
len(all_trials) >= self.min_trials
)
message = self._get_confidence_message(overall_confidence, ready_for_exploitation)
return {
'overall_confidence': overall_confidence,
'convergence_score': convergence_score,
'exploration_coverage': exploration_coverage,
'prediction_stability': prediction_stability,
'ready_for_exploitation': ready_for_exploitation,
'total_trials': len(all_trials),
'message': message
}
def _get_confidence_message(self, confidence: float, ready: bool) -> str:
"""Generate human-readable confidence assessment."""
if ready:
return f"HIGH CONFIDENCE ({confidence:.1%}) - Transitioning to exploitation phase"
elif confidence >= 0.5:
return f"MEDIUM CONFIDENCE ({confidence:.1%}) - Continue exploration with some exploitation"
elif confidence >= 0.3:
return f"LOW CONFIDENCE ({confidence:.1%}) - Focus on exploration"
else:
return f"VERY LOW CONFIDENCE ({confidence:.1%}) - Need more diverse exploration"
class AdaptiveExploitationCallback:
"""
Dynamically adjust sampler behavior based on surrogate confidence.
This callback monitors surrogate model confidence and adapts the optimization
strategy from exploration-heavy to exploitation-heavy as confidence increases.
STUDY-AWARE: Tracks phase transitions across multiple optimization runs
and persists confidence history to JSON files.
"""
def __init__(
self,
target_value: Optional[float] = None,
tolerance: float = 0.1,
min_confidence_for_exploitation: float = 0.65,
min_trials: int = 15,
verbose: bool = True,
tracking_dir: Optional[Path] = None
):
"""
Args:
target_value: Target objective value (if known)
tolerance: Acceptable error from target
min_confidence_for_exploitation: Confidence threshold to enable intensive exploitation
min_trials: Minimum trials before confidence assessment
verbose: Print confidence updates
tracking_dir: Directory to save phase transition tracking files
"""
self.target_value = target_value
self.tolerance = tolerance
self.min_confidence = min_confidence_for_exploitation
self.verbose = verbose
self.tracking_dir = tracking_dir
self.metrics = SurrogateConfidenceMetrics(min_trials_for_confidence=min_trials)
self.consecutive_successes = 0
# Initialize phase transition tracking
self.phase_transition_file = None
self.confidence_history_file = None
if tracking_dir:
self.tracking_dir = Path(tracking_dir)
self.tracking_dir.mkdir(parents=True, exist_ok=True)
self.phase_transition_file = self.tracking_dir / "phase_transitions.json"
self.confidence_history_file = self.tracking_dir / "confidence_history.json"
# Load existing phase transition data if available
self.phase_transitions = self._load_phase_transitions()
self.confidence_history = self._load_confidence_history()
# Determine current phase from history
self.phase = self._get_current_phase()
def _load_phase_transitions(self) -> List[Dict]:
"""Load existing phase transition history from JSON."""
if self.phase_transition_file and self.phase_transition_file.exists():
try:
with open(self.phase_transition_file, 'r') as f:
return json.load(f)
except Exception:
return []
return []
def _load_confidence_history(self) -> List[Dict]:
"""Load existing confidence history from JSON."""
if self.confidence_history_file and self.confidence_history_file.exists():
try:
with open(self.confidence_history_file, 'r') as f:
return json.load(f)
except Exception:
return []
return []
def _get_current_phase(self) -> str:
"""Determine current phase from transition history."""
if not self.phase_transitions:
return "exploration"
# If any transition to exploitation exists, we're in exploitation
for transition in self.phase_transitions:
if transition.get('to_phase') == 'exploitation':
return "exploitation"
return "exploration"
def _save_phase_transition(self, trial_number: int, confidence: Dict):
"""Save phase transition event to JSON."""
if not self.phase_transition_file:
return
transition_event = {
'trial_number': trial_number,
'from_phase': 'exploration',
'to_phase': 'exploitation',
'confidence_metrics': {
'overall_confidence': confidence['overall_confidence'],
'convergence_score': confidence['convergence_score'],
'exploration_coverage': confidence['exploration_coverage'],
'prediction_stability': confidence['prediction_stability']
},
'total_trials': confidence.get('total_trials', trial_number + 1)
}
self.phase_transitions.append(transition_event)
try:
with open(self.phase_transition_file, 'w') as f:
json.dump(self.phase_transitions, f, indent=2)
except Exception as e:
if self.verbose:
print(f" Warning: Failed to save phase transition: {e}")
def _save_confidence_snapshot(self, trial_number: int, confidence: Dict):
"""Save confidence metrics snapshot to history."""
if not self.confidence_history_file:
return
snapshot = {
'trial_number': trial_number,
'phase': self.phase,
'confidence_metrics': {
'overall_confidence': confidence['overall_confidence'],
'convergence_score': confidence['convergence_score'],
'exploration_coverage': confidence['exploration_coverage'],
'prediction_stability': confidence['prediction_stability']
},
'total_trials': confidence.get('total_trials', trial_number + 1)
}
self.confidence_history.append(snapshot)
try:
with open(self.confidence_history_file, 'w') as f:
json.dump(self.confidence_history, f, indent=2)
except Exception as e:
if self.verbose:
print(f" Warning: Failed to save confidence history: {e}")
def __call__(self, study: optuna.Study, trial: optuna.trial.FrozenTrial):
"""Called after each trial completes."""
# Skip failed trials
if trial.state != optuna.trial.TrialState.COMPLETE:
return
# Update metrics (no-op for study-aware implementation)
self.metrics.update(study, trial)
# Calculate confidence
confidence = self.metrics.calculate_confidence(study)
# Save confidence snapshot every 5 trials
if trial.number % 5 == 0:
self._save_confidence_snapshot(trial.number, confidence)
# Print confidence report
if self.verbose and trial.number % 5 == 0: # Every 5 trials
self._print_confidence_report(trial.number, confidence)
# Check for phase transition
if confidence['ready_for_exploitation'] and self.phase == "exploration":
self.phase = "exploitation"
# Save transition event
self._save_phase_transition(trial.number, confidence)
if self.verbose:
print(f"\n{'='*60}")
print(f" PHASE TRANSITION: EXPLORATION -> EXPLOITATION")
print(f" Trial #{trial.number}")
print(f" Surrogate confidence: {confidence['overall_confidence']:.1%}")
print(f" Now focusing on refining best regions")
print(f"{'='*60}\n")
# Check for target achievement
if self.target_value is not None and trial.value <= self.tolerance:
self.consecutive_successes += 1
if self.verbose:
print(f" [TARGET] Trial #{trial.number}: {trial.value:.6f}{self.tolerance:.6f}")
print(f" [TARGET] Consecutive successes: {self.consecutive_successes}/3")
# Stop after 3 consecutive successes in exploitation phase
if self.consecutive_successes >= 3 and self.phase == "exploitation":
if self.verbose:
print(f"\n{'='*60}")
print(f" TARGET ACHIEVED WITH HIGH CONFIDENCE")
print(f" Best value: {study.best_value:.6f}")
print(f" Stopping optimization")
print(f"{'='*60}\n")
study.stop()
else:
self.consecutive_successes = 0
def _print_confidence_report(self, trial_number: int, confidence: Dict):
"""Print confidence metrics report."""
print(f"\n [CONFIDENCE REPORT - Trial #{trial_number}]")
print(f" Phase: {self.phase.upper()}")
print(f" Overall Confidence: {confidence['overall_confidence']:.1%}")
print(f" - Convergence: {confidence['convergence_score']:.1%}")
print(f" - Coverage: {confidence['exploration_coverage']:.1%}")
print(f" - Stability: {confidence['prediction_stability']:.1%}")
print(f" {confidence['message']}")
print()
def create_adaptive_sampler(
n_startup_trials: int = 10,
multivariate: bool = True,
confidence_threshold: float = 0.65
) -> optuna.samplers.TPESampler:
"""
Create TPE sampler configured for adaptive exploration-exploitation.
Args:
n_startup_trials: Initial random exploration trials
multivariate: Enable multivariate TPE for correlated parameters
confidence_threshold: Confidence needed before intensive exploitation
Returns:
Configured TPESampler
"""
# Higher n_ei_candidates = more exploitation
# Will be used once confidence threshold is reached
return optuna.samplers.TPESampler(
n_startup_trials=n_startup_trials,
n_ei_candidates=24,
multivariate=multivariate,
warn_independent_sampling=True
)