devjas1
Initial Release: Polymer Aging With ML [Standalone Appliance]
4a0e21d
Raw
History Blame Contribute Delete
12.5 kB
# pylint: wrong-import-order, unused-import, import-outside-toplevel
"""
Backend service layer for ML inference.
Extracts and preserves the current Streamlit application logic for FastAPI.
Maintains scientific fidelity and deterministic outputs.
"""
import time
import gc
from typing import Tuple, List
from pathlib import Path
from datetime import datetime
import psutil
import torch
import torch.nn.functional as F
import numpy as np
from backend.utils.preprocessing import (
# We will replace these with SpectrumPreprocessor
# remove_baseline, smooth_spectrum, normalize_spectrum,
validate_spectrum_modality,
MODALITY_PARAMS
)
from backend.models.registry import get_model_info as get_registry_model_info
from backend.utils.performance import log_model_performance
from .config import TARGET_LEN, LABEL_MAP
from .pydantic_models import (
SpectrumData,
PredictionResult,
PreprocessingMetadata,
QualityControlMetadata,
ModelMetadata,
ModelInfo,
SystemInfo,
SystemHealth
)
from backend.utils.model_manager import model_manager
from backend.utils.preprocessing_fixed import SpectrumPreprocessor
class MLServiceError(Exception):
"""Custom exception for ML service errors."""
pass
class MLInferenceService:
"""
Core ML inference service that preserves the exact behavior of the Streamlit app.
Maintains scientific fidelity and deterministic outputs.
"""
def __init__(self, model_manager_instance=model_manager):
self.model_manager = model_manager_instance
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def get_memory_usage(self) -> float:
"""Get current memory usage in MB"""
try:
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024
except ImportError:
return 0.0
def cleanup_memory(self):
"""Clean up memory after inference"""
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def create_preprocessing_metadata(
self,
modality: str,
original_length: int,
x_data: np.ndarray,
validation_result: Tuple[bool, List[str]]
) -> PreprocessingMetadata:
"""Create preprocessing provenance metadata"""
params = MODALITY_PARAMS.get(modality, MODALITY_PARAMS["raman"])
is_valid, issues = validation_result
return PreprocessingMetadata(
target_length=TARGET_LEN,
baseline_degree=params["baseline_degree"],
smooth_window=params["smooth_window"],
smooth_polyorder=params["smooth_polyorder"],
normalization_method="min_max",
modality_validated=is_valid,
validation_issues=issues,
original_length=original_length,
wavenumber_range=[float(np.min(x_data)), float(np.max(x_data))]
)
def create_quality_control_metadata(
self,
y_data: np.ndarray,
y_processed: np.ndarray
) -> QualityControlMetadata:
"""Create quality control metadata with basic checks"""
issues = []
# Basic signal quality checks
signal_range = np.max(y_data) - np.min(y_data)
noise_estimate = np.std(np.diff(y_data))
snr = signal_range / noise_estimate if noise_estimate > 0 else None
# Check for saturation (values at extremes)
if np.any(y_data >= 0.99 * np.max(y_data)):
issues.append("Potential signal saturation detected")
# Check for cosmic rays (sudden spikes)
diff = np.abs(np.diff(y_data))
if len(diff) > 0:
threshold = np.mean(diff) + 5 * np.std(diff)
cosmic_ray_detected = np.any(diff > threshold)
if cosmic_ray_detected:
issues.append("Potential cosmic ray spikes detected")
else:
cosmic_ray_detected = False
# Baseline stability
baseline_stability = 0.0
if len(y_processed) >= 100:
baseline_stability = 1.0 - \
(np.std(y_processed[:50]) + np.std(y_processed[-50:])) / 2
baseline_stability = max(0.0, min(1.0, float(baseline_stability)))
return QualityControlMetadata(
signal_to_noise_ratio=snr,
baseline_stability=baseline_stability if baseline_stability > 0 else None,
spectral_resolution=None,
cosmic_ray_detected=bool(cosmic_ray_detected),
saturation_detected=any("saturation" in issue.lower()
for issue in issues),
issues=issues
)
def create_model_metadata(
self,
model_name: str,
weights_loaded: bool,
weights_path: Path
) -> ModelMetadata:
"""Create model metadata with calibration details"""
info = get_registry_model_info(model_name)
return ModelMetadata(
model_name=model_name,
model_description=info.get("description", ""),
model_version=None,
training_date=None,
input_length=info.get("input_length", TARGET_LEN),
num_classes=info.get("num_classes", 2),
parameters_count=info.get("parameters", "Unknown"),
performance_metrics=info.get("performance", {}),
supported_modalities=info.get("modalities", ["raman", "ftir"]),
citation=info.get("citation", ""),
weights_loaded=weights_loaded, # This comes from model_manager
weights_path=str(weights_path) if weights_loaded else None
)
def run_inference(
self,
spectrum: SpectrumData,
model_name: str,
modality: str,
include_provenance: bool = True
) -> PredictionResult:
"""
Run model inference preserving exact Streamlit behavior.
Returns complete result with full provenance metadata.
"""
start_total = time.time()
start_memory = self.get_memory_usage()
# Convert input data
x_data = np.array(spectrum.x_values)
y_data = np.array(spectrum.y_values)
original_length = len(y_data)
if original_length < 2:
raise MLServiceError("Spectrum must have at least 2 data points")
# Validate modality
validation_result = validate_spectrum_modality(x_data, y_data, modality)
# Preprocessing
start_preprocess = time.time()
# Use SpectrumPreprocessor for consistent preprocessing
preprocessor = SpectrumPreprocessor(
target_len=TARGET_LEN,
do_baseline=True, # Assuming these are desired for standard analysis
do_smooth=True,
do_normalize=True,
modality=modality
)
y_processed = preprocessor.preprocess_single_spectrum(x_data, y_data, use_fitted_stats=False)
# For x_resampled, we can just generate it based on target_len and original range
x_resampled = np.linspace(np.min(x_data), np.max(x_data), TARGET_LEN)
preprocessing_time = time.time() - start_preprocess
# Load model
model, weights_loaded, weights_path = self.model_manager.load_model(model_name)
if model is None:
raise MLServiceError(f"Model '{model_name}' not available")
# Ensure model is on the correct device before inference
model.to(self.device)
# Create input tensor
input_tensor = torch.tensor(y_processed, dtype=torch.float32).unsqueeze(0).unsqueeze(0)
input_tensor = input_tensor.to(self.device) # Move to device
# Inference
start_inference = time.time()
model.eval()
with torch.no_grad():
logits = model(input_tensor)
prediction = torch.argmax(logits, dim=1).item()
logits_list = logits.detach().numpy().tolist()[0]
probs = F.softmax(logits.detach(), dim=1).cpu().numpy().flatten()
inference_time = time.time() - start_inference
total_time = time.time() - start_total
end_memory = self.get_memory_usage()
memory_usage = max(end_memory - start_memory, 0)
# Log performance metrics for benchmarking
log_model_performance(
model_name=model_name,
inference_time=inference_time,
preprocessing_time=preprocessing_time,
total_time=total_time,
memory_usage=memory_usage,
spectrum_length=original_length
)
# Calculate confidence
confidence = float(max(probs)) if probs is not None and len(
probs) > 0 else 0.0
# Create metadata
if include_provenance:
preprocessing_metadata = self.create_preprocessing_metadata(
modality, original_length, x_data, validation_result
)
qc_metadata = self.create_quality_control_metadata(
y_data, y_processed)
model_metadata = self.create_model_metadata(
model_name, weights_loaded, weights_path)
else:
preprocessing_metadata = PreprocessingMetadata(
target_length=TARGET_LEN,
baseline_degree=2,
smooth_window=11,
smooth_polyorder=2,
normalization_method="min_max",
modality_validated=validation_result[0],
validation_issues=validation_result[1],
original_length=original_length,
wavenumber_range=[float(np.min(x_data)), float(np.max(x_data))]
)
qc_metadata = QualityControlMetadata(
signal_to_noise_ratio=None,
baseline_stability=None,
spectral_resolution=None,
cosmic_ray_detected=False,
saturation_detected=False,
issues=[]
)
model_metadata = self.create_model_metadata(
model_name, weights_loaded, weights_path) # Still need model metadata
# Create processed spectrum data
processed_spectrum = SpectrumData(
x_values=x_resampled.tolist(),
y_values=y_processed.tolist(),
filename=f"processed_{spectrum.filename}" if spectrum.filename else None
)
# Clean up memory
self.cleanup_memory()
return PredictionResult(
prediction=prediction,
prediction_label=LABEL_MAP[prediction] if prediction in LABEL_MAP else "Unknown",
confidence=confidence,
probabilities=probs.tolist(),
logits=logits_list,
preprocessing=preprocessing_metadata,
quality_control=qc_metadata,
model_metadata=model_metadata,
inference_time=inference_time,
preprocessing_time=preprocessing_time,
total_time=total_time,
memory_usage_mb=memory_usage,
original_spectrum=spectrum,
processed_spectrum=processed_spectrum,
timestamp=datetime.now().isoformat()
)
def get_available_models(self) -> List[ModelInfo]:
"""Get list of available models with their information"""
return self.model_manager.get_available_models()
def get_system_info(self) -> SystemInfo:
"""Get system information and health status"""
models = self.model_manager.get_available_models()
system_health_data = SystemHealth(
status="ok",
timestamp=time.time(),
models_loaded=sum(1 for m in models if m.available),
total_models=len(models),
memory_usage_mb=self.get_memory_usage(),
torch_version=torch.__version__,
cuda_available=torch.cuda.is_available()
)
return SystemInfo(
version="1.0.0",
available_models=models,
supported_modalities=["raman", "ftir"],
max_batch_size=100,
target_spectrum_length=TARGET_LEN,
system_health=system_health_data
)
# Global service instance
ml_service = MLInferenceService()