trainer.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. """
  2. Wakeword detection trainer implementation.
  3. This module provides a complete trainer for wakeword detection models
  4. using RepCNN architecture with comprehensive training, validation, and
  5. deployment capabilities.
  6. """
  7. import os
  8. import time
  9. import logging
  10. from pathlib import Path
  11. from typing import Dict, List, Tuple, Optional, Any
  12. import numpy as np
  13. import torch
  14. import torch.nn as nn
  15. import torch.optim as optim
  16. import torch.nn.functional as F
  17. from torch.utils.data import DataLoader
  18. from ..base import BaseTrainer, TrainerConfig, TrainingState
  19. from ..metadata import ModelMetadata, MetadataManager, ModelType
  20. from ..model_formats import ModelFormatManager
  21. from ..data_pipeline import AudioProcessingConfig
  22. from ..utils import TrainerLogger, ProgressMonitor, ValidationMetrics
  23. from ..validation import ModelValidator, ValidationConfig
  24. from .models import RepCNN, ImprovedRepCNN, LightweightRepCNN, create_repcnn_model
  25. from .data import WakewordDataPreprocessor, create_wakeword_dataloaders
  26. class FocalLoss(nn.Module):
  27. """
  28. Focal Loss for addressing class imbalance in wakeword detection.
  29. Particularly useful when dealing with imbalanced datasets where
  30. negative samples significantly outnumber positive wakeword samples.
  31. """
  32. def __init__(self, alpha: float = 1.0, gamma: float = 2.0,
  33. class_weights: Optional[torch.Tensor] = None):
  34. """
  35. Initialize Focal Loss.
  36. Args:
  37. alpha: Weighting factor for rare class
  38. gamma: Focusing parameter
  39. class_weights: Optional class weights
  40. """
  41. super().__init__()
  42. self.alpha = alpha
  43. self.gamma = gamma
  44. self.class_weights = class_weights
  45. def forward(self, inputs: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
  46. """Compute focal loss."""
  47. ce_loss = F.cross_entropy(inputs, targets, weight=self.class_weights, reduction='none')
  48. pt = torch.exp(-ce_loss)
  49. focal_loss = self.alpha * (1 - pt) ** self.gamma * ce_loss
  50. return focal_loss.mean()
  51. class WakewordTrainer(BaseTrainer):
  52. """
  53. Specialized trainer for wakeword detection models.
  54. Provides end-to-end training pipeline for wakeword detection including
  55. data preprocessing, model training, validation, and deployment preparation.
  56. """
  57. def __init__(self, config: TrainerConfig):
  58. """Initialize wakeword trainer."""
  59. super().__init__(config)
  60. # Wakeword-specific configuration
  61. self.wakeword_classes = config.custom_params.get('wakeword_classes', {
  62. 0: "custom",
  63. 1: "system_command",
  64. 2: "negative"
  65. })
  66. self.model_type = config.custom_params.get('model_type', 'standard')
  67. self.detection_threshold = config.custom_params.get('detection_threshold', 0.5)
  68. self.use_focal_loss = config.custom_params.get('use_focal_loss', True)
  69. self.class_weights = config.custom_params.get('class_weights', None)
  70. # Audio processing configuration
  71. self.audio_config = AudioProcessingConfig(
  72. sample_rate=config.sample_rate,
  73. target_length=config.audio_length,
  74. n_mels=config.n_mels,
  75. n_fft=config.n_fft,
  76. hop_length=config.hop_length,
  77. win_length=config.win_length
  78. )
  79. # Initialize specialized components
  80. self.data_preprocessor = WakewordDataPreprocessor(self.audio_config, self.logger)
  81. self.metadata_manager = MetadataManager(self.logger)
  82. self.format_manager = ModelFormatManager(self.logger)
  83. # Model validation
  84. self.validator = None
  85. self.logger.info(f"Initialized WakewordTrainer with classes: {self.wakeword_classes}")
  86. def prepare_data(self) -> Tuple[DataLoader, DataLoader, DataLoader]:
  87. """
  88. Prepare wakeword detection data.
  89. Returns:
  90. Tuple of (train_loader, val_loader, test_loader)
  91. """
  92. self.logger.info("Preparing wakeword detection data...")
  93. # Check if data is already preprocessed
  94. processed_data_dir = Path(self.config.data_dir) / "processed"
  95. if not processed_data_dir.exists() or not any(processed_data_dir.iterdir()):
  96. # Preprocess raw data
  97. raw_data_dir = Path(self.config.data_dir) / "raw"
  98. if not raw_data_dir.exists():
  99. raise FileNotFoundError(f"Raw data directory not found: {raw_data_dir}")
  100. self.logger.info("Preprocessing raw wakeword data...")
  101. processed_path = self.data_preprocessor.preprocess_wakeword_data(
  102. str(raw_data_dir),
  103. str(processed_data_dir),
  104. self.wakeword_classes
  105. )
  106. self.logger.info(f"Data preprocessing completed: {processed_path}")
  107. # Create data loaders
  108. augmentation_config = {
  109. 'noise_factor': self.config.noise_factor,
  110. 'speed_factor': self.config.speed_factor,
  111. 'pitch_factor': self.config.pitch_factor,
  112. 'volume_factor': self.config.volume_factor,
  113. 'time_shift_factor': 0.1
  114. } if self.config.use_augmentation else None
  115. train_loader, val_loader, test_loader = create_wakeword_dataloaders(
  116. str(processed_data_dir),
  117. self.wakeword_classes,
  118. self.audio_config,
  119. batch_size=self.config.batch_size,
  120. num_workers=self.config.num_workers,
  121. augmentation_config=augmentation_config,
  122. balance_training=True
  123. )
  124. self.logger.info(f"Created data loaders - Train: {len(train_loader.dataset)}, "
  125. f"Val: {len(val_loader.dataset)}, Test: {len(test_loader.dataset)}")
  126. return train_loader, val_loader, test_loader
  127. def build_model(self) -> nn.Module:
  128. """
  129. Build RepCNN model for wakeword detection.
  130. Returns:
  131. RepCNN model
  132. """
  133. self.logger.info(f"Building {self.model_type} RepCNN model...")
  134. # Calculate input dimensions
  135. time_frames = int(self.audio_config.target_length * self.audio_config.sample_rate
  136. // self.audio_config.hop_length) + 1
  137. model_kwargs = {
  138. 'num_classes': len(self.wakeword_classes),
  139. 'input_channels': 1,
  140. 'num_mels': self.audio_config.n_mels,
  141. 'time_frames': time_frames,
  142. 'width_multiplier': self.config.custom_params.get('width_multiplier', 1.0),
  143. 'use_se': self.config.custom_params.get('use_se', False),
  144. 'dropout_rate': self.config.custom_params.get('dropout_rate', 0.2)
  145. }
  146. # Add model-specific parameters
  147. if self.model_type == 'improved':
  148. model_kwargs['use_temporal_attention'] = self.config.custom_params.get('use_temporal_attention', True)
  149. elif self.model_type == 'lightweight':
  150. model_kwargs['width_multiplier'] = min(model_kwargs['width_multiplier'], 0.75)
  151. model = create_repcnn_model(self.model_type, **model_kwargs)
  152. # Log model information
  153. total_params = sum(p.numel() for p in model.parameters())
  154. trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
  155. self.logger.info(f"Model created - Type: {self.model_type}")
  156. self.logger.info(f"Total parameters: {total_params:,}")
  157. self.logger.info(f"Trainable parameters: {trainable_params:,}")
  158. return model
  159. def create_criterion(self) -> nn.Module:
  160. """
  161. Create loss criterion for wakeword detection.
  162. Returns:
  163. Loss function
  164. """
  165. if self.use_focal_loss:
  166. # Use Focal Loss for imbalanced datasets
  167. class_weights = None
  168. if self.class_weights:
  169. class_weights = torch.tensor(self.class_weights, dtype=torch.float32)
  170. criterion = FocalLoss(
  171. alpha=self.config.custom_params.get('focal_alpha', 1.0),
  172. gamma=self.config.custom_params.get('focal_gamma', 2.0),
  173. class_weights=class_weights
  174. )
  175. self.logger.info("Using Focal Loss for training")
  176. else:
  177. # Use standard Cross Entropy Loss
  178. class_weights = None
  179. if self.class_weights:
  180. class_weights = torch.tensor(self.class_weights, dtype=torch.float32)
  181. criterion = nn.CrossEntropyLoss(weight=class_weights)
  182. self.logger.info("Using Cross Entropy Loss for training")
  183. return criterion
  184. def train(self) -> Any:
  185. """
  186. Train the wakeword detection model with comprehensive tracking.
  187. Returns:
  188. Training metrics and results
  189. """
  190. self.logger.info("Starting wakeword detection training...")
  191. # Setup training components
  192. self.setup_training()
  193. # Initialize model validator
  194. self.validator = ModelValidator(self.model, self.config.device, self.logger)
  195. # Create metadata
  196. metadata = self.metadata_manager.create_metadata(
  197. ModelType.WAKEWORD, self.config.model_name, "Trixy ML Trainer"
  198. )
  199. metadata.description = f"RepCNN wakeword detection model ({self.model_type})"
  200. metadata.update_from_training_config(self.config)
  201. metadata.update_from_model(self.model)
  202. metadata.update_from_dataset(self.train_loader, self.val_loader, self.test_loader)
  203. metadata.update_wakeword_info(self.wakeword_classes, {
  204. 'detection_threshold': self.detection_threshold
  205. })
  206. # Run parent training loop
  207. training_metrics = super().train()
  208. # Update metadata with training results
  209. metadata.update_from_training_results(training_metrics)
  210. # Validate model
  211. validation_config = ValidationConfig(
  212. test_augmentations=True,
  213. robustness_tests=True,
  214. performance_profiling=True
  215. )
  216. self.logger.info("Running comprehensive model validation...")
  217. validation_results = self.validator.validate(self.val_loader, self.criterion, validation_config)
  218. metadata.add_test_results(validation_results)
  219. # Test model
  220. test_results = self.validator.test(self.test_loader, self.criterion, validation_config)
  221. # Save model with metadata
  222. self._save_trained_model(metadata, test_results)
  223. # Generate training report
  224. self._generate_training_report(metadata, training_metrics, test_results)
  225. self.logger.info("Wakeword training completed successfully!")
  226. return {
  227. 'training_metrics': training_metrics,
  228. 'validation_results': validation_results,
  229. 'test_results': test_results,
  230. 'metadata': metadata
  231. }
  232. def _save_trained_model(self, metadata: ModelMetadata, test_results: Dict[str, Any]):
  233. """Save the trained model with comprehensive metadata."""
  234. model_dir = Path(self.config.output_dir) / self.config.model_name
  235. model_dir.mkdir(parents=True, exist_ok=True)
  236. # Prepare model for saving
  237. self.model.eval()
  238. # Reparameterize RepCNN if applicable
  239. if hasattr(self.model, 'reparameterize'):
  240. self.logger.info("Reparameterizing model for inference...")
  241. self.model.reparameterize()
  242. # Update metadata with final model info
  243. metadata.set_file_info(str(model_dir / f"{self.config.model_name}.pth"))
  244. metadata.add_test_results(test_results)
  245. # Save model in requested format
  246. model_file = model_dir / f"{self.config.model_name}{self.config.model_format.value}"
  247. success = self.format_manager.save_model(
  248. self.model,
  249. str(model_file),
  250. metadata.to_dict(),
  251. password=self.config.password if self.config.use_password_protection else None
  252. )
  253. if success:
  254. self.logger.info(f"Model saved successfully: {model_file}")
  255. else:
  256. self.logger.error(f"Failed to save model: {model_file}")
  257. # Save standalone metadata file
  258. metadata_file = model_dir / "metadata.json"
  259. metadata.save_to_file(str(metadata_file))
  260. # Save training configuration
  261. config_file = model_dir / "training_config.json"
  262. with open(config_file, 'w') as f:
  263. import json
  264. json.dump(self.config.to_dict(), f, indent=2)
  265. # Save validation results
  266. if hasattr(self, 'validator') and self.validator:
  267. self.validator.save_results(str(model_dir / "validation"))
  268. def _generate_training_report(self, metadata: ModelMetadata,
  269. training_metrics: Any, test_results: Dict[str, Any]):
  270. """Generate comprehensive training report."""
  271. report_dir = Path(self.config.output_dir) / self.config.model_name / "reports"
  272. report_dir.mkdir(parents=True, exist_ok=True)
  273. # Generate metadata report
  274. report = self.metadata_manager.create_training_report(metadata)
  275. # Add wakeword-specific information
  276. report += "\n## Wakeword Detection Specific Results\n\n"
  277. # Add detection metrics
  278. if 'standard_test' in test_results:
  279. std_test = test_results['standard_test']
  280. if 'metrics' in std_test:
  281. metrics = std_test['metrics']
  282. report += "### Classification Performance\n"
  283. for class_id, class_name in self.wakeword_classes.items():
  284. if f'f1_{class_name}' in metrics:
  285. precision = metrics.get(f'precision_{class_name}', 0)
  286. recall = metrics.get(f'recall_{class_name}', 0)
  287. f1 = metrics.get(f'f1_{class_name}', 0)
  288. report += f"- **{class_name.title()}**:\n"
  289. report += f" - Precision: {precision:.4f}\n"
  290. report += f" - Recall: {recall:.4f}\n"
  291. report += f" - F1-Score: {f1:.4f}\n"
  292. # Add confusion matrix info
  293. if 'confusion_matrix' in metrics:
  294. report += "\n### Confusion Matrix\n"
  295. cm = metrics['confusion_matrix']
  296. report += "```\n"
  297. class_names = list(self.wakeword_classes.values())
  298. # Header
  299. report += "Actual\\Predicted "
  300. for name in class_names:
  301. report += f"{name[:8]:>8} "
  302. report += "\n"
  303. # Matrix rows
  304. for i, row in enumerate(cm):
  305. if i < len(class_names):
  306. report += f"{class_names[i][:12]:>12} "
  307. for val in row:
  308. report += f"{val:>8} "
  309. report += "\n"
  310. report += "```\n\n"
  311. # Add robustness test results
  312. if 'robustness_tests' in test_results:
  313. report += "### Robustness Tests\n"
  314. robustness = test_results['robustness_tests']
  315. if 'input_corruptions' in robustness:
  316. report += "#### Input Corruption Robustness\n"
  317. for corruption, results in robustness['input_corruptions'].items():
  318. accuracy = results.get('accuracy', 0)
  319. report += f"- {corruption}: {accuracy:.4f} accuracy\n"
  320. report += "\n"
  321. # Add performance profiling
  322. if 'performance_profile' in test_results:
  323. profile = test_results['performance_profile']
  324. report += "### Performance Profile\n"
  325. if 'inference_performance' in profile:
  326. perf = profile['inference_performance']
  327. report += f"- Inference time: {perf['mean_inference_time']*1000:.2f} ms\n"
  328. report += f"- Throughput: {perf['throughput_samples_per_second']:.1f} samples/sec\n"
  329. if 'model_size' in profile:
  330. size = profile['model_size']
  331. report += f"- Model size: {size['total_size_mb']:.2f} MB\n"
  332. report += "\n"
  333. # Add deployment recommendations
  334. report += "## Deployment Recommendations\n\n"
  335. report += "### Detection Threshold\n"
  336. report += f"- Recommended threshold: {self.detection_threshold}\n"
  337. report += "- Monitor false positive/negative rates in production\n"
  338. report += "- Consider threshold adjustment based on use case requirements\n\n"
  339. report += "### Hardware Requirements\n"
  340. if 'performance_profile' in test_results:
  341. profile = test_results['performance_profile']
  342. inference_time = profile.get('inference_performance', {}).get('mean_inference_time', 0) * 1000
  343. if inference_time < 50:
  344. report += "- Suitable for real-time processing on most devices\n"
  345. elif inference_time < 100:
  346. report += "- Suitable for real-time processing on modern devices\n"
  347. else:
  348. report += "- May require optimization for real-time processing\n"
  349. report += "\n### Model Optimization\n"
  350. if self.model_type == 'lightweight':
  351. report += "- Already optimized for edge deployment\n"
  352. else:
  353. report += "- Consider using lightweight variant for edge deployment\n"
  354. if hasattr(self.model, 'reparameterize'):
  355. report += "- Model has been reparameterized for efficient inference\n"
  356. # Save report
  357. report_file = report_dir / "training_report.md"
  358. with open(report_file, 'w', encoding='utf-8') as f:
  359. f.write(report)
  360. self.logger.info(f"Training report saved: {report_file}")
  361. def evaluate_detection_performance(self, test_loader: DataLoader,
  362. thresholds: List[float] = None) -> Dict[str, Any]:
  363. """
  364. Evaluate wakeword detection performance at different thresholds.
  365. Args:
  366. test_loader: Test data loader
  367. thresholds: List of detection thresholds to evaluate
  368. Returns:
  369. Detection performance results
  370. """
  371. if thresholds is None:
  372. thresholds = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
  373. self.logger.info("Evaluating detection performance...")
  374. self.model.eval()
  375. all_predictions = []
  376. all_probabilities = []
  377. all_targets = []
  378. with torch.no_grad():
  379. for data, targets in test_loader:
  380. data = data.to(self.config.device)
  381. targets = targets.to(self.config.device)
  382. outputs = self.model(data)
  383. probabilities = F.softmax(outputs, dim=1)
  384. all_probabilities.append(probabilities.cpu().numpy())
  385. all_targets.append(targets.cpu().numpy())
  386. all_probabilities = np.concatenate(all_probabilities)
  387. all_targets = np.concatenate(all_targets)
  388. # Evaluate at different thresholds
  389. results = {}
  390. for threshold in thresholds:
  391. # Get predictions based on threshold
  392. max_probs = np.max(all_probabilities, axis=1)
  393. predictions = np.argmax(all_probabilities, axis=1)
  394. # Apply threshold (predict negative if below threshold)
  395. negative_class_id = max([id for id, name in self.wakeword_classes.items()
  396. if name in ['negative', 'background']], default=2)
  397. low_confidence_mask = max_probs < threshold
  398. predictions[low_confidence_mask] = negative_class_id
  399. # Calculate metrics
  400. accuracy = np.mean(predictions == all_targets)
  401. # Calculate per-class metrics
  402. class_metrics = {}
  403. for class_id, class_name in self.wakeword_classes.items():
  404. mask = all_targets == class_id
  405. if np.sum(mask) > 0:
  406. class_acc = np.mean(predictions[mask] == all_targets[mask])
  407. class_metrics[class_name] = {
  408. 'accuracy': class_acc,
  409. 'samples': np.sum(mask)
  410. }
  411. results[f"threshold_{threshold}"] = {
  412. 'threshold': threshold,
  413. 'overall_accuracy': accuracy,
  414. 'class_metrics': class_metrics
  415. }
  416. self.logger.info("Detection performance evaluation completed")
  417. return results
  418. def create_wakeword_trainer_config(model_name: str = "wakeword_model",
  419. model_type: str = "standard",
  420. data_dir: str = "./trainer/data/wakeword",
  421. **kwargs) -> TrainerConfig:
  422. """
  423. Create a TrainerConfig specifically configured for wakeword detection.
  424. Args:
  425. model_name: Name of the model
  426. model_type: Type of RepCNN model ('standard', 'improved', 'lightweight')
  427. data_dir: Directory containing wakeword data
  428. **kwargs: Additional configuration parameters
  429. Returns:
  430. TrainerConfig instance for wakeword detection
  431. """
  432. # Default wakeword configuration
  433. config_dict = {
  434. 'trainer_name': 'wakeword_trainer',
  435. 'model_name': model_name,
  436. 'data_dir': data_dir,
  437. 'output_dir': './models/wakeword',
  438. # Training parameters optimized for wakeword detection
  439. 'batch_size': 64,
  440. 'learning_rate': 0.001,
  441. 'num_epochs': 100,
  442. 'min_epochs': 20, # Allow at least 20 epochs for wakeword model to learn patterns
  443. 'early_stopping_patience': 15,
  444. # Audio parameters for wakeword detection
  445. 'sample_rate': 16000,
  446. 'audio_length': 1.5, # Typical wakeword length
  447. 'n_mels': 40,
  448. 'n_fft': 512,
  449. 'hop_length': 160,
  450. 'win_length': 400,
  451. # Augmentation for robustness
  452. 'use_augmentation': True,
  453. 'noise_factor': 0.1,
  454. 'speed_factor': 0.1,
  455. 'pitch_factor': 0.05,
  456. 'volume_factor': 0.2,
  457. # Model-specific parameters
  458. 'custom_params': {
  459. 'model_type': model_type,
  460. 'wakeword_classes': {0: "custom", 1: "system_command", 2: "negative"},
  461. 'detection_threshold': 0.5,
  462. 'use_focal_loss': True,
  463. 'focal_alpha': 1.0,
  464. 'focal_gamma': 2.0,
  465. 'width_multiplier': 1.0,
  466. 'use_se': False,
  467. 'dropout_rate': 0.2
  468. }
  469. }
  470. # Separate custom parameters from standard TrainerConfig parameters
  471. custom_param_keys = {
  472. 'use_focal_loss', 'detection_threshold', 'focal_alpha', 'focal_gamma',
  473. 'width_multiplier', 'use_se', 'dropout_rate', 'wakeword_classes',
  474. 'use_temporal_attention'
  475. }
  476. # Extract custom parameters from kwargs
  477. custom_params = config_dict['custom_params'].copy()
  478. for key, value in kwargs.items():
  479. if key in custom_param_keys:
  480. custom_params[key] = value
  481. else:
  482. config_dict[key] = value
  483. # Handle model_type specially
  484. if 'model_type' in kwargs:
  485. custom_params['model_type'] = kwargs['model_type']
  486. # Update custom_params
  487. config_dict['custom_params'] = custom_params
  488. return TrainerConfig(**config_dict)