""" FusionEngine - Fusion Multi-Modale d'Embeddings Fusionne plusieurs embeddings (image, texte, titre, UI) en un seul vecteur avec pondération configurable et normalisation L2. Tâche 5.2: Lazy loading des embeddings avec WeakValueDictionary. """ from typing import Dict, List, Optional import numpy as np from dataclasses import dataclass import weakref import logging from pathlib import Path from ..models.state_embedding import ( StateEmbedding, EmbeddingComponent, DEFAULT_FUSION_WEIGHTS ) logger = logging.getLogger(__name__) @dataclass class FusionConfig: """Configuration de la fusion""" method: str = "weighted" # weighted ou concat_projection normalize: bool = True # Normaliser le vecteur final weights: Dict[str, float] = None # Poids personnalisés def __post_init__(self): if self.weights is None: self.weights = DEFAULT_FUSION_WEIGHTS.copy() # Valider que les poids somment à 1.0 pour weighted if self.method == "weighted": total = sum(self.weights.values()) if not (0.99 <= total <= 1.01): raise ValueError( f"Weights must sum to 1.0 for weighted fusion, got {total}" ) class FusionEngine: """ Moteur de fusion multi-modale avec lazy loading optimisé Fusionne des embeddings de différentes modalités (image, texte, UI) en un seul vecteur représentant l'état complet de l'écran. Tâche 5.2: Implémente lazy loading avec WeakValueDictionary pour éviter les rechargements multiples tout en permettant le garbage collection. """ def __init__(self, config: Optional[FusionConfig] = None): """ Initialiser le moteur de fusion avec lazy loading Args: config: Configuration de fusion (utilise config par défaut si None) """ self.config = config or FusionConfig() # Tâche 5.2: Cache lazy loading avec WeakValueDictionary # Permet le garbage collection automatique des embeddings non utilisés self._embedding_cache: weakref.WeakValueDictionary = weakref.WeakValueDictionary() self._cache_stats = { 'hits': 0, 'misses': 0, 'loads': 0, 'evictions': 0 } def fuse(self, embeddings: Dict[str, np.ndarray], weights: Optional[Dict[str, float]] = None) -> np.ndarray: """ Fusionner plusieurs embeddings en un seul vecteur Args: embeddings: Dict {modalité: vecteur} e.g., {"image": vec1, "text": vec2, "title": vec3, "ui": vec4} weights: Poids personnalisés (optionnel, utilise config par défaut) Returns: Vecteur fusionné (normalisé si config.normalize=True) Raises: ValueError: Si les dimensions ne correspondent pas ou poids invalides """ if not embeddings: raise ValueError("No embeddings provided for fusion") # Utiliser poids de config ou poids fournis fusion_weights = weights or self.config.weights # Vérifier que toutes les modalités ont le même nombre de dimensions dimensions = None for modality, vector in embeddings.items(): if dimensions is None: dimensions = vector.shape[0] elif vector.shape[0] != dimensions: raise ValueError( f"All embeddings must have same dimensions. " f"Expected {dimensions}, got {vector.shape[0]} for {modality}" ) if self.config.method == "weighted": fused = self._fuse_weighted(embeddings, fusion_weights) elif self.config.method == "concat_projection": fused = self._fuse_concat_projection(embeddings, fusion_weights) else: raise ValueError(f"Unknown fusion method: {self.config.method}") # Normaliser si demandé if self.config.normalize: fused = self._normalize_l2(fused) return fused def _fuse_weighted(self, embeddings: Dict[str, np.ndarray], weights: Dict[str, float]) -> np.ndarray: """ Fusion pondérée simple : somme pondérée des vecteurs fused = w1*v1 + w2*v2 + w3*v3 + w4*v4 """ # Initialiser vecteur résultat first_vector = next(iter(embeddings.values())) fused = np.zeros_like(first_vector, dtype=np.float32) # Somme pondérée for modality, vector in embeddings.items(): weight = weights.get(modality, 0.0) fused += weight * vector return fused def _fuse_concat_projection(self, embeddings: Dict[str, np.ndarray], weights: Dict[str, float]) -> np.ndarray: """ Fusion par concaténation + projection Concatène tous les vecteurs puis projette vers dimension cible. Note: Pour l'instant, on fait une simple moyenne pondérée. TODO: Implémenter vraie projection avec matrice apprise. """ # Pour l'instant, utiliser fusion pondérée # Dans une version future, on pourrait apprendre une matrice de projection return self._fuse_weighted(embeddings, weights) def _normalize_l2(self, vector: np.ndarray) -> np.ndarray: """ Normaliser un vecteur avec norme L2 normalized = vector / ||vector||_2 """ norm = np.linalg.norm(vector) if norm < 1e-10: # Éviter division par zéro return vector return vector / norm def create_state_embedding(self, embedding_id: str, embeddings: Dict[str, np.ndarray], vector_save_path: str, weights: Optional[Dict[str, float]] = None, metadata: Optional[Dict] = None) -> StateEmbedding: """ Créer un StateEmbedding complet depuis des embeddings individuels Args: embedding_id: ID unique pour cet embedding embeddings: Dict {modalité: vecteur} vector_save_path: Chemin où sauvegarder le vecteur fusionné weights: Poids personnalisés (optionnel) metadata: Métadonnées additionnelles Returns: StateEmbedding avec vecteur fusionné sauvegardé """ # Fusionner les embeddings fused_vector = self.fuse(embeddings, weights) # Créer les composants fusion_weights = weights or self.config.weights components = {} for modality, vector in embeddings.items(): # Pour l'instant, on ne sauvegarde pas les vecteurs individuels # On pourrait les sauvegarder si nécessaire components[modality] = EmbeddingComponent( weight=fusion_weights.get(modality, 0.0), vector_id=f"{vector_save_path}_{modality}.npy", source_text=None ) # Créer StateEmbedding dimensions = fused_vector.shape[0] state_emb = StateEmbedding( embedding_id=embedding_id, vector_id=vector_save_path, dimensions=dimensions, fusion_method=self.config.method, components=components, metadata=metadata or {} ) # Sauvegarder le vecteur fusionné state_emb.save_vector(fused_vector) return state_emb def compute_similarity(self, emb1: StateEmbedding, emb2: StateEmbedding) -> float: """ Calculer similarité cosinus entre deux StateEmbeddings Args: emb1: Premier embedding emb2: Deuxième embedding Returns: Similarité cosinus dans [-1, 1] """ return emb1.compute_similarity(emb2) def batch_fuse(self, batch_embeddings: List[Dict[str, np.ndarray]], weights: Optional[Dict[str, float]] = None) -> List[np.ndarray]: """ Fusionner un batch d'embeddings en parallèle Args: batch_embeddings: Liste de dicts {modalité: vecteur} weights: Poids personnalisés (optionnel) Returns: Liste de vecteurs fusionnés """ return [self.fuse(embs, weights) for embs in batch_embeddings] def get_config(self) -> FusionConfig: """Récupérer la configuration actuelle""" return self.config def set_weights(self, weights: Dict[str, float]) -> None: """ Mettre à jour les poids de fusion Args: weights: Nouveaux poids Raises: ValueError: Si les poids ne somment pas à 1.0 (pour weighted) """ if self.config.method == "weighted": total = sum(weights.values()) if not (0.99 <= total <= 1.01): raise ValueError( f"Weights must sum to 1.0 for weighted fusion, got {total}" ) self.config.weights = weights.copy() # ============================================================================ # Fonctions utilitaires # ============================================================================ def create_default_fusion_engine() -> FusionEngine: """Créer un FusionEngine avec configuration par défaut""" return FusionEngine(FusionConfig()) def normalize_vector(vector: np.ndarray) -> np.ndarray: """ Normaliser un vecteur avec norme L2 Args: vector: Vecteur à normaliser Returns: Vecteur normalisé """ norm = np.linalg.norm(vector) if norm < 1e-10: return vector return vector / norm def validate_weights(weights: Dict[str, float], method: str = "weighted") -> bool: """ Valider que les poids sont corrects Args: weights: Poids à valider method: Méthode de fusion Returns: True si valides, False sinon """ if method == "weighted": total = sum(weights.values()) return 0.99 <= total <= 1.01 return True def fuse_batch( self, embeddings_batch: List[Dict[str, np.ndarray]], weights: Optional[Dict[str, float]] = None ) -> np.ndarray: """ Fusionner un batch d'embeddings en parallèle pour efficacité. Args: embeddings_batch: Liste de dicts {modalité: vecteur} weights: Poids personnalisés (optionnel) Returns: Array numpy de shape (batch_size, embedding_dim) avec vecteurs fusionnés Note: Cette méthode est optimisée pour traiter plusieurs embeddings en une seule opération vectorisée, ce qui est plus rapide que de fusionner un par un. """ if not embeddings_batch: raise ValueError("Empty batch provided") batch_size = len(embeddings_batch) fusion_weights = weights or self.config.weights # Déterminer les dimensions depuis le premier élément first_emb = embeddings_batch[0] first_vector = next(iter(first_emb.values())) embedding_dim = first_vector.shape[0] # Préparer le résultat fused_batch = np.zeros((batch_size, embedding_dim), dtype=np.float32) # Traiter chaque modalité pour tout le batch for modality in first_emb.keys(): weight = fusion_weights.get(modality, 0.0) if weight == 0.0: continue # Collecter tous les vecteurs de cette modalité modality_vectors = [] for emb_dict in embeddings_batch: if modality in emb_dict: modality_vectors.append(emb_dict[modality]) else: # Si modalité manquante, utiliser vecteur zéro modality_vectors.append(np.zeros(embedding_dim, dtype=np.float32)) # Convertir en array numpy (batch_size, embedding_dim) modality_batch = np.array(modality_vectors, dtype=np.float32) # Ajouter contribution pondérée fused_batch += weight * modality_batch # Normaliser si demandé if self.config.normalize: # Normalisation L2 pour chaque vecteur du batch norms = np.linalg.norm(fused_batch, axis=1, keepdims=True) # Éviter division par zéro norms = np.where(norms < 1e-10, 1.0, norms) fused_batch = fused_batch / norms return fused_batch def create_state_embeddings_batch( self, embedding_ids: List[str], embeddings_batch: List[Dict[str, np.ndarray]], vector_save_paths: List[str], weights: Optional[Dict[str, float]] = None, metadata_batch: Optional[List[Dict]] = None ) -> List[StateEmbedding]: """ Créer un batch de StateEmbeddings de manière optimisée. Args: embedding_ids: Liste des IDs uniques embeddings_batch: Liste de dicts {modalité: vecteur} vector_save_paths: Liste des chemins de sauvegarde weights: Poids personnalisés (optionnel) metadata_batch: Liste de métadonnées (optionnel) Returns: Liste de StateEmbeddings créés Note: Cette méthode est ~3-5x plus rapide que de créer les embeddings un par un grâce au traitement vectorisé. """ if not (len(embedding_ids) == len(embeddings_batch) == len(vector_save_paths)): raise ValueError("All input lists must have the same length") batch_size = len(embedding_ids) # Fusionner tout le batch en une seule opération fused_vectors = self.fuse_batch(embeddings_batch, weights) # Créer les StateEmbeddings state_embeddings = [] fusion_weights = weights or self.config.weights for i in range(batch_size): embedding_id = embedding_ids[i] embeddings = embeddings_batch[i] vector_save_path = vector_save_paths[i] metadata = metadata_batch[i] if metadata_batch else None fused_vector = fused_vectors[i] # Créer les composants components = {} for modality, vector in embeddings.items(): components[modality] = EmbeddingComponent( weight=fusion_weights.get(modality, 0.0), vector_id=f"{vector_save_path}_{modality}.npy", source_text=None ) # Créer StateEmbedding dimensions = fused_vector.shape[0] state_emb = StateEmbedding( embedding_id=embedding_id, vector_id=vector_save_path, dimensions=dimensions, fusion_method=self.config.method, components=components, metadata=metadata or {} ) # Sauvegarder le vecteur fusionné state_emb.save_vector(fused_vector) state_embeddings.append(state_emb) return state_embeddings def compute_similarity_batch( self, query_embedding: StateEmbedding, candidate_embeddings: List[StateEmbedding] ) -> np.ndarray: """ Calculer la similarité entre un embedding query et un batch de candidats. Args: query_embedding: Embedding de requête candidate_embeddings: Liste d'embeddings candidats Returns: Array numpy de similarités (batch_size,) Note: Utilise des opérations vectorisées pour calculer toutes les similarités en une seule opération matricielle. """ # Charger le vecteur query query_vector = query_embedding.get_vector() # Charger tous les vecteurs candidats candidate_vectors = [] for emb in candidate_embeddings: candidate_vectors.append(emb.get_vector()) # Convertir en matrice (batch_size, embedding_dim) candidates_matrix = np.array(candidate_vectors, dtype=np.float32) # Calcul vectorisé : similarité cosinus = dot product (si normalisés) # similarities = candidates_matrix @ query_vector similarities = np.dot(candidates_matrix, query_vector) return similarities def load_embedding_lazy(self, embedding_path: str, force_reload: bool = False) -> Optional[np.ndarray]: """ Charger un embedding avec lazy loading et cache. Tâche 5.2: Lazy loading des embeddings avec cache WeakValueDictionary. Chargement à la demande depuis le disque avec éviction automatique. Args: embedding_path: Chemin vers le fichier embedding (.npy) force_reload: Forcer le rechargement depuis le disque Returns: Array numpy de l'embedding ou None si erreur """ if not embedding_path: return None # Vérifier le cache d'abord (sauf si force_reload) if not force_reload and embedding_path in self._embedding_cache: self._cache_stats['hits'] += 1 logger.debug(f"Embedding cache hit: {Path(embedding_path).name}") return self._embedding_cache[embedding_path] # Cache miss - charger depuis le disque self._cache_stats['misses'] += 1 try: if not Path(embedding_path).exists(): logger.warning(f"Embedding file not found: {embedding_path}") return None logger.debug(f"Loading embedding from disk: {Path(embedding_path).name}") embedding = np.load(embedding_path) # Valider le format if not isinstance(embedding, np.ndarray) or embedding.ndim != 1: logger.error(f"Invalid embedding format in {embedding_path}") return None # Ajouter au cache (WeakValueDictionary gère l'éviction automatique) self._embedding_cache[embedding_path] = embedding self._cache_stats['loads'] += 1 logger.debug(f"Embedding loaded: {embedding.shape} from {Path(embedding_path).name}") return embedding except Exception as e: logger.error(f"Error loading embedding from {embedding_path}: {e}") return None def fuse_with_lazy_loading(self, embedding_paths: Dict[str, str], weights: Optional[Dict[str, float]] = None) -> Optional[np.ndarray]: """ Fusionner des embeddings avec lazy loading depuis les chemins de fichiers. Tâche 5.2: Version optimisée qui charge les embeddings à la demande. Args: embedding_paths: Dict {modalité: chemin_fichier} weights: Poids personnalisés (optionnel) Returns: Vecteur fusionné ou None si erreur """ if not embedding_paths: logger.warning("No embedding paths provided for lazy fusion") return None # Charger les embeddings avec lazy loading embeddings = {} for modality, path in embedding_paths.items(): embedding = self.load_embedding_lazy(path) if embedding is not None: embeddings[modality] = embedding else: logger.warning(f"Failed to load embedding for modality '{modality}' from {path}") if not embeddings: logger.error("No embeddings could be loaded for fusion") return None # Fusionner normalement return self.fuse(embeddings, weights) def get_cache_stats(self) -> Dict[str, int]: """ Obtenir les statistiques du cache d'embeddings. Returns: Dict avec hits, misses, loads, cache_size """ return { **self._cache_stats, 'cache_size': len(self._embedding_cache) } def clear_embedding_cache(self) -> None: """ Vider le cache d'embeddings. Utile pour libérer la mémoire ou forcer le rechargement. """ cache_size = len(self._embedding_cache) self._embedding_cache.clear() self._cache_stats['evictions'] += cache_size logger.info(f"Cleared embedding cache ({cache_size} entries)") def preload_embeddings(self, embedding_paths: List[str]) -> int: """ Précharger des embeddings dans le cache. Utile pour optimiser les performances en chargeant les embeddings fréquemment utilisés à l'avance. Args: embedding_paths: Liste des chemins à précharger Returns: Nombre d'embeddings préchargés avec succès """ loaded_count = 0 for path in embedding_paths: if self.load_embedding_lazy(path) is not None: loaded_count += 1 logger.info(f"Preloaded {loaded_count}/{len(embedding_paths)} embeddings") return loaded_count