CNN_activation_analyzer / utils_streamlit.py
Gaston
CNN project
7ecf7dc
Raw
History Blame Contribute Delete
19.3 kB
"""
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}"