4.5. Explicabilidad (src/explainability/ + explain_*.py)
La explicabilidad responde por qué el modelo predijo lo que predijo. Es post-hoc (se corre después del entrenamiento, sobre best.pth) y no está acoplada al loop. Combina dos técnicas complementarias: LIME (perturbación) y Grad-CAM (gradientes).
gradcam.py — Grad-CAM con hooks nativos
Propósito
Producir un mapa de calor que muestre qué regiones espaciales de la imagen activaron más la clase predicha, usando los gradientes de la última capa convolucional. Sin dependencias externas (solo PyTorch + matplotlib para el colormap).
GRADCAM_TARGET_LAYERS — el mapa modelo→capa
GRADCAM_TARGET_LAYERS = {
"efficientnet_b0": "features.-1",
"efficientnet_b4": "conv_head",
"efficientnet_lite0": "conv_head",
"mobilenet_v3_large": "blocks.-1",
"shufflenet_v2_x1_0": "conv5",
"ghostnetv2_100": "blocks.-1",
"fastvit_t8": "final_conv",
...
}Cada arquitectura tiene su última capa con salida espacial (antes del pooling global). Grad-CAM necesita esa capa porque después del pooling se pierde la información de "dónde". get_target_layer resuelve el dot-path, soportando índices negativos (features.-1 = último bloque de features).
Al añadir un modelo, añade su capa aquí
Si registras un modelo en MODEL_REGISTRY pero olvidas su entrada en GRADCAM_TARGET_LAYERS, get_target_layer lanza KeyError, se captura y Grad-CAM se omite con warning — el reporte cae a 3 paneles. No es un crash, pero pierdes el panel. Ver modelos: checklist.
Clase GradCAM — el detalle técnico más fino del proyecto
class GradCAM:
def __init__(self, model, target_layer):
self._fwd_handle = target_layer.register_forward_hook(self._save_activations)
def _save_activations(self, module, inp, out):
self._activations = out
out.register_hook(self._save_gradients) # ⭐ hook sobre el TENSOR, no el módulo
def __call__(self, input_tensor, class_idx):
self.model.zero_grad(set_to_none=True)
input_tensor = input_tensor.clone().requires_grad_(True)
logits = self.model(input_tensor)
logits[0, class_idx].backward() # gradiente de la clase predicha
weights = self._gradients.mean(dim=(2, 3), keepdim=True) # α_k: importancia de cada canal
cam = F.relu((weights * activations).sum(dim=1)).squeeze(0) # combinación + ReLU
# normaliza a [0,1]El bloque más importante y por qué se hace así:
- Se captura el gradiente vía
out.register_hook(hook sobre el tensor de salida), no víaregister_full_backward_hook(hook a nivel de módulo). Razón documentada: varias arquitecturas (p.ej.ghostnetv2_100) aplican unaReLUinplace inmediatamente después de la capa objetivo. El hook de módulo intercepta el tensor antes de esa modificación inplace y rompe con el error "view is being modified inplace". Enganchar el gradiente directo del tensor de salida evita ese conflicto y funciona en todas las arquitecturas. Es una decisión de robustez ganada con experiencia. weights = gradients.mean(dim=(2,3)): promedia el gradiente sobre el espacio → un peso por canal (los coeficientesde la fórmula de Grad-CAM). F.relu((weights*activations).sum(dim=1)): combina canales ponderados y aplica ReLU (solo importan las contribuciones positivas a la clase).
Requisito de uso: requiere gradientes habilitados — no debe invocarse dentro de un torch.no_grad(). El assert del código lo verifica y da un mensaje claro si se viola.
Diseño como context manager (with GradCAM(...) as cam:): garantiza que el forward hook se remueva al salir (close()), evitando fugas de hooks que ralentizarían forwards posteriores.
build_gradcam_overlay(...)
Hace upsample bilineal del heatmap (que está a la resolución de la capa, ~7×7) al tamaño de la imagen y lo mezcla (40 % imagen / 60 % calor) con el colormap jet. Se usa jet a propósito distinto del RdYlGn del panel LIME, para que visualmente no se confundan las dos técnicas.
visual_report.py — el reporte visual de 3-4 paneles
Propósito
Ensamblar, por imagen, un PNG con: (1) original, (2) regiones positivas LIME, (3) heatmap de importancia LIME, y opcionalmente (4) Grad-CAM. Es lo que un agrónomo o un jurado realmente mira.
build_predict_fn(model, device, target_size)
LIME necesita una función que reciba imágenes y devuelva probabilidades. Este wrapper aplica las mismas transforms de validación (resize+normalize) y devuelve softmax:
@torch.no_grad()
def predict_fn(images): # images: batch HWC uint8
batch = torch.stack([validation_transform(Image.fromarray(img)) for img in images]).to(device)
return model(batch).softmax(dim=1).cpu().numpy()Clave: usa exactamente las transforms de validación, así LIME "ve" la imagen como la ve el modelo en evaluación (sin skew).
render_visual_explanation(...) — el orquestador por imagen
- Redimensiona la imagen a
target_size. - Corre
LimeImageExplainer.explain_instanceconnum_samplesperturbaciones. - Toma la clase predicha (
top_labels[0]) y su probabilidad. - Panel de regiones (
_build_positive_region_panel): resalta en verde los superpíxeles positivos, atenúa el resto, dibuja bordes gruesos. - Panel de heatmap (
_build_importance_heatmap): colorea cada superpíxel con su peso LIME (RdYlGn, rojo=negativo, verde=positivo). - Panel Grad-CAM (si
model_namedado y registrado): calcula y superpone el mapa. - Persiste artefactos (
_save_explanation_artifacts): junto al PNG guarda un.json(predicción + pesos por superpíxel) y un.npy(mapa de segmentos).
Por qué persistir .json + .npy: permite recalcular el heatmap, cruzarlo con otras máscaras, o auditar estabilidad sin re-ejecutar LIME (que es caro: num_samples forwards por imagen). Lo aprovecha lime_stability.py.
explanation_dispersion(local_exp)
weights = np.array([w for _, w in local_exp])
return float(np.std(weights / weights.max())) if ... else 0.0Desviación estándar de los pesos LIME normalizados. Interpretación: dispersión baja = explicación concentrada en pocos superpíxeles dominantes (buena, interpretable); dispersión alta = pesos repartidos parejo (mala, típica de las clases con fondo ruidoso de campo). Es una métrica de calidad de la explicación, no del modelo.
explain_lime.py — entrypoint del reporte visual
make explain-lime. Dos modos:
- Muestreo balanceado (default):
lime.images_per_class(=2) imágenes por clase del test →<run_dir>/lime_visual/. --image <ruta>: explica una imagen puntual (útil para depurar un caso concreto).
Carga el checkpoint con resolve_run_dir + load_run_metadata (mapeo y tamaño del run). Import diferido de visual_report con mensaje claro si falta el extra xai.
explain_report.py — fidelidad agregada y análisis de errores
make explain-report / make explain-errors. Requiere predictions.csv. Dos modos:
- Muestreo amplio (
report_sample_size=30/clase): explica muchas más imágenes y agrega confianza y dispersión por clase, separando aciertos de errores →explain_report/summary.{csv,json}. --errors-only: explica todas las filas dondelabel != pred_label→lime_errors/. Sirve para responder "¿en qué se está fijando el modelo cuando se equivoca?".
if args.errors_only:
df_subset = predictions_df[predictions_df["label"] != predictions_df["pred_label"]]
else:
df_subset = sample_balanced(predictions_df, sample_size, seed)El resumen agrupa por (label, correct):
report_df.groupby(["label", "correct"]).agg(
n=("dispersion", "size"),
mean_pred_prob=("pred_prob", "mean"),
mean_dispersion=("dispersion", "mean"),
)Así puedes ver, por clase: cuántos aciertos/errores, con qué confianza media y con qué dispersión de explicación media. Un patrón revelador es "alta confianza en errores" (el modelo se equivoca seguro) o "alta dispersión en una clase" (el modelo no tiene una región clara en la que fijarse).
Por qué DOS técnicas (LIME + Grad-CAM)
| LIME | Grad-CAM | |
|---|---|---|
| Enfoque | Perturba superpíxeles y ajusta modelo lineal local | Gradientes de la clase respecto a la última conv |
| Ventaja | Model-agnostic; explica en el espacio de la imagen | Rápido; usa la estructura interna real de la red |
| Desventaja | Costoso (miles de forwards); algo inestable | Solo CNNs con capa espacial; resolución gruesa |
| Colormap | RdYlGn | jet |
Complementariedad: si LIME y Grad-CAM coinciden en la misma región de la lesión, hay evidencia fuerte de que el modelo mira lo correcto. Si divergen, la explicación es dudosa. Ofrecer ambas es una postura defensiva de XAI frente a un jurado. Ver fundamentos: explicabilidad.
Siguiente
Falta el último grupo de código: los Scripts de soporte (inferencia, checks, dataset, Modal, cleanup).