Source code for eli5.keras.explain_prediction

# -*- coding: utf-8 -*-
from __future__ import absolute_import
from typing import Union, Optional, Callable, Tuple, List, TYPE_CHECKING
if TYPE_CHECKING:
    import PIL

import numpy as np
import keras
import keras.backend as K
from keras.models import Model
from keras.layers import Layer
from keras.layers import (
    Conv2D,
    MaxPooling2D,
    AveragePooling2D,
    GlobalMaxPooling2D,
    GlobalAveragePooling2D,
)
from keras.preprocessing.image import array_to_img

from eli5.base import Explanation, TargetExplanation
from eli5.explain import explain_prediction
from .gradcam import gradcam, gradcam_backend


DESCRIPTION_KERAS = """Grad-CAM visualization for image classification; 
output is explanation object that contains input image 
and heatmap image for a target.
"""

# note that keras.models.Sequential subclasses keras.models.Model
[docs]@explain_prediction.register(Model) def explain_prediction_keras(model, # type: Model doc, # type: np.ndarray targets=None, # type: Optional[list] layer=None, # type: Optional[Union[int, str, Layer]] image=None, ): # type: (...) -> Explanation """ Explain the prediction of a Keras classifier with the Grad-CAM technique. We explicitly assume that the model's task is classification, i.e. final output is class scores. :param keras.models.Model model: Instance of a Keras neural network model, whose predictions are to be explained. :param numpy.ndarray doc: An input to ``model`` whose prediction will be explained. Currently only numpy arrays are supported. The tensor must be of suitable shape for the ``model``. Check ``model.input_shape`` to confirm the required dimensions of the input tensor. :raises TypeError: if ``doc`` is not a numpy array. :raises ValueError: if ``doc`` shape does not match. :param targets: Prediction ID's to focus on. *Currently only the first prediction from the list is explained*. The list must be length one. If None, the model is fed the input image and its top prediction is taken as the target automatically. :raises ValueError: if ``targets`` is a list with more than one item. :raises TypeError: if ``targets`` is not list or None. :type targets: list[int], optional :param layer: The activation layer in the model to perform Grad-CAM on: a valid keras layer name, layer index, or an instance of a Keras layer. If None, a suitable layer is attempted to be retrieved. For best results, pick a layer that: * has spatial or temporal information (conv, recurrent, pooling, embedding) (not dense layers). * shows high level features. * has large enough dimensions for resizing over input to work. :raises TypeError: if ``layer`` is not None, str, int, or keras.layers.Layer instance. :raises ValueError: if suitable layer can not be found. :raises ValueError: if differentiation fails with respect to retrieved ``layer``. :type layer: int or str or keras.layers.Layer, optional See :func:`eli5.explain_prediction` for more information about the ``model``, ``doc``, and ``targets`` parameters. Other arguments are passed to concrete implementations for image and text explanations. Returns ------- expl : :class:`eli5.base.Explanation` An :class:`eli5.base.Explanation` object for the relevant implementation. """ # Note that this function should only do dispatch # and no other processing if image is not None or _maybe_image(model, doc): return explain_prediction_keras_image(model, doc, image=image, targets=targets, layer=layer, ) else: return explain_prediction_keras_not_supported(model, doc)
[docs]def explain_prediction_keras_not_supported(model, doc): """ Can not do an explanation based on the passed arguments. Did you pass either "image" or "tokens"? """ return Explanation( model.name, error='model "{}" is not supported, ' 'try passing the "image" argument if explaining an image model.'.format(model.name), )
# TODO (open issue): implement 'other'/differentiable network type explanations
[docs]def explain_prediction_keras_image(model, doc, image=None, # type: Optional['PIL.Image.Image'] targets=None, layer=None, ): """ Explain an image-based model, highlighting what contributed in the image. :param numpy.ndarray doc: Input representing an image. Must have suitable format. Some models require tensors to be rank 4 in format `(batch_size, dims, ..., channels)` (channels last) or `(batch_size, channels, dims, ...)` (channels first), where `dims` is usually in order `height, width` and `batch_size` is 1 for a single image. If ``image`` argument is not given, an image will be created from ``doc``, where possible. :param image: Pillow image over which to overlay the heatmap. Corresponds to the input ``doc``. :type image: PIL.Image.Image, optional See :func:`eli5.keras.explain_prediction.explain_prediction_keras` for a description of ``model``, ``doc``, ``targets``, and ``layer`` parameters. Returns ------- expl : eli5.base.Explanation An :class:`eli5.base.Explanation` object with the following attributes: * ``image`` a Pillow image representing the input. * ``targets`` a list of :class:`eli5.base.TargetExplanation` objects \ for each target. Currently only 1 target is supported. The :class:`eli5.base.TargetExplanation` objects will have the following attributes: * ``heatmap`` a rank 2 numpy array with the localization map \ values as floats. * ``target`` ID of target class. * ``score`` value for predicted class. """ if image is None: image = _extract_image(doc) _validate_doc(model, doc) activation_layer = _get_activation_layer(model, layer) # TODO: maybe do the sum / loss calculation in this function and pass it to gradcam. # This would be consistent with what is done in # https://github.com/ramprs/grad-cam/blob/master/misc/utils.lua # and https://github.com/ramprs/grad-cam/blob/master/classification.lua values = gradcam_backend(model, doc, targets, activation_layer) weights, activations, grads, predicted_idx, predicted_val = values heatmap = gradcam(weights, activations) return Explanation( model.name, description=DESCRIPTION_KERAS, error='', method='Grad-CAM', image=image, targets=[TargetExplanation( predicted_idx, score=predicted_val, # for now we keep the prediction in the .score field (not .proba) heatmap=heatmap, # 2D [0, 1] numpy array )], is_regression=False, # might be relevant later when explaining for regression tasks highlight_spaces=None, # might be relevant later when explaining text models )
def _maybe_image(model, doc): # type: (Model, np.ndarray) -> bool """Decide whether we are dealing with a image-based explanation based on heuristics on ``model`` and ``doc``.""" return _maybe_image_input(doc) and _maybe_image_model(model) def _maybe_image_input(doc): # type: (np.ndarray) -> bool """Decide whether ``doc`` represents an image input.""" rank = len(doc.shape) # image with channels or without (spatial only) return rank == 4 or rank == 3 def _maybe_image_model(model): # type: (Model) -> bool """Decide whether ``model`` is used for images.""" # FIXME: replace try-except with something else try: # search for the first occurrence of an "image" layer _search_layer_backwards(model, _is_possible_image_model_layer) return True except ValueError: return False image_model_layers = (Conv2D, MaxPooling2D, AveragePooling2D, GlobalMaxPooling2D, GlobalAveragePooling2D, ) def _is_possible_image_model_layer(model, layer): # type: (Model, Layer) -> bool """Check that the given ``layer`` is usually used for images.""" return isinstance(layer, image_model_layers) def _extract_image(doc): # type: (np.ndarray) -> 'PIL.Image.Image' """Convert ``doc`` tensor to image.""" im_arr, = doc # rank 4 batch -> rank 3 single image image = array_to_img(im_arr) return image def _validate_doc(model, doc): # type: (Model, np.ndarray) -> None """ Check that the input ``doc`` is suitable for ``model``. """ if not isinstance(doc, np.ndarray): raise TypeError('doc must be a numpy.ndarray, got: {}'.format(doc)) input_sh = model.input_shape doc_sh = doc.shape if len(input_sh) == 4: # rank 4 with (batch, ...) shape # check that we have only one image (batch size 1) single_batch = (1,) + input_sh[1:] if doc_sh != single_batch: raise ValueError('Batch size does not match (must be 1). ' 'doc must be of shape: {}, ' 'got: {}'.format(single_batch, doc_sh)) else: # other shapes if doc_sh != input_sh: raise ValueError('Input and doc shapes do not match.' 'input: {}, doc: {}'.format(input_sh, doc_sh)) def _get_activation_layer(model, layer): # type: (Model, Union[None, int, str, Layer]) -> Layer """ Get an instance of the desired activation layer in ``model``, as specified by ``layer``. """ if layer is None: # Automatically get the layer if not provided activation_layer = _search_layer_backwards(model, _is_suitable_activation_layer) return activation_layer if isinstance(layer, Layer): activation_layer = layer # get_layer() performs a bottom-up horizontal graph traversal # it can raise ValueError if the layer index / name specified is not found elif isinstance(layer, int): activation_layer = model.get_layer(index=layer) elif isinstance(layer, str): activation_layer = model.get_layer(name=layer) else: raise TypeError('Invalid layer (must be str, int, keras.layers.Layer, or None): %s' % layer) if _is_suitable_activation_layer(model, activation_layer): # final validation step return activation_layer else: raise ValueError('Can not perform Grad-CAM on the retrieved activation layer') def _search_layer_backwards(model, condition): # type: (Model, Callable[[Model, Layer], bool]) -> Layer """ Search for a layer in ``model``, backwards (starting from the output layer), checking if the layer is suitable with the callable ``condition``, """ # linear search in reverse through the flattened layers for layer in model.layers[::-1]: if condition(model, layer): # linear search succeeded return layer # linear search ended with no results raise ValueError('Could not find a suitable target layer automatically.') def _is_suitable_activation_layer(model, layer): # type: (Model, Layer) -> bool """ Check whether the layer ``layer`` matches what is required by ``model`` to do Grad-CAM on ``layer``. Returns a boolean. Matching Criteria: * Rank of the layer's output tensor. """ # TODO: experiment with this, using many models and images, to find what works best # Some ideas: # check layer type, i.e.: isinstance(l, keras.layers.Conv2D) # check layer name # a check that asks "can we resize this activation layer over the image?" rank = len(layer.output_shape) required_rank = len(model.input_shape) return rank == required_rank