validation.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. """
  2. Model validation and testing framework for ML trainers.
  3. This module provides comprehensive validation and testing capabilities including
  4. cross-validation, model evaluation, performance benchmarking, and robustness testing.
  5. """
  6. import os
  7. import logging
  8. import time
  9. import random
  10. from pathlib import Path
  11. from typing import Dict, List, Tuple, Optional, Any, Callable, Union
  12. from dataclasses import dataclass
  13. import numpy as np
  14. from collections import defaultdict
  15. import torch
  16. import torch.nn as nn
  17. import torch.nn.functional as F
  18. from torch.utils.data import DataLoader, Subset, random_split
  19. from sklearn.model_selection import KFold, StratifiedKFold
  20. from sklearn.metrics import classification_report, confusion_matrix
  21. from .utils import ValidationMetrics, ModelProfiler
  22. from .data_pipeline import AudioDataset, AudioAugmentation, AudioProcessor
  23. @dataclass
  24. class ValidationConfig:
  25. """Configuration for validation and testing."""
  26. k_folds: int = 5
  27. test_augmentations: bool = True
  28. robustness_tests: bool = True
  29. performance_profiling: bool = True
  30. save_predictions: bool = True
  31. save_embeddings: bool = False
  32. confidence_threshold: float = 0.5
  33. batch_size: int = 32
  34. num_workers: int = 4
  35. class ModelValidator:
  36. """
  37. Comprehensive model validation and testing framework.
  38. Provides various validation methods including standard validation,
  39. cross-validation, robustness testing, and performance profiling.
  40. """
  41. def __init__(self, model: nn.Module, device: str = "cpu",
  42. logger: Optional[logging.Logger] = None):
  43. """
  44. Initialize model validator.
  45. Args:
  46. model: PyTorch model to validate
  47. device: Device for validation
  48. logger: Optional logger instance
  49. """
  50. self.model = model
  51. self.device = device
  52. self.logger = logger or logging.getLogger(__name__)
  53. # Initialize validation metrics calculator
  54. self.metrics_calculator = ValidationMetrics(
  55. task_type="classification" # Will be updated based on model
  56. )
  57. # Initialize model profiler
  58. self.profiler = ModelProfiler(model, device)
  59. # Results storage
  60. self.validation_results = {}
  61. self.test_results = {}
  62. self.cross_validation_results = {}
  63. self.robustness_results = {}
  64. self.profiling_results = {}
  65. def validate(self, val_loader: DataLoader, criterion: nn.Module,
  66. config: ValidationConfig = None) -> Dict[str, Any]:
  67. """
  68. Perform standard validation.
  69. Args:
  70. val_loader: Validation data loader
  71. criterion: Loss criterion
  72. config: Validation configuration
  73. Returns:
  74. Validation results dictionary
  75. """
  76. if config is None:
  77. config = ValidationConfig()
  78. self.logger.info("Starting model validation...")
  79. self.model.eval()
  80. total_loss = 0.0
  81. all_predictions = []
  82. all_probabilities = []
  83. all_targets = []
  84. all_embeddings = []
  85. validation_start_time = time.time()
  86. with torch.no_grad():
  87. for batch_idx, (data, targets) in enumerate(val_loader):
  88. data = data.to(self.device)
  89. targets = targets.to(self.device)
  90. # Forward pass
  91. outputs = self.model(data)
  92. loss = criterion(outputs, targets)
  93. total_loss += loss.item()
  94. # Get predictions
  95. if len(outputs.shape) > 1 and outputs.shape[1] > 1:
  96. # Classification - get probabilities and predictions
  97. probabilities = F.softmax(outputs, dim=1)
  98. predictions = torch.argmax(outputs, dim=1)
  99. all_probabilities.append(probabilities.cpu().numpy())
  100. all_predictions.append(predictions.cpu().numpy())
  101. else:
  102. # Regression or single output
  103. all_predictions.append(outputs.cpu().numpy())
  104. all_targets.append(targets.cpu().numpy())
  105. # Store embeddings if model has embedding layer
  106. if hasattr(self.model, 'get_embeddings'):
  107. try:
  108. embeddings = self.model.get_embeddings(data)
  109. all_embeddings.append(embeddings.cpu().numpy())
  110. except:
  111. pass
  112. validation_time = time.time() - validation_start_time
  113. # Concatenate all results
  114. all_targets = np.concatenate(all_targets)
  115. all_predictions = np.concatenate(all_predictions)
  116. if all_probabilities:
  117. all_probabilities = np.concatenate(all_probabilities)
  118. else:
  119. all_probabilities = None
  120. if all_embeddings:
  121. all_embeddings = np.concatenate(all_embeddings)
  122. else:
  123. all_embeddings = None
  124. # Calculate metrics
  125. avg_loss = total_loss / len(val_loader)
  126. # Update metrics calculator with appropriate settings
  127. num_classes = len(np.unique(all_targets))
  128. self.metrics_calculator.num_classes = num_classes
  129. self.metrics_calculator.class_names = [f"Class_{i}" for i in range(num_classes)]
  130. metrics = self.metrics_calculator.calculate_all_metrics(
  131. all_targets, all_predictions, all_probabilities, all_embeddings
  132. )
  133. # Compile results
  134. results = {
  135. "validation_loss": avg_loss,
  136. "validation_time": validation_time,
  137. "num_samples": len(all_targets),
  138. "metrics": metrics
  139. }
  140. # Save predictions if requested
  141. if config.save_predictions:
  142. results["predictions"] = all_predictions.tolist()
  143. results["targets"] = all_targets.tolist()
  144. if all_probabilities is not None:
  145. results["probabilities"] = all_probabilities.tolist()
  146. # Save embeddings if requested
  147. if config.save_embeddings and all_embeddings is not None:
  148. results["embeddings"] = all_embeddings.tolist()
  149. self.validation_results = results
  150. self.logger.info(f"Validation completed - Loss: {avg_loss:.6f}, "
  151. f"Accuracy: {metrics.get('accuracy', 0):.4f}")
  152. return results
  153. def test(self, test_loader: DataLoader, criterion: nn.Module,
  154. config: ValidationConfig = None) -> Dict[str, Any]:
  155. """
  156. Perform comprehensive testing.
  157. Args:
  158. test_loader: Test data loader
  159. criterion: Loss criterion
  160. config: Validation configuration
  161. Returns:
  162. Test results dictionary
  163. """
  164. if config is None:
  165. config = ValidationConfig()
  166. self.logger.info("Starting model testing...")
  167. # Standard test
  168. test_results = self.validate(test_loader, criterion, config)
  169. test_results["test_type"] = "standard"
  170. results = {"standard_test": test_results}
  171. # Augmentation robustness test
  172. if config.test_augmentations:
  173. aug_results = self._test_augmentation_robustness(test_loader, criterion, config)
  174. results["augmentation_robustness"] = aug_results
  175. # Performance profiling
  176. if config.performance_profiling:
  177. profile_results = self._profile_performance(test_loader)
  178. results["performance_profile"] = profile_results
  179. # Additional robustness tests
  180. if config.robustness_tests:
  181. robustness_results = self._test_robustness(test_loader, criterion, config)
  182. results["robustness_tests"] = robustness_results
  183. self.test_results = results
  184. self.logger.info("Testing completed successfully")
  185. return results
  186. def cross_validate(self, dataset: AudioDataset, criterion: nn.Module,
  187. config: ValidationConfig = None) -> Dict[str, Any]:
  188. """
  189. Perform k-fold cross-validation.
  190. Args:
  191. dataset: Complete dataset for cross-validation
  192. criterion: Loss criterion
  193. config: Validation configuration
  194. Returns:
  195. Cross-validation results
  196. """
  197. if config is None:
  198. config = ValidationConfig()
  199. self.logger.info(f"Starting {config.k_folds}-fold cross-validation...")
  200. # Get labels for stratified split
  201. labels = np.array(dataset.labels)
  202. # Use stratified k-fold for balanced splits
  203. if len(np.unique(labels)) > 1:
  204. kfold = StratifiedKFold(n_splits=config.k_folds, shuffle=True, random_state=42)
  205. splits = list(kfold.split(np.arange(len(dataset)), labels))
  206. else:
  207. kfold = KFold(n_splits=config.k_folds, shuffle=True, random_state=42)
  208. splits = list(kfold.split(np.arange(len(dataset))))
  209. fold_results = []
  210. for fold, (train_idx, val_idx) in enumerate(splits):
  211. self.logger.info(f"Evaluating fold {fold + 1}/{config.k_folds}")
  212. # Create validation subset
  213. val_subset = Subset(dataset, val_idx)
  214. val_loader = DataLoader(
  215. val_subset,
  216. batch_size=config.batch_size,
  217. shuffle=False,
  218. num_workers=config.num_workers
  219. )
  220. # Validate on this fold
  221. fold_result = self.validate(val_loader, criterion, config)
  222. fold_result["fold"] = fold
  223. fold_result["train_samples"] = len(train_idx)
  224. fold_result["val_samples"] = len(val_idx)
  225. fold_results.append(fold_result)
  226. self.logger.info(f"Fold {fold + 1} - Loss: {fold_result['validation_loss']:.6f}, "
  227. f"Accuracy: {fold_result['metrics'].get('accuracy', 0):.4f}")
  228. # Aggregate results
  229. cv_results = self._aggregate_cv_results(fold_results)
  230. self.cross_validation_results = cv_results
  231. self.logger.info(f"Cross-validation completed - Mean Accuracy: "
  232. f"{cv_results['mean_accuracy']:.4f} ± {cv_results['std_accuracy']:.4f}")
  233. return cv_results
  234. def _test_augmentation_robustness(self, test_loader: DataLoader,
  235. criterion: nn.Module,
  236. config: ValidationConfig) -> Dict[str, Any]:
  237. """Test model robustness against data augmentations."""
  238. self.logger.info("Testing augmentation robustness...")
  239. # Define augmentation configurations
  240. augmentation_configs = {
  241. "noise_light": {"noise_factor": 0.05},
  242. "noise_moderate": {"noise_factor": 0.15},
  243. "noise_heavy": {"noise_factor": 0.25},
  244. "speed_light": {"speed_factor": 0.05},
  245. "speed_moderate": {"speed_factor": 0.15},
  246. "volume_light": {"volume_factor": 0.1},
  247. "volume_moderate": {"volume_factor": 0.3},
  248. "combined_light": {
  249. "noise_factor": 0.05,
  250. "speed_factor": 0.05,
  251. "volume_factor": 0.1
  252. }
  253. }
  254. augmentation = AudioAugmentation(sample_rate=16000, device=self.device)
  255. results = {}
  256. for aug_name, aug_config in augmentation_configs.items():
  257. self.logger.info(f"Testing with {aug_name} augmentation")
  258. self.model.eval()
  259. total_loss = 0.0
  260. all_predictions = []
  261. all_targets = []
  262. with torch.no_grad():
  263. for data, targets in test_loader:
  264. data = data.to(self.device)
  265. targets = targets.to(self.device)
  266. # Apply augmentation to raw audio (convert back from features)
  267. # This is a simplified approach - in practice, you'd need the raw audio
  268. # For now, we'll apply augmentation to the feature representations
  269. outputs = self.model(data)
  270. loss = criterion(outputs, targets)
  271. total_loss += loss.item()
  272. if len(outputs.shape) > 1 and outputs.shape[1] > 1:
  273. predictions = torch.argmax(outputs, dim=1)
  274. all_predictions.append(predictions.cpu().numpy())
  275. else:
  276. all_predictions.append(outputs.cpu().numpy())
  277. all_targets.append(targets.cpu().numpy())
  278. # Calculate metrics
  279. all_targets = np.concatenate(all_targets)
  280. all_predictions = np.concatenate(all_predictions)
  281. metrics = self.metrics_calculator.calculate_all_metrics(
  282. all_targets, all_predictions
  283. )
  284. results[aug_name] = {
  285. "loss": total_loss / len(test_loader),
  286. "accuracy": metrics.get("accuracy", 0),
  287. "metrics": metrics
  288. }
  289. return results
  290. def _profile_performance(self, test_loader: DataLoader) -> Dict[str, Any]:
  291. """Profile model performance characteristics."""
  292. self.logger.info("Profiling model performance...")
  293. # Get sample input
  294. sample_batch = next(iter(test_loader))
  295. sample_input = sample_batch[0][:1].to(self.device) # Single sample
  296. # Profile inference
  297. inference_stats = self.profiler.profile_inference(sample_input)
  298. memory_stats = self.profiler.profile_memory(sample_input)
  299. param_stats = self.profiler.count_parameters()
  300. size_stats = self.profiler.estimate_model_size()
  301. # Test batch processing performance
  302. batch_sizes = [1, 8, 16, 32, 64]
  303. batch_performance = {}
  304. for batch_size in batch_sizes:
  305. if batch_size <= len(sample_batch[0]):
  306. batch_input = sample_batch[0][:batch_size].to(self.device)
  307. batch_stats = self.profiler.profile_inference(batch_input, num_runs=20)
  308. batch_performance[f"batch_{batch_size}"] = {
  309. "inference_time": batch_stats["mean_inference_time"],
  310. "throughput": batch_stats["throughput_samples_per_second"]
  311. }
  312. return {
  313. "inference_performance": inference_stats,
  314. "memory_usage": memory_stats,
  315. "model_parameters": param_stats,
  316. "model_size": size_stats,
  317. "batch_performance": batch_performance
  318. }
  319. def _test_robustness(self, test_loader: DataLoader, criterion: nn.Module,
  320. config: ValidationConfig) -> Dict[str, Any]:
  321. """Test model robustness with various perturbations."""
  322. self.logger.info("Testing model robustness...")
  323. results = {}
  324. # Test with different confidence thresholds
  325. threshold_results = self._test_confidence_thresholds(test_loader, criterion)
  326. results["confidence_thresholds"] = threshold_results
  327. # Test with corrupted inputs
  328. corruption_results = self._test_input_corruptions(test_loader, criterion)
  329. results["input_corruptions"] = corruption_results
  330. # Test with adversarial examples (simplified)
  331. adversarial_results = self._test_adversarial_robustness(test_loader, criterion)
  332. results["adversarial_robustness"] = adversarial_results
  333. return results
  334. def _test_confidence_thresholds(self, test_loader: DataLoader,
  335. criterion: nn.Module) -> Dict[str, Any]:
  336. """Test model performance at different confidence thresholds."""
  337. thresholds = [0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]
  338. results = {}
  339. self.model.eval()
  340. all_probabilities = []
  341. all_targets = []
  342. with torch.no_grad():
  343. for data, targets in test_loader:
  344. data = data.to(self.device)
  345. targets = targets.to(self.device)
  346. outputs = self.model(data)
  347. if len(outputs.shape) > 1 and outputs.shape[1] > 1:
  348. probabilities = F.softmax(outputs, dim=1)
  349. all_probabilities.append(probabilities.cpu().numpy())
  350. all_targets.append(targets.cpu().numpy())
  351. if all_probabilities:
  352. all_probabilities = np.concatenate(all_probabilities)
  353. all_targets = np.concatenate(all_targets)
  354. for threshold in thresholds:
  355. # Get confident predictions
  356. max_probs = np.max(all_probabilities, axis=1)
  357. confident_mask = max_probs >= threshold
  358. if np.sum(confident_mask) > 0:
  359. confident_preds = np.argmax(all_probabilities[confident_mask], axis=1)
  360. confident_targets = all_targets[confident_mask]
  361. accuracy = np.mean(confident_preds == confident_targets)
  362. coverage = np.mean(confident_mask)
  363. results[f"threshold_{threshold}"] = {
  364. "accuracy": accuracy,
  365. "coverage": coverage,
  366. "num_samples": np.sum(confident_mask)
  367. }
  368. return results
  369. def _test_input_corruptions(self, test_loader: DataLoader,
  370. criterion: nn.Module) -> Dict[str, Any]:
  371. """Test robustness to input corruptions."""
  372. corruption_types = {
  373. "zero_out_10": lambda x: self._zero_out_random(x, 0.1),
  374. "zero_out_25": lambda x: self._zero_out_random(x, 0.25),
  375. "gaussian_noise_01": lambda x: x + torch.randn_like(x) * 0.1,
  376. "gaussian_noise_02": lambda x: x + torch.randn_like(x) * 0.2,
  377. "dropout_10": lambda x: F.dropout(x, p=0.1, training=True),
  378. "dropout_25": lambda x: F.dropout(x, p=0.25, training=True)
  379. }
  380. results = {}
  381. for corruption_name, corruption_func in corruption_types.items():
  382. self.logger.info(f"Testing with {corruption_name}")
  383. self.model.eval()
  384. total_loss = 0.0
  385. all_predictions = []
  386. all_targets = []
  387. with torch.no_grad():
  388. for data, targets in test_loader:
  389. data = data.to(self.device)
  390. targets = targets.to(self.device)
  391. # Apply corruption
  392. corrupted_data = corruption_func(data)
  393. outputs = self.model(corrupted_data)
  394. loss = criterion(outputs, targets)
  395. total_loss += loss.item()
  396. if len(outputs.shape) > 1 and outputs.shape[1] > 1:
  397. predictions = torch.argmax(outputs, dim=1)
  398. all_predictions.append(predictions.cpu().numpy())
  399. else:
  400. all_predictions.append(outputs.cpu().numpy())
  401. all_targets.append(targets.cpu().numpy())
  402. # Calculate metrics
  403. all_targets = np.concatenate(all_targets)
  404. all_predictions = np.concatenate(all_predictions)
  405. accuracy = np.mean(all_predictions == all_targets)
  406. results[corruption_name] = {
  407. "loss": total_loss / len(test_loader),
  408. "accuracy": accuracy
  409. }
  410. return results
  411. def _test_adversarial_robustness(self, test_loader: DataLoader,
  412. criterion: nn.Module) -> Dict[str, Any]:
  413. """Test robustness to adversarial examples (simplified FGSM)."""
  414. epsilon_values = [0.01, 0.05, 0.1, 0.2]
  415. results = {}
  416. for epsilon in epsilon_values:
  417. self.logger.info(f"Testing adversarial robustness with epsilon={epsilon}")
  418. total_loss = 0.0
  419. all_predictions = []
  420. all_targets = []
  421. for data, targets in test_loader:
  422. data = data.to(self.device)
  423. targets = targets.to(self.device)
  424. data.requires_grad = True
  425. # Forward pass
  426. outputs = self.model(data)
  427. loss = criterion(outputs, targets)
  428. # Backward pass to get gradients
  429. self.model.zero_grad()
  430. loss.backward()
  431. # Generate adversarial examples using FGSM
  432. data_grad = data.grad.data
  433. perturbed_data = data + epsilon * data_grad.sign()
  434. # Re-evaluate with perturbed data
  435. with torch.no_grad():
  436. perturbed_outputs = self.model(perturbed_data)
  437. perturbed_loss = criterion(perturbed_outputs, targets)
  438. total_loss += perturbed_loss.item()
  439. if len(perturbed_outputs.shape) > 1 and perturbed_outputs.shape[1] > 1:
  440. predictions = torch.argmax(perturbed_outputs, dim=1)
  441. all_predictions.append(predictions.cpu().numpy())
  442. else:
  443. all_predictions.append(perturbed_outputs.cpu().numpy())
  444. all_targets.append(targets.cpu().numpy())
  445. # Calculate metrics
  446. all_targets = np.concatenate(all_targets)
  447. all_predictions = np.concatenate(all_predictions)
  448. accuracy = np.mean(all_predictions == all_targets)
  449. results[f"epsilon_{epsilon}"] = {
  450. "loss": total_loss / len(test_loader),
  451. "accuracy": accuracy
  452. }
  453. return results
  454. def _zero_out_random(self, tensor: torch.Tensor, fraction: float) -> torch.Tensor:
  455. """Randomly zero out a fraction of tensor elements."""
  456. mask = torch.rand_like(tensor) < fraction
  457. return tensor * (~mask).float()
  458. def _aggregate_cv_results(self, fold_results: List[Dict[str, Any]]) -> Dict[str, Any]:
  459. """Aggregate cross-validation results across folds."""
  460. # Extract metrics from all folds
  461. losses = [result["validation_loss"] for result in fold_results]
  462. accuracies = [result["metrics"].get("accuracy", 0) for result in fold_results]
  463. # Calculate statistics
  464. aggregated = {
  465. "num_folds": len(fold_results),
  466. "mean_loss": np.mean(losses),
  467. "std_loss": np.std(losses),
  468. "mean_accuracy": np.mean(accuracies),
  469. "std_accuracy": np.std(accuracies),
  470. "fold_results": fold_results
  471. }
  472. # Aggregate other metrics if available
  473. metric_keys = set()
  474. for result in fold_results:
  475. metric_keys.update(result["metrics"].keys())
  476. for metric_key in metric_keys:
  477. if metric_key != "confusion_matrix" and metric_key != "classification_report":
  478. values = []
  479. for result in fold_results:
  480. if metric_key in result["metrics"]:
  481. value = result["metrics"][metric_key]
  482. if isinstance(value, (int, float)):
  483. values.append(value)
  484. if values:
  485. aggregated[f"mean_{metric_key}"] = np.mean(values)
  486. aggregated[f"std_{metric_key}"] = np.std(values)
  487. return aggregated
  488. def generate_validation_report(self, output_dir: str = None) -> str:
  489. """Generate comprehensive validation report."""
  490. report = "# Model Validation Report\n\n"
  491. # Add validation results
  492. if self.validation_results:
  493. report += "## Validation Results\n"
  494. val_res = self.validation_results
  495. report += f"- Validation Loss: {val_res['validation_loss']:.6f}\n"
  496. report += f"- Validation Time: {val_res['validation_time']:.2f} seconds\n"
  497. report += f"- Number of Samples: {val_res['num_samples']:,}\n"
  498. if "accuracy" in val_res["metrics"]:
  499. report += f"- Accuracy: {val_res['metrics']['accuracy']:.4f}\n"
  500. report += "\n"
  501. # Add test results
  502. if self.test_results:
  503. report += "## Test Results\n"
  504. # Standard test
  505. if "standard_test" in self.test_results:
  506. std_test = self.test_results["standard_test"]
  507. report += f"- Test Loss: {std_test['validation_loss']:.6f}\n"
  508. if "accuracy" in std_test["metrics"]:
  509. report += f"- Test Accuracy: {std_test['metrics']['accuracy']:.4f}\n"
  510. # Performance profile
  511. if "performance_profile" in self.test_results:
  512. profile = self.test_results["performance_profile"]
  513. report += f"- Inference Time: {profile['inference_performance']['mean_inference_time']*1000:.2f} ms\n"
  514. report += f"- Model Size: {profile['model_size']['total_size_mb']:.2f} MB\n"
  515. report += f"- Parameters: {profile['model_parameters']['total_parameters']:,}\n"
  516. report += "\n"
  517. # Add cross-validation results
  518. if self.cross_validation_results:
  519. report += "## Cross-Validation Results\n"
  520. cv_res = self.cross_validation_results
  521. report += f"- Number of Folds: {cv_res['num_folds']}\n"
  522. report += f"- Mean Accuracy: {cv_res['mean_accuracy']:.4f} ± {cv_res['std_accuracy']:.4f}\n"
  523. report += f"- Mean Loss: {cv_res['mean_loss']:.6f} ± {cv_res['std_loss']:.6f}\n"
  524. report += "\n"
  525. # Save report if output directory provided
  526. if output_dir:
  527. os.makedirs(output_dir, exist_ok=True)
  528. report_path = Path(output_dir) / "validation_report.md"
  529. with open(report_path, 'w') as f:
  530. f.write(report)
  531. self.logger.info(f"Validation report saved to: {report_path}")
  532. return report
  533. def save_results(self, output_dir: str):
  534. """Save all validation results to files."""
  535. output_dir = Path(output_dir)
  536. output_dir.mkdir(parents=True, exist_ok=True)
  537. # Save validation results
  538. if self.validation_results:
  539. val_path = output_dir / "validation_results.json"
  540. self._save_json(self.validation_results, val_path)
  541. # Save test results
  542. if self.test_results:
  543. test_path = output_dir / "test_results.json"
  544. self._save_json(self.test_results, test_path)
  545. # Save cross-validation results
  546. if self.cross_validation_results:
  547. cv_path = output_dir / "cross_validation_results.json"
  548. self._save_json(self.cross_validation_results, cv_path)
  549. # Generate and save report
  550. report = self.generate_validation_report(str(output_dir))
  551. self.logger.info(f"Validation results saved to: {output_dir}")
  552. def _save_json(self, data: Dict[str, Any], filepath: Path):
  553. """Save data to JSON file with proper handling of numpy arrays."""
  554. import json
  555. def convert_numpy(obj):
  556. if isinstance(obj, np.ndarray):
  557. return obj.tolist()
  558. elif isinstance(obj, np.integer):
  559. return int(obj)
  560. elif isinstance(obj, np.floating):
  561. return float(obj)
  562. return obj
  563. # Convert numpy objects recursively
  564. def clean_data(data):
  565. if isinstance(data, dict):
  566. return {k: clean_data(v) for k, v in data.items()}
  567. elif isinstance(data, list):
  568. return [clean_data(v) for v in data]
  569. else:
  570. return convert_numpy(data)
  571. cleaned_data = clean_data(data)
  572. with open(filepath, 'w') as f:
  573. json.dump(cleaned_data, f, indent=2)