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>
433 lines
15 KiB
Python
433 lines
15 KiB
Python
"""
|
|
Real Data Cross-Validation for Surrogate Model
|
|
|
|
This script performs proper k-fold cross-validation using ONLY real FEA data
|
|
to assess the true prediction accuracy of the neural network surrogate.
|
|
|
|
The key insight: We don't need simulated FEA - we already have real FEA data!
|
|
We can use cross-validation to estimate out-of-sample performance.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.optim as optim
|
|
from sklearn.model_selection import KFold
|
|
import matplotlib
|
|
matplotlib.use('Agg')
|
|
import matplotlib.pyplot as plt
|
|
|
|
# Add project paths
|
|
project_root = Path(__file__).parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from optimization_engine.processors.surrogates.active_learning_surrogate import (
|
|
EnsembleMLP,
|
|
extract_training_data_from_study
|
|
)
|
|
|
|
|
|
def k_fold_cross_validation(
|
|
design_params: np.ndarray,
|
|
objectives: np.ndarray,
|
|
design_var_names: list,
|
|
n_folds: int = 5,
|
|
hidden_dims: list = [128, 64, 32], # Deeper network
|
|
epochs: int = 300,
|
|
lr: float = 0.001
|
|
):
|
|
"""
|
|
Perform k-fold cross-validation to assess real prediction performance.
|
|
|
|
Returns detailed metrics for each fold.
|
|
"""
|
|
kf = KFold(n_splits=n_folds, shuffle=True, random_state=42)
|
|
|
|
fold_results = []
|
|
all_predictions = []
|
|
all_actuals = []
|
|
all_indices = []
|
|
|
|
for fold, (train_idx, test_idx) in enumerate(kf.split(design_params)):
|
|
print(f"\n--- Fold {fold+1}/{n_folds} ---")
|
|
|
|
X_train, X_test = design_params[train_idx], design_params[test_idx]
|
|
y_train, y_test = objectives[train_idx], objectives[test_idx]
|
|
|
|
# Normalization on training data
|
|
X_mean = X_train.mean(axis=0)
|
|
X_std = X_train.std(axis=0) + 1e-8
|
|
y_mean = y_train.mean(axis=0)
|
|
y_std = y_train.std(axis=0) + 1e-8
|
|
|
|
X_train_norm = (X_train - X_mean) / X_std
|
|
X_test_norm = (X_test - X_mean) / X_std
|
|
y_train_norm = (y_train - y_mean) / y_std
|
|
|
|
# Convert to tensors
|
|
X_train_t = torch.FloatTensor(X_train_norm)
|
|
y_train_t = torch.FloatTensor(y_train_norm)
|
|
X_test_t = torch.FloatTensor(X_test_norm)
|
|
|
|
# Train model
|
|
input_dim = X_train.shape[1]
|
|
output_dim = y_train.shape[1]
|
|
model = EnsembleMLP(input_dim, output_dim, hidden_dims)
|
|
|
|
optimizer = optim.Adam(model.parameters(), lr=lr)
|
|
criterion = nn.MSELoss()
|
|
|
|
# Training with early stopping
|
|
best_loss = float('inf')
|
|
patience = 30
|
|
patience_counter = 0
|
|
best_state = None
|
|
|
|
for epoch in range(epochs):
|
|
model.train()
|
|
|
|
# Mini-batch training
|
|
batch_size = 32
|
|
perm = torch.randperm(len(X_train_t))
|
|
epoch_loss = 0
|
|
n_batches = 0
|
|
|
|
for j in range(0, len(X_train_t), batch_size):
|
|
batch_idx = perm[j:j+batch_size]
|
|
X_batch = X_train_t[batch_idx]
|
|
y_batch = y_train_t[batch_idx]
|
|
|
|
optimizer.zero_grad()
|
|
pred = model(X_batch)
|
|
loss = criterion(pred, y_batch)
|
|
loss.backward()
|
|
optimizer.step()
|
|
|
|
epoch_loss += loss.item()
|
|
n_batches += 1
|
|
|
|
avg_loss = epoch_loss / n_batches
|
|
if avg_loss < best_loss:
|
|
best_loss = avg_loss
|
|
best_state = model.state_dict().copy()
|
|
patience_counter = 0
|
|
else:
|
|
patience_counter += 1
|
|
if patience_counter >= patience:
|
|
break
|
|
|
|
# Restore best model
|
|
if best_state:
|
|
model.load_state_dict(best_state)
|
|
|
|
# Evaluate on test set
|
|
model.eval()
|
|
with torch.no_grad():
|
|
pred_norm = model(X_test_t).numpy()
|
|
pred = pred_norm * y_std + y_mean
|
|
|
|
# Calculate errors for each objective
|
|
mass_pred = pred[:, 0]
|
|
mass_actual = y_test[:, 0]
|
|
freq_pred = pred[:, 1]
|
|
freq_actual = y_test[:, 1]
|
|
|
|
mass_errors = np.abs(mass_pred - mass_actual) / (np.abs(mass_actual) + 1e-8)
|
|
freq_errors = np.abs(freq_pred - freq_actual) / (np.abs(freq_actual) + 1e-8)
|
|
|
|
mass_mape = np.mean(mass_errors) * 100
|
|
freq_mape = np.mean(freq_errors) * 100
|
|
mass_rmse = np.sqrt(np.mean((mass_pred - mass_actual)**2))
|
|
freq_rmse = np.sqrt(np.mean((freq_pred - freq_actual)**2))
|
|
|
|
fold_results.append({
|
|
'fold': fold + 1,
|
|
'n_train': len(train_idx),
|
|
'n_test': len(test_idx),
|
|
'mass_mape': mass_mape,
|
|
'freq_mape': freq_mape,
|
|
'mass_rmse': mass_rmse,
|
|
'freq_rmse': freq_rmse,
|
|
'epochs_trained': epoch + 1
|
|
})
|
|
|
|
# Store for plotting
|
|
all_predictions.extend(pred.tolist())
|
|
all_actuals.extend(y_test.tolist())
|
|
all_indices.extend(test_idx.tolist())
|
|
|
|
print(f" Mass MAPE: {mass_mape:.1f}%, RMSE: {mass_rmse:.1f}g")
|
|
print(f" Freq MAPE: {freq_mape:.1f}%, RMSE: {freq_rmse:.1f}Hz")
|
|
|
|
return fold_results, np.array(all_predictions), np.array(all_actuals), all_indices
|
|
|
|
|
|
def train_final_model_with_cv_uncertainty(
|
|
design_params: np.ndarray,
|
|
objectives: np.ndarray,
|
|
design_var_names: list,
|
|
cv_results: dict
|
|
):
|
|
"""
|
|
Train final model on all data and attach CV-based uncertainty estimates.
|
|
"""
|
|
print("\n" + "="*60)
|
|
print("Training Final Model on All Data")
|
|
print("="*60)
|
|
|
|
# Normalization
|
|
X_mean = design_params.mean(axis=0)
|
|
X_std = design_params.std(axis=0) + 1e-8
|
|
y_mean = objectives.mean(axis=0)
|
|
y_std = objectives.std(axis=0) + 1e-8
|
|
|
|
X_norm = (design_params - X_mean) / X_std
|
|
y_norm = (objectives - y_mean) / y_std
|
|
|
|
X_t = torch.FloatTensor(X_norm)
|
|
y_t = torch.FloatTensor(y_norm)
|
|
|
|
# Train on all data
|
|
input_dim = design_params.shape[1]
|
|
output_dim = objectives.shape[1]
|
|
model = EnsembleMLP(input_dim, output_dim, [128, 64, 32])
|
|
|
|
optimizer = optim.Adam(model.parameters(), lr=0.001)
|
|
criterion = nn.MSELoss()
|
|
|
|
for epoch in range(500):
|
|
model.train()
|
|
optimizer.zero_grad()
|
|
pred = model(X_t)
|
|
loss = criterion(pred, y_t)
|
|
loss.backward()
|
|
optimizer.step()
|
|
|
|
# Save model with metadata
|
|
model_state = {
|
|
'model': model.state_dict(),
|
|
'input_mean': X_mean.tolist(),
|
|
'input_std': X_std.tolist(),
|
|
'output_mean': y_mean.tolist(),
|
|
'output_std': y_std.tolist(),
|
|
'design_var_names': design_var_names,
|
|
'cv_mass_mape': cv_results['mass_mape'],
|
|
'cv_freq_mape': cv_results['freq_mape'],
|
|
'cv_mass_std': cv_results['mass_std'],
|
|
'cv_freq_std': cv_results['freq_std'],
|
|
'n_samples': len(design_params),
|
|
'hidden_dims': [128, 64, 32]
|
|
}
|
|
|
|
save_path = project_root / "cv_validated_surrogate.pt"
|
|
torch.save(model_state, save_path)
|
|
print(f"Saved CV-validated model to: {save_path}")
|
|
|
|
return model, model_state
|
|
|
|
|
|
def plot_cv_results(
|
|
all_predictions: np.ndarray,
|
|
all_actuals: np.ndarray,
|
|
fold_results: list,
|
|
save_path: str
|
|
):
|
|
"""Generate comprehensive validation plots."""
|
|
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
|
|
|
|
# 1. Mass: Predicted vs Actual
|
|
ax = axes[0, 0]
|
|
ax.scatter(all_actuals[:, 0], all_predictions[:, 0], alpha=0.5, s=20)
|
|
min_val = min(all_actuals[:, 0].min(), all_predictions[:, 0].min())
|
|
max_val = max(all_actuals[:, 0].max(), all_predictions[:, 0].max())
|
|
ax.plot([min_val, max_val], [min_val, max_val], 'r--', linewidth=2, label='Perfect')
|
|
ax.set_xlabel('FEA Mass (g)')
|
|
ax.set_ylabel('NN Predicted Mass (g)')
|
|
ax.set_title('Mass: Predicted vs Actual')
|
|
ax.legend()
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# 2. Frequency: Predicted vs Actual
|
|
ax = axes[0, 1]
|
|
ax.scatter(all_actuals[:, 1], all_predictions[:, 1], alpha=0.5, s=20)
|
|
min_val = min(all_actuals[:, 1].min(), all_predictions[:, 1].min())
|
|
max_val = max(all_actuals[:, 1].max(), all_predictions[:, 1].max())
|
|
ax.plot([min_val, max_val], [min_val, max_val], 'r--', linewidth=2, label='Perfect')
|
|
ax.set_xlabel('FEA Frequency (Hz)')
|
|
ax.set_ylabel('NN Predicted Frequency (Hz)')
|
|
ax.set_title('Frequency: Predicted vs Actual')
|
|
ax.legend()
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# 3. Mass Errors by Fold
|
|
ax = axes[0, 2]
|
|
folds = [r['fold'] for r in fold_results]
|
|
mass_mapes = [r['mass_mape'] for r in fold_results]
|
|
bars = ax.bar(folds, mass_mapes, color='steelblue', alpha=0.7)
|
|
ax.axhline(y=np.mean(mass_mapes), color='red', linestyle='--', label=f'Mean: {np.mean(mass_mapes):.1f}%')
|
|
ax.axhline(y=10, color='green', linestyle='--', label='Target: 10%')
|
|
ax.set_xlabel('Fold')
|
|
ax.set_ylabel('Mass MAPE (%)')
|
|
ax.set_title('Mass MAPE by Fold')
|
|
ax.legend()
|
|
ax.grid(True, alpha=0.3, axis='y')
|
|
|
|
# 4. Frequency Errors by Fold
|
|
ax = axes[1, 0]
|
|
freq_mapes = [r['freq_mape'] for r in fold_results]
|
|
bars = ax.bar(folds, freq_mapes, color='coral', alpha=0.7)
|
|
ax.axhline(y=np.mean(freq_mapes), color='red', linestyle='--', label=f'Mean: {np.mean(freq_mapes):.1f}%')
|
|
ax.axhline(y=10, color='green', linestyle='--', label='Target: 10%')
|
|
ax.set_xlabel('Fold')
|
|
ax.set_ylabel('Frequency MAPE (%)')
|
|
ax.set_title('Frequency MAPE by Fold')
|
|
ax.legend()
|
|
ax.grid(True, alpha=0.3, axis='y')
|
|
|
|
# 5. Error Distribution (Mass)
|
|
ax = axes[1, 1]
|
|
mass_errors = (all_predictions[:, 0] - all_actuals[:, 0]) / all_actuals[:, 0] * 100
|
|
ax.hist(mass_errors, bins=30, color='steelblue', alpha=0.7, edgecolor='black')
|
|
ax.axvline(x=0, color='red', linestyle='--', linewidth=2)
|
|
ax.set_xlabel('Mass Error (%)')
|
|
ax.set_ylabel('Count')
|
|
ax.set_title(f'Mass Error Distribution (Mean={np.mean(mass_errors):.1f}%, Std={np.std(mass_errors):.1f}%)')
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# 6. Error Distribution (Frequency)
|
|
ax = axes[1, 2]
|
|
freq_errors = (all_predictions[:, 1] - all_actuals[:, 1]) / all_actuals[:, 1] * 100
|
|
ax.hist(freq_errors, bins=30, color='coral', alpha=0.7, edgecolor='black')
|
|
ax.axvline(x=0, color='red', linestyle='--', linewidth=2)
|
|
ax.set_xlabel('Frequency Error (%)')
|
|
ax.set_ylabel('Count')
|
|
ax.set_title(f'Freq Error Distribution (Mean={np.mean(freq_errors):.1f}%, Std={np.std(freq_errors):.1f}%)')
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
plt.suptitle('Cross-Validation Results on Real FEA Data', fontsize=14, fontweight='bold')
|
|
plt.tight_layout()
|
|
plt.savefig(save_path, dpi=150)
|
|
plt.close()
|
|
print(f"Saved validation plot: {save_path}")
|
|
|
|
|
|
def main():
|
|
print("="*70)
|
|
print("Cross-Validation Assessment on Real FEA Data")
|
|
print("="*70)
|
|
|
|
# Find database
|
|
db_path = project_root / "studies/uav_arm_optimization/2_results/study.db"
|
|
study_name = "uav_arm_optimization"
|
|
|
|
if not db_path.exists():
|
|
db_path = project_root / "studies/uav_arm_atomizerfield_test/2_results/study.db"
|
|
study_name = "uav_arm_atomizerfield_test"
|
|
|
|
if not db_path.exists():
|
|
print(f"ERROR: No database found")
|
|
return
|
|
|
|
print(f"\nLoading data from: {db_path}")
|
|
design_params, objectives, design_var_names = extract_training_data_from_study(
|
|
str(db_path), study_name
|
|
)
|
|
|
|
print(f"Total FEA samples: {len(design_params)}")
|
|
print(f"Design variables: {design_var_names}")
|
|
print(f"Objective ranges:")
|
|
print(f" Mass: {objectives[:, 0].min():.1f} - {objectives[:, 0].max():.1f} g")
|
|
print(f" Frequency: {objectives[:, 1].min():.1f} - {objectives[:, 1].max():.1f} Hz")
|
|
|
|
# Run k-fold cross-validation
|
|
print("\n" + "="*70)
|
|
print("Running 5-Fold Cross-Validation")
|
|
print("="*70)
|
|
|
|
fold_results, all_predictions, all_actuals, all_indices = k_fold_cross_validation(
|
|
design_params, objectives, design_var_names,
|
|
n_folds=5,
|
|
hidden_dims=[128, 64, 32], # Deeper network
|
|
epochs=300
|
|
)
|
|
|
|
# Summary statistics
|
|
print("\n" + "="*70)
|
|
print("CROSS-VALIDATION SUMMARY")
|
|
print("="*70)
|
|
|
|
mass_mapes = [r['mass_mape'] for r in fold_results]
|
|
freq_mapes = [r['freq_mape'] for r in fold_results]
|
|
|
|
cv_summary = {
|
|
'mass_mape': np.mean(mass_mapes),
|
|
'mass_std': np.std(mass_mapes),
|
|
'freq_mape': np.mean(freq_mapes),
|
|
'freq_std': np.std(freq_mapes)
|
|
}
|
|
|
|
print(f"\nMass Prediction:")
|
|
print(f" MAPE: {cv_summary['mass_mape']:.1f}% +/- {cv_summary['mass_std']:.1f}%")
|
|
print(f" Status: {'[OK]' if cv_summary['mass_mape'] < 10 else '[NEEDS IMPROVEMENT]'}")
|
|
|
|
print(f"\nFrequency Prediction:")
|
|
print(f" MAPE: {cv_summary['freq_mape']:.1f}% +/- {cv_summary['freq_std']:.1f}%")
|
|
print(f" Status: {'[OK]' if cv_summary['freq_mape'] < 10 else '[NEEDS IMPROVEMENT]'}")
|
|
|
|
# Overall confidence
|
|
mass_ok = cv_summary['mass_mape'] < 10
|
|
freq_ok = cv_summary['freq_mape'] < 10
|
|
|
|
if mass_ok and freq_ok:
|
|
confidence = "HIGH"
|
|
recommendation = "NN surrogate is ready for optimization"
|
|
elif mass_ok:
|
|
confidence = "MEDIUM"
|
|
recommendation = "Mass prediction is good, but frequency needs more data or better architecture"
|
|
else:
|
|
confidence = "LOW"
|
|
recommendation = "Need more FEA data or improved NN architecture"
|
|
|
|
print(f"\nOverall Confidence: {confidence}")
|
|
print(f"Recommendation: {recommendation}")
|
|
|
|
# Generate plots
|
|
plot_path = project_root / "cv_validation_results.png"
|
|
plot_cv_results(all_predictions, all_actuals, fold_results, str(plot_path))
|
|
|
|
# Train and save final model
|
|
model, model_state = train_final_model_with_cv_uncertainty(
|
|
design_params, objectives, design_var_names, cv_summary
|
|
)
|
|
|
|
print("\n" + "="*70)
|
|
print("FILES GENERATED")
|
|
print("="*70)
|
|
print(f" Validation plot: {plot_path}")
|
|
print(f" CV-validated model: {project_root / 'cv_validated_surrogate.pt'}")
|
|
|
|
# Final assessment
|
|
print("\n" + "="*70)
|
|
print("NEXT STEPS")
|
|
print("="*70)
|
|
|
|
if confidence == "HIGH":
|
|
print("1. Use the NN surrogate for fast optimization")
|
|
print("2. Periodically validate Pareto-optimal designs with FEA")
|
|
elif confidence == "MEDIUM":
|
|
print("1. Frequency prediction needs improvement")
|
|
print("2. Options:")
|
|
print(" a. Collect more FEA samples in underrepresented regions")
|
|
print(" b. Try deeper/wider network architecture")
|
|
print(" c. Add physics-informed features (e.g., I-beam moment of inertia)")
|
|
print(" d. Use ensemble with uncertainty-weighted Pareto front")
|
|
else:
|
|
print("1. Current model not ready for optimization")
|
|
print("2. Run more FEA trials to expand training data")
|
|
print("3. Consider data augmentation or transfer learning")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|