XAI_Vision_Inspector / explainers /occlusion_sensitivity.py
Lee Henriques
HF clean deployment
8c58a75
Raw
History Blame Contribute Delete
5.14 kB
"""
Occlusion Sensitivity Analysis
Original: Zeiler & Fergus, 2014 (https://arxiv.org/abs/1311.2901)
A perturbation-based XAI method that systematically occludes (masks) patches
of the input image with a neutral value and measures how much the prediction
confidence drops — revealing which regions the model relies on.
Unlike gradient-based methods, this is model-agnostic (works on any classifier)
at the cost of being computationally expensive (O(HxW / stride²) forward passes).
"""
import numpy as np
import torch
import torch.nn.functional as F
from typing import Optional, Tuple
import warnings
class OcclusionSensitivity:
"""
Occlusion Sensitivity attribution map.
For each spatial position (i, j), replaces a (patch_size x patch_size) window
centered at that position with `occlusion_value`, runs the model forward,
and records the drop in the target class probability.
Attributes:
model: PyTorch model
patch_size: Size of the occlusion square patch (pixels)
stride: Step size for sliding the patch (smaller = higher res map)
occlusion_value: Value to fill the patch with (default 0 = black)
"""
def __init__(
self,
model: torch.nn.Module,
patch_size: int = 32,
stride: int = 8,
occlusion_value: float = 0.0,
):
self.model = model
self.patch_size = patch_size
self.stride = stride
self.occlusion_value = occlusion_value
self.model.eval()
def __call__(
self,
input_tensor: torch.Tensor,
class_idx: Optional[int] = None,
batch_size: int = 32,
) -> Tuple[np.ndarray, int]:
"""
Compute the occlusion sensitivity map.
Args:
input_tensor: (1, C, H, W) preprocessed image tensor
class_idx: Target class. If None, uses the argmax prediction.
batch_size: Number of occluded images to process at once.
Returns:
sensitivity_map: (H, W) numpy array in [0, 1]
class_idx: Resolved target class index
"""
_, C, H, W = input_tensor.shape
device = input_tensor.device
with torch.no_grad():
baseline_logits = self.model(input_tensor)
if class_idx is None:
class_idx = baseline_logits.argmax(dim=1).item()
baseline_prob = F.softmax(baseline_logits, dim=1)[0, class_idx].item()
# Accumulate sensitivity scores in a (H, W) map and count map
sensitivity_map = np.zeros((H, W), dtype=np.float32)
count_map = np.zeros((H, W), dtype=np.float32)
# Generate all (top, left) patch positions
positions = []
y = 0
while y < H:
x = 0
while x < W:
positions.append((y, x))
x += self.stride
y += self.stride
# Process positions in batches for efficiency
for batch_start in range(0, len(positions), batch_size):
batch_positions = positions[batch_start: batch_start + batch_size]
batch_tensors = []
for (top, left) in batch_positions:
occluded = input_tensor.clone()
bottom = min(top + self.patch_size, H)
right = min(left + self.patch_size, W)
occluded[0, :, top:bottom, left:right] = self.occlusion_value
batch_tensors.append(occluded)
batch = torch.cat(batch_tensors, dim=0).to(device)
with torch.no_grad():
logits = self.model(batch)
probs = F.softmax(logits, dim=1)[:, class_idx].cpu().numpy()
# The sensitivity at this patch = how much the probability dropped
for i, (top, left) in enumerate(batch_positions):
bottom = min(top + self.patch_size, H)
right = min(left + self.patch_size, W)
drop = baseline_prob - probs[i] # positive = important region
sensitivity_map[top:bottom, left:right] += drop
count_map[top:bottom, left:right] += 1.0
# Average overlapping regions
with warnings.catch_warnings():
warnings.simplefilter("ignore")
sensitivity_map = np.where(
count_map > 0,
sensitivity_map / count_map,
0.0,
)
sensitivity_map = self._normalize(sensitivity_map)
return sensitivity_map, class_idx
@staticmethod
def _normalize(arr: np.ndarray) -> np.ndarray:
min_val, max_val = arr.min(), arr.max()
if max_val - min_val < 1e-8:
return np.zeros_like(arr)
return (arr - min_val) / (max_val - min_val)
def compute_mean_sensitivity(
self,
sensitivity_map: np.ndarray,
top_k_percent: float = 0.1,
) -> float:
"""Return the mean sensitivity of the top-k% most important pixels."""
flat = sensitivity_map.flatten()
k = max(1, int(len(flat) * top_k_percent))
return float(np.partition(flat, -k)[-k:].mean())