Skip to content

Prediction explanations

explain

High-level prediction debugging for one image.

torchcam.explain.PredictionExplanation dataclass

PredictionExplanation(logits: Tensor, predicted_class_idx: int, expected_class_idx: int | None, cams: Mapping[int, tuple[Tensor, ...]], method: str, target_layers: tuple[str, ...], model: str, input_shape: tuple[int, ...], versions: Mapping[str, str], class_names: tuple[str, ...] | None = None)

Prediction, class activation maps, and reproducibility metadata for one image.

torchcam.explain.PredictionExplanation.save

save(directory: str | Path, image: Image, alpha: float = 0.5) -> Path

Save NumPy maps, heatmaps, overlays, and a completion manifest to a new directory.

PARAMETER DESCRIPTION
directory

new output directory

TYPE: str | Path

image

source image used for full-size overlays

TYPE: Image

alpha

source-image opacity in the overlay

TYPE: float DEFAULT: 0.5

RETURNS DESCRIPTION
Path

output directory

RAISES DESCRIPTION
TypeError

if the image is not a PIL image

FileExistsError

if the output directory already exists

Source code in torchcam/explain.py
def save(self, directory: str | Path, image: Image, alpha: float = 0.5) -> Path:
    """Save NumPy maps, heatmaps, overlays, and a completion manifest to a new directory.

    Args:
        directory: new output directory
        image: source image used for full-size overlays
        alpha: source-image opacity in the overlay

    Returns:
        output directory

    Raises:
        TypeError: if the image is not a PIL image
        FileExistsError: if the output directory already exists
    """
    if not isinstance(image, Image):
        raise TypeError("`image` must be a PIL image")

    output_dir = Path(directory)
    if output_dir.exists():
        raise FileExistsError(f"output directory already exists: {output_dir}")
    output_dir.mkdir(parents=True)
    with ExitStack() as cleanup:
        cleanup.callback(shutil.rmtree, output_dir)
        probabilities = self.logits.softmax(dim=1)[0]
        classes: dict[str, dict[str, Any]] = {}

        for class_idx, maps in sorted(self.cams.items()):
            artifacts = []
            artifact_layers = (
                (self.target_layers,) if len(maps) == 1 else tuple((name,) for name in self.target_layers)
            )

            for layer_idx, (target_layers, cam) in enumerate(zip(artifact_layers, maps, strict=True)):
                stem = f"class-{class_idx}-layer-{layer_idx}"
                array = cam.numpy()
                npy_path = output_dir / f"{stem}.npy"
                heatmap_path = output_dir / f"{stem}-heatmap.png"
                overlay_path = output_dir / f"{stem}-overlay.png"

                np.save(npy_path, array, allow_pickle=False)
                heatmap = fromarray((255 * np.clip(array, 0, 1)).round().astype(np.uint8))
                heatmap.save(heatmap_path)
                overlay_mask(image, heatmap, alpha=alpha).save(overlay_path)
                artifacts.append({
                    "target_layers": list(target_layers),
                    "map": npy_path.name,
                    "heatmap": heatmap_path.name,
                    "overlay": overlay_path.name,
                })

            classes[str(class_idx)] = {
                "class_idx": class_idx,
                "class_name": self._class_name(class_idx),
                "logit": self.logits[0, class_idx].item(),
                "probability": probabilities[class_idx].item(),
                "artifacts": artifacts,
            }

        manifest = {
            "schema_version": 1,
            "prediction": self._class_reference(self.predicted_class_idx),
            "expected": self._class_reference(self.expected_class_idx),
            "classes": classes,
            "method": self.method,
            "target_layers": list(self.target_layers),
            "model": self.model,
            "input_shape": list(self.input_shape),
            "versions": dict(self.versions),
            "image_size": list(image.size),
            "alpha": alpha,
        }
        (output_dir / "manifest.json").write_text(
            json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8"
        )
        cleanup.pop_all()
    return output_dir

torchcam.explain.explain

explain(model: Module, input_tensor: Tensor, *, expected_class_idx: int | None = None, class_names: Sequence[str] | None = None, method: type[_CAM] = GradCAM, target_layer: Module | str | list[Module | str] | None = None, method_kwargs: Mapping[str, Any] | None = None) -> PredictionExplanation

Explain the predicted and optional expected class for one 2D image.

RETURNS DESCRIPTION
PredictionExplanation

detached prediction evidence and CAMs

RAISES DESCRIPTION
RuntimeError

if called from inference mode

ValueError

if an argument, model output, or CAM has an unsupported value

Source code in torchcam/explain.py
def explain(
    model: nn.Module,
    input_tensor: Tensor,
    *,
    expected_class_idx: int | None = None,
    class_names: Sequence[str] | None = None,
    method: type[_CAM] = GradCAM,
    target_layer: nn.Module | str | list[nn.Module | str] | None = None,
    method_kwargs: Mapping[str, Any] | None = None,
) -> PredictionExplanation:
    """Explain the predicted and optional expected class for one 2D image.

    Returns:
        detached prediction evidence and CAMs

    Raises:
        RuntimeError: if called from inference mode
        ValueError: if an argument, model output, or CAM has an unsupported value
    """
    if torch.is_inference_mode_enabled():
        raise RuntimeError("`explain` cannot run inside torch.inference_mode() because CAM extraction needs gradients")
    _validate_request(model, input_tensor, expected_class_idx, method, method_kwargs)

    kwargs = dict(method_kwargs or {})
    if "target_layer" in kwargs:
        raise ValueError("pass `target_layer` directly, not through `method_kwargs`")
    if target_layer is not None:
        kwargs["target_layer"] = target_layer
    elif method is GradCAM:
        kwargs.setdefault("input_shape", tuple(input_tensor.shape[1:]))

    working_input = input_tensor.detach().requires_grad_(True)
    parameters = tuple(model.parameters())
    gradients = tuple(parameter.grad for parameter in parameters)

    try:
        with torch.enable_grad(), method(model, **kwargs) as extractor:
            logits = _validate_logits(model(working_input))
            predicted_class_idx = int(logits[0].argmax().item())
            validated_class_names = _validate_classes(expected_class_idx, class_names, logits.shape[1])

            cams = {predicted_class_idx: _prepare_maps(extractor(predicted_class_idx, logits))}
            if expected_class_idx is not None and expected_class_idx != predicted_class_idx:
                expected_logits = _validate_logits(model(working_input))
                if expected_logits.shape != logits.shape:
                    raise ValueError("model output shape changed between explanation forwards")
                cams[expected_class_idx] = _prepare_maps(extractor(expected_class_idx, expected_logits))

            result = PredictionExplanation(
                logits=logits.detach().cpu(),
                predicted_class_idx=predicted_class_idx,
                expected_class_idx=expected_class_idx,
                cams=MappingProxyType(cams),
                method=method.__name__,
                target_layers=tuple(extractor.target_names),
                model=f"{model.__class__.__module__}.{model.__class__.__qualname__}",
                input_shape=tuple(input_tensor.shape),
                versions=MappingProxyType({
                    "python": platform.python_version(),
                    "torch": str(torch.__version__),
                    "torchcam": version("torchcam"),
                }),
                class_names=validated_class_names,
            )
    finally:
        for parameter, gradient in zip(parameters, gradients, strict=True):
            parameter.grad = gradient

    return result