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>
777 lines
26 KiB
Python
777 lines
26 KiB
Python
"""
|
|
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}")
|