polymer-aging-with-ml / backend /utils /enhanced_ml_service.py
devjas1
Initial Release: Polymer Aging With ML [Standalone Appliance]
4a0e21d
Raw
History Blame Contribute Delete
12.5 kB
# pylint: disable=wrong-import-order, unused-import
"""
Enhanced API endpoints with explainability features.
Extends the existing FastAPI backend with SHAP-based model explanations
and improved prediction capabilities.
"""
from backend.config import TARGET_LEN # Import TARGET_LEN for model loading
import numpy as np
import torch
from typing import Dict, Any, List, Optional
from fastapi import HTTPException # Keep HTTPException for API errors
# PredictionResult is not directly returned by this service
from backend.pydantic_models import SpectrumData
from backend.models.registry import build as build_model, choices, registry_spec
from backend.utils.preprocessing_fixed import SpectrumPreprocessor
import os
# Import moved here to the toplevel
from backend.utils.model_manager import model_manager
class EnhancedMLService:
"""
Enhanced ML service with explainability features.
Provides predictions with feature importance and model confidence.
"""
def __init__(self):
self.model_manager = model_manager
# Local cache for loaded models (model, preprocessor)
self._model_cache = {}
self.device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu")
print(f"✅ Enhanced ML Service initialized on {self.device}")
def cache_model(self, model_name: str, model_instance, preprocessor):
"""Public method to cache a model and its preprocessor."""
self._model_cache[model_name] = {
'model': model_instance,
'preprocessor': preprocessor
}
def predict_with_explanation(
self,
spectrum_data: SpectrumData,
model_name: str,
modality: str = "raman",
include_feature_importance: bool = True
) -> Dict[str, Any]:
"""
Make prediction with explainability features.
Args:
spectrum_data (SpectrumData): Input spectrum data
model_name (str): Name of model to use
modality (str): The spectroscopy modality ('raman' or 'ftir')
include_feature_importance (bool): Whether to compute feature importance
Returns:
dict: Prediction results with explanations
"""
if model_name not in self._model_cache:
# Attempt to load model via centralized manager if not in local cache
model_instance, weights_loaded, _ = self.model_manager.load_model(
model_name)
if model_instance is None or not weights_loaded:
raise HTTPException(
status_code=400,
detail=f"Model {model_name} not loaded or weights not found"
)
# Determine model input length robustly: prefer model attribute,
# fallback to registry/spec, then TARGET_LEN
input_len = getattr(model_instance, 'input_length', None)
if input_len is None:
try:
spec_info = registry_spec(model_name)
input_len = int(spec_info.get("input_length", TARGET_LEN))
except Exception:
input_len = TARGET_LEN
# Create preprocessor for this model (use resolved input_len)
preprocessor = SpectrumPreprocessor(
target_len=input_len,
do_baseline=True,
do_smooth=True,
do_normalize=True,
modality=modality # Use the provided modality
)
self._model_cache[model_name] = {
'model': model_instance, 'preprocessor': preprocessor}
model_entry = self._model_cache.get(model_name)
if not model_entry: # Should not happen if previous block executed
raise HTTPException(
status_code=400,
detail=f"Model {model_name} not loaded"
)
model = model_entry['model']
# --- FIX: Ensure preprocessor has the correct modality ---
# The preprocessor might have been cached with a default or different modality.
# We must ensure it matches the one from the current request.
if model_entry['preprocessor'].modality != modality:
print(
f"🔄 Updating preprocessor modality for '{model_name}' from '{model_entry['preprocessor'].modality}' to '{modality}'")
model_entry['preprocessor'] = SpectrumPreprocessor(
target_len=model.input_length,
do_baseline=True, do_smooth=True, do_normalize=True,
modality=modality
)
preprocessor = model_entry['preprocessor']
try:
# Preprocess input data
x_data = np.array(spectrum_data.x_values)
y_data = np.array(spectrum_data.y_values)
# Preprocess spectrum
processed_spectrum = preprocessor.preprocess_single_spectrum(
x_data, y_data, use_fitted_stats=False
)
# Convert to tensor
input_tensor = torch.tensor(
processed_spectrum, dtype=torch.float32)
# Add batch and channel dimensions
input_tensor = input_tensor.unsqueeze(0)
input_tensor = input_tensor.unsqueeze(0)
input_tensor = input_tensor.to(self.device)
# Make prediction
with torch.no_grad():
outputs = model(input_tensor)
probabilities = torch.softmax(outputs, dim=1)
predicted_class = torch.argmax(probabilities, dim=1).item()
confidence = torch.max(probabilities).item()
# Basic prediction result
result = {
'prediction': predicted_class,
'confidence': confidence,
'probabilities': probabilities.cpu().numpy().tolist()[0],
'class_labels': ['stable', 'weathered'],
'model_used': model_name,
'spectrum_filename': spectrum_data.filename
}
# Add feature importance if requested
if include_feature_importance:
feature_importance = self._compute_feature_importance(
model, input_tensor, processed_spectrum
)
result['feature_importance'] = feature_importance
return result
except (RuntimeError, ValueError, TypeError) as e:
raise HTTPException(
status_code=500,
detail=f"Prediction failed: {str(e)}"
) from e
def _compute_feature_importance(
self,
model: torch.nn.Module,
input_tensor: torch.Tensor,
processed_spectrum: np.ndarray
) -> Dict[str, Any]:
"""
Compute feature importance using gradient-based methods.
Args:
model: PyTorch model
input_tensor: Preprocessed input tensor
processed_spectrum: Original processed spectrum
Returns:
dict: Feature importance information
"""
try:
# Enable gradient computation
input_tensor.requires_grad_(True)
torch.set_grad_enabled(True)
# Forward pass
output = model(input_tensor)
predicted_class = torch.argmax(output, dim=1).item()
# Compute gradients with respect to input
class_score = output[0, predicted_class]
class_score.backward()
if input_tensor.grad is not None:
gradients = input_tensor.grad.data.cpu().numpy().squeeze()
else:
raise RuntimeError(
"Gradients were not computed. Ensure requires_grad is set "
"and gradient computation is enabled."
)
gradients = input_tensor.grad.data.cpu().numpy().squeeze()
# Compute importance metrics
importance_abs = np.abs(gradients)
# Find most important regions
top_indices = np.argsort(importance_abs)[-20:] # Top 20 features
# Create interpretable output
feature_importance = {
'method': 'gradient_saliency',
'importance_scores': importance_abs.tolist(),
'top_features': {
'indices': top_indices.tolist(),
'values': importance_abs[top_indices].tolist()
},
'summary': {
'max_importance': float(np.max(importance_abs)),
'mean_importance': float(np.mean(importance_abs)),
'important_region_start': int(top_indices[0]),
'important_region_end': int(top_indices[-1])
}
}
return feature_importance
except (RuntimeError, ValueError, TypeError) as e:
print(f"⚠️ Feature importance computation failed: {e}")
return {
'method': 'gradient_saliency',
'error': str(e),
'importance_scores': [0.0] * len(processed_spectrum)
}
def get_model_info(self) -> List[Dict[str, Any]]:
"""
Get information about loaded models.
Returns:
list: List of ModelInfo objects from the centralized manager.
"""
return self.model_manager.get_available_models()
def batch_predict_with_explanation(
self,
spectra: List[SpectrumData],
model_name: str,
modality: str, # Add modality for preprocessor
include_feature_importance: bool = True
) -> List[Dict[str, Any]]:
"""
Batch prediction with explanations.
Args:
spectra (list): List of spectrum data
model_name (str): Model to use
modality (str): Spectroscopy modality
include_feature_importance (bool): Whether to include explanations
Returns:
list: List of prediction results
"""
results = []
for spectrum in spectra:
try:
result = self.predict_with_explanation(
spectrum,
model_name,
modality=modality, # Pass modality down
include_feature_importance=include_feature_importance
)
results.append(result)
except (HTTPException, ValueError, RuntimeError) as e:
results.append({
'error': str(e),
'spectrum_filename': spectrum.filename
})
return results
# Global enhanced service instance
enhanced_ml_service = EnhancedMLService()
def initialize_enhanced_service():
"""Initialize the enhanced ML service with available models."""
print("Initializing Enhanced ML Service models...")
# Iterate through all known models in the registry by calling choices() directly
for model_name in choices():
try:
# Attempt to load each model via the centralized manager
model_instance, weights_loaded, _ = enhanced_ml_service.model_manager.load_model(
model_name, TARGET_LEN)
if model_instance and weights_loaded:
preprocessor = SpectrumPreprocessor(
target_len=TARGET_LEN,
do_baseline=True,
do_smooth=True,
do_normalize=True,
modality="raman"
)
enhanced_ml_service.cache_model(model_name, model_instance, preprocessor)
print(f"✅ Enhanced ML Service: Prepared model '{model_name}' with preprocessor.")
else:
print(
f"⚠️ Enhanced ML Service: Model '{model_name}' not fully loaded or weights missing.")
except (RuntimeError, ValueError, ImportError) as e:
print(
f"❌ Enhanced ML Service: Error initializing model '{model_name}': {e}")
# Initialize on import
initialize_enhanced_service()