""" Utilidades para análisis de activaciones de CNN con Streamlit. Adaptado del módulo image_analyzer.py original. """ import streamlit as st import torch import torch.nn as nn import numpy as np from typing import Dict, List, Tuple, Optional, Union from PIL import Image import matplotlib.pyplot as plt import io class ActivationHook: """ Hook para capturar activaciones de capas específicas. Versión simplificada sin logging para uso en Streamlit. """ def __init__(self, model: nn.Module, target_layers: List[str]): """ Inicializa el hook. Args: model: Modelo PyTorch target_layers: Lista de nombres de capas a capturar """ self.model = model self.target_layers = target_layers self.activations = {} self.hooks = [] def _make_hook(self, name: str): """Crea una función hook para una capa específica.""" def hook(module, input, output): self.activations[name] = output.detach() return hook def register_hooks(self): """Registra los hooks en las capas objetivo.""" for name, module in self.model.named_modules(): if name in self.target_layers: hook = module.register_forward_hook(self._make_hook(name)) self.hooks.append(hook) def get_activations(self) -> Dict[str, torch.Tensor]: """Retorna las activaciones capturadas.""" return self.activations def clear_activations(self): """Limpia las activaciones almacenadas.""" self.activations = {} def remove_hooks(self): """Remueve todos los hooks.""" for hook in self.hooks: hook.remove() self.hooks = [] class StreamlitImageAnalyzer: """ Analizador de activaciones optimizado para Streamlit. """ def __init__( self, model: nn.Module, target_layer: str, device: torch.device = None ): """ Inicializa el analizador. Args: model: Modelo de PyTorch (ResNet-18 o AlexNet) target_layer: Nombre de la capa a analizar device: Device para cómputo (CPU/GPU) """ self.model = model.to(device) self.model.eval() self.target_layer = target_layer self.device = device if device else torch.device('cpu') # Registrar hook self.hook = ActivationHook(self.model, [target_layer]) self.hook.register_hooks() # Verificar captura con imagen dummy dummy = torch.randn(1, 3, 224, 224).to(self.device) with torch.no_grad(): _ = self.model(dummy) captured = self.hook.get_activations() if captured: self.actual_layer_name = list(captured.keys())[0] else: raise ValueError(f"No se pudo capturar la capa '{target_layer}'") self.hook.clear_activations() # ImageNet normalization self.mean = torch.tensor([0.485, 0.456, 0.406]).view( 3, 1, 1).to(self.device) self.std = torch.tensor([0.229, 0.224, 0.225]).view( 3, 1, 1).to(self.device) def load_image_from_pil( self, pil_image: Image.Image, size: Tuple[int, int] = (224, 224) ) -> Tuple[torch.Tensor, np.ndarray]: """ Carga una imagen desde objeto PIL. Args: pil_image: Imagen PIL size: Tamaño al que redimensionar (H, W) Returns: Tuple con: - Tensor normalizado [1, 3, H, W] para el modelo - Array numpy [H, W, 3] para visualización """ # Convertir y redimensionar img_pil = pil_image.convert('RGB') img_pil = img_pil.resize(size, Image.BILINEAR) # Para visualización img_vis = np.array(img_pil).astype(np.float32) / 255.0 # Para el modelo (normalizado) img_array = np.array(img_pil).astype(np.float32) / 255.0 img_tensor = torch.from_numpy(img_array.transpose(2, 0, 1)).float() img_tensor = img_tensor.unsqueeze(0).to(self.device) # Normalizar con ImageNet stats img_normalized = (img_tensor - self.mean) / self.std return img_normalized, img_vis def analyze_image( self, image: torch.Tensor ) -> Dict[str, torch.Tensor]: """ Analiza una imagen y extrae activaciones. Args: image: Tensor de imagen [1, 3, H, W] normalizado Returns: Dict con activaciones, predicción y confianza """ with torch.no_grad(): # Forward pass output = self.model(image) # Predicción probs = torch.softmax(output, dim=1) confidence, prediction = torch.max(probs, dim=1) # Obtener activaciones activations = self.hook.get_activations() layer_acts = activations[self.actual_layer_name] self.hook.clear_activations() return { 'activations': layer_acts, 'prediction': prediction.item(), 'confidence': confidence.item() } def get_neuron_statistics( self, activations: torch.Tensor ) -> List[Dict]: """ Calcula estadísticas de activación para cada neurona. Args: activations: Tensor [1, C, H, W] de activaciones Returns: Lista de diccionarios con estadísticas por neurona """ num_neurons = activations.shape[1] stats = [] for neuron_idx in range(num_neurons): neuron_act = activations[0, neuron_idx, :, :] stats.append({ 'neuron_idx': neuron_idx, 'mean': neuron_act.mean().item(), 'max': neuron_act.max().item(), 'min': neuron_act.min().item(), 'std': neuron_act.std().item(), 'sparsity': (neuron_act == 0).float().mean().item() }) return stats def get_top_neurons( self, stats: List[Dict], top_k: int = 10, criterion: str = 'mean', activation_weight: float = 0.7, min_sparsity: float = 0.0 # Nuevo parámetro ) -> List[int]: """ Obtiene los índices de las top-k neuronas más activas y selectivas. Args: stats: Lista de estadísticas por neurona top_k: Número de neuronas a retornar criterion: Criterio de selección activation_weight: Peso de activación en score balanceado (0-1) min_sparsity: Sparsity mínima requerida (0-1) para filtrar neuronas Returns: Lista de índices de neuronas """ if criterion == 'balanced': selectivity_weight = 1.0 - activation_weight # Filtrar neuronas con sparsity muy baja (poco selectivas) filtered_stats = [ s for s in stats if s['sparsity'] >= min_sparsity] # Si filtramos demasiado, relajar el criterio # if len(filtered_stats) < top_k: # filtered_stats = stats # Si filtramos demasiado, usar lo que hay (no relajar) if len(filtered_stats) < top_k and len(filtered_stats) > 0: # Usar las que hay, aunque sean menos de top_k pass # No hacer nada, usar filtered_stats como está # Solo si NO hay ninguna, usar todas (caso extremo) if len(filtered_stats) == 0: filtered_stats = stats # Score combinado for s in filtered_stats: # Normalizar mean a [0, 1] max_mean = max([x['mean'] for x in filtered_stats]) min_mean = min([x['mean'] for x in filtered_stats]) if max_mean > min_mean: norm_activation = (s['mean'] - min_mean) / \ (max_mean - min_mean) else: norm_activation = 0.5 # Selectividad (sparsity alta es bueno) selectivity = s['sparsity'] # Score combinado con pesos ajustables s['balanced_score'] = (activation_weight * norm_activation) + \ (selectivity_weight * selectivity) sorted_stats = sorted( filtered_stats, key=lambda x: x['balanced_score'], reverse=True) else: # Criterio simple (original) sorted_stats = sorted( stats, key=lambda x: x[criterion], reverse=True) return [s['neuron_idx'] for s in sorted_stats[:top_k]] def cleanup(self): """Limpia los hooks.""" self.hook.remove_hooks() def create_activation_heatmap( image_vis: np.ndarray, activation_map: np.ndarray, title: str = "", alpha: float = 0.5, cmap: str = 'jet', figsize: Tuple[int, int] = (5, 5) ) -> plt.Figure: """ Crea un mapa de calor superpuesto sobre la imagen original. Args: image_vis: Imagen original [H, W, 3] activation_map: Mapa de activación [H, W] title: Título del plot alpha: Transparencia del heatmap cmap: Colormap a usar Returns: Figura de matplotlib """ fig, ax = plt.subplots(figsize=(4, 4)) # Mostrar imagen original ax.imshow(image_vis) # Redimensionar mapa de activación al tamaño de la imagen from scipy.ndimage import zoom h, w = image_vis.shape[:2] h_act, w_act = activation_map.shape if (h_act, w_act) != (h, w): zoom_factors = (h / h_act, w / w_act) activation_resized = zoom(activation_map, zoom_factors, order=1) else: activation_resized = activation_map # Normalizar activaciones a [0, 1] act_min = activation_resized.min() act_max = activation_resized.max() if act_max > act_min: activation_norm = (activation_resized - act_min) / (act_max - act_min) else: activation_norm = activation_resized # Superponer heatmap im = ax.imshow(activation_norm, cmap=cmap, alpha=alpha) ax.set_title(title, fontsize=14, fontweight='bold') ax.axis('off') # Colorbar plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) plt.tight_layout() return fig def create_filter_grid( activations: torch.Tensor, neuron_indices: List[int], image_vis: np.ndarray, max_cols: int = 6, cmap: str = 'viridis' ) -> plt.Figure: """ Crea una grilla de mapas de activación numerados. Args: activations: Tensor [1, C, H, W] de activaciones neuron_indices: Lista de índices de neuronas a visualizar image_vis: Imagen original [H, W, 3] para referencia max_cols: Número máximo de columnas cmap: Colormap a usar Returns: Figura de matplotlib """ num_neurons = len(neuron_indices) num_cols = min(max_cols, num_neurons) num_rows = (num_neurons + num_cols - 1) // num_cols fig, axes = plt.subplots( num_rows, num_cols, figsize=(3*num_cols, 3*num_rows)) # Asegurar que axes sea siempre un array 2D if num_rows == 1 and num_cols == 1: axes = np.array([[axes]]) elif num_rows == 1: axes = axes.reshape(1, -1) elif num_cols == 1: axes = axes.reshape(-1, 1) for idx, neuron_idx in enumerate(neuron_indices): row = idx // num_cols col = idx % num_cols ax = axes[row, col] # Obtener mapa de activación act_map = activations[0, neuron_idx, :, :].cpu().numpy() # Mostrar im = ax.imshow(act_map, cmap=cmap) ax.set_title(f'Filtro {neuron_idx}', fontsize=10, fontweight='bold') ax.axis('off') # Añadir número grande en la esquina ax.text(0.05, 0.95, str(idx + 1), transform=ax.transAxes, fontsize=16, fontweight='bold', va='top', ha='left', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) # Ocultar ejes vacíos for idx in range(num_neurons, num_rows * num_cols): row = idx // num_cols col = idx % num_cols axes[row, col].axis('off') plt.tight_layout() return fig def get_available_conv_layers(model: nn.Module, model_name: str) -> List[str]: """ Obtiene lista de capas convolucionales disponibles en el modelo. Args: model: Modelo PyTorch model_name: Nombre del modelo ('resnet18' o 'alexnet') Returns: Lista de nombres de capas convolucionales """ conv_layers = [] for name, module in model.named_modules(): if isinstance(module, nn.Conv2d): conv_layers.append(name) return conv_layers def fig_to_image(fig: plt.Figure) -> Image.Image: """ Convierte una figura de matplotlib a imagen PIL. Args: fig: Figura de matplotlib Returns: Imagen PIL """ buf = io.BytesIO() fig.savefig(buf, format='png', bbox_inches='tight', dpi=100) buf.seek(0) img = Image.open(buf) plt.close(fig) return img def run_ablation_experiment( model: nn.Module, image: torch.Tensor, target_layer: str, neuron_idx: Union[int, List[int]], # ← Ahora acepta lista experiment_type: str = 'knockout', amplification_factor: float = 5.0, noise_level: float = 0.5, device: torch.device = None ) -> Dict: """ Realiza experimento de ablación modificando activaciones de una o más neuronas. Args: model: Modelo PyTorch image: Tensor de imagen [1, 3, H, W] target_layer: Nombre de la capa neuron_idx: Índice o lista de índices de neuronas a modificar experiment_type: 'knockout', 'isolation', 'amplify', 'add_noise', 'group_knockout', 'group_isolation', 'group_amplify', 'group_noise' amplification_factor: Factor de amplificación (solo para 'amplify' y 'group_amplify') noise_level: Nivel de ruido gaussiano (solo para 'add_noise' y 'group_noise') device: Device de cómputo Returns: Dict con predicción y confianza modificadas """ if device is None: device = torch.device('cpu') model.eval() # Convertir a lista si es un solo índice if isinstance(neuron_idx, int): neuron_indices = [neuron_idx] else: neuron_indices = neuron_idx # Hook para modificar activaciones modified_activations = {} def modification_hook(module, input, output): # Clonar para no modificar el original modified = output.clone() if experiment_type == 'knockout': # Apagar la neurona específica (individual) for idx in neuron_indices: modified[0, idx, :, :] = 0 elif experiment_type == 'isolation': # Apagar TODAS excepto la neurona específica (individual) modified[0, :, :, :] = 0 for idx in neuron_indices: modified[0, idx, :, :] = output[0, idx, :, :] elif experiment_type == 'amplify': # Amplificar la neurona específica (individual) for idx in neuron_indices: modified[0, idx, :, :] *= amplification_factor elif experiment_type == 'group_amplify': # Amplificar el GRUPO de neuronas for idx in neuron_indices: modified[0, idx, :, :] *= amplification_factor elif experiment_type == 'group_knockout': # Apagar el GRUPO de neuronas for idx in neuron_indices: modified[0, idx, :, :] = 0 elif experiment_type == 'group_isolation': # Apagar TODAS excepto el GRUPO de neuronas modified[0, :, :, :] = 0 for idx in neuron_indices: modified[0, idx, :, :] = output[0, idx, :, :] elif experiment_type == 'add_noise': # Agregar ruido gaussiano a las neuronas específicas (individual) for idx in neuron_indices: # Obtener estadísticas de la activación original para escalar el ruido act_std = output[0, idx, :, :].std() # Generar ruido gaussiano escalado por noise_level noise = torch.randn_like( output[0, idx, :, :]) * act_std * noise_level modified[0, idx, :, :] = output[0, idx, :, :] + noise elif experiment_type == 'group_noise': # Agregar ruido gaussiano al GRUPO de neuronas for idx in neuron_indices: # Obtener estadísticas de la activación original para escalar el ruido act_std = output[0, idx, :, :].std() # Generar ruido gaussiano escalado por noise_level noise = torch.randn_like( output[0, idx, :, :]) * act_std * noise_level modified[0, idx, :, :] = output[0, idx, :, :] + noise modified_activations['output'] = modified return modified # Registrar hook hook_handle = None for name, module in model.named_modules(): if name == target_layer: hook_handle = module.register_forward_hook(modification_hook) break if hook_handle is None: raise ValueError(f"No se encontró la capa: {target_layer}") # Forward pass con modificación with torch.no_grad(): output = model(image) probs = torch.softmax(output, dim=1) confidence, prediction = torch.max(probs, dim=1) # Limpiar hook hook_handle.remove() return { 'prediction': prediction.item(), 'confidence': confidence.item(), 'probabilities': probs[0].cpu().numpy() } def get_imagenet_class_name(class_idx: int) -> str: """ Obtiene el nombre de una clase de ImageNet por su índice. Args: class_idx: Índice de la clase (0-999) Returns: Nombre legible de la clase """ try: import json import urllib.request # URL del archivo de clases ImageNet url = "https://raw.githubusercontent.com/anishathalye/imagenet-simple-labels/master/imagenet-simple-labels.json" # Descargar y cachear with urllib.request.urlopen(url) as response: class_names = json.loads(response.read().decode()) if 0 <= class_idx < len(class_names): return class_names[class_idx] else: return f"Clase #{class_idx}" except Exception as e: return f"Clase #{class_idx}"