Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Keras images) Add an optional image argument, and other improvements #329

Merged
merged 21 commits into from Aug 10, 2019
Merged
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c4eb039
(keras) Make image argument required
teabolt Aug 5, 2019
1111105
Update README.md to include keras
teabolt Aug 6, 2019
7d6d982
Merge branch 'master' of https://github.com/teabolt/eli5 into keras-g…
teabolt Aug 6, 2019
14452c3
Remove mentions of target_names (not implemented)
teabolt Aug 7, 2019
9e85021
Add dispatch function and image implementation
teabolt Aug 7, 2019
9a0cd53
Update dispatcher and image function docstrings
teabolt Aug 7, 2019
6b002a6
Automatically check if model/input is image-based. Convert input to a…
teabolt Aug 7, 2019
7d82c30
Mock keras.preprocessing.image in docs conf (CI fix)
teabolt Aug 7, 2019
d1af643
Update tests, docs, tutorial with image argument changes
teabolt Aug 7, 2019
6aec486
Blank line between header and list in docstring (CI fix)
teabolt Aug 8, 2019
aaa83a7
Test keras not supported function
teabolt Aug 8, 2019
47b03c1
Docstring typo
teabolt Aug 8, 2019
df25038
Clarify "not supported" error message.
teabolt Aug 8, 2019
6234aaa
Remove TODO: explain Grad-CAM in docstring. (Will be explained in ker…
teabolt Aug 8, 2019
13e1847
Move image extraction call from dispatcher to image function
teabolt Aug 8, 2019
3e875bb
Move Keras to second place in supported package list
teabolt Aug 8, 2019
4192939
Remove warnings for 'maybe image' dispatch and conversion to RGBA
teabolt Aug 8, 2019
2bb7ba5
'not supported' error typo
teabolt Aug 8, 2019
991159b
Test 'maybe image' check with both input and model
teabolt Aug 9, 2019
10921be
Add Grad-CAM image to README
teabolt Aug 9, 2019
0cf31fe
Remove line breaking backslash from docstring
teabolt Aug 10, 2019
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

Add dispatch function and image implementation

  • Loading branch information
teabolt committed Aug 7, 2019
commit 9e85021e36cbfffdbdd3b0eb3481b2c3bae6229f
@@ -22,63 +22,48 @@

# note that keras.models.Sequential subclasses keras.models.Model
@explain_prediction.register(Model)
def explain_prediction_keras(estimator, # type: Model
def explain_prediction_keras(model, # type: Model
doc, # type: np.ndarray
image=None, # type: Optional['PIL.Image.Image']
targets=None, # type: Optional[list]
layer=None, # type: Optional[Union[int, str, Layer]]
image=None,
):
# type: (...) -> Explanation
"""
Explain the prediction of a Keras image classifier.
Explain the prediction of a Keras classifier with the Grad-CAM technique.
We make two explicit assumptions
* The input is images.
* The model's task is classification, i.e. final output is class scores.
# TODO: explain Grad-CAM
See :func:`eli5.explain_prediction` for more information about the ``estimator``,
``doc``, and ``targets`` parameters.
We explicitly assume that the model's task is classification, i.e. final output is class scores.
:param keras.models.Model estimator:
:param keras.models.Model model:
Instance of a Keras neural network model,
whose predictions are to be explained.
:param numpy.ndarray doc:
An input image as a tensor to ``estimator``,
An input image as a tensor to ``model``,
from which prediction will be done and explained.
Currently only numpy arrays are supported.
The tensor must be of suitable shape for the ``estimator``.
The tensor must be of suitable shape for the ``model``.
For example, some models require input images 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.
Check ``estimator.input_shape`` to confirm the required dimensions of the input tensor.
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 image:
Pillow image over which to overlay the heatmap.
Corresponds to the input ``doc``.
Must have mode 'RGBA'.
:type image: PIL.Image.Image, optional
:param targets:
Prediction ID's to focus on.
*Currently only the first prediction from the list is explained*.
*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
@@ -92,47 +77,107 @@ def explain_prediction_keras(estimator, # type: Model
: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.
The layer is searched for going backwards from the output layer,
checking that the rank of the layer's output
equals to the rank of the input.
If None, a suitable layer is attempted to be retrieved.
For best results, pick a layer that:
* has spatial or temporal information (conv, recurrent, pool, 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``.
: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
This conversation was marked as resolved by lopuhin

This comment has been minimized.

Copy link
@lopuhin

lopuhin Aug 8, 2019

Contributor

That's quite minor, but currently this function does more than dispatch, it also does image = _extract_image(doc) - maybe it's better to either move that to explain_prediction_keras_image, or to update the comment.

This comment has been minimized.

Copy link
@teabolt

teabolt Aug 8, 2019

Author Contributor

Good catch. I will move the line to explain_prediction_keras_image.

if image is not None:
return explain_prediction_keras_image(model,
doc,
image=image,
targets=targets,
layer=layer,
)
else:
return explain_prediction_keras_not_supported(model, doc)


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.'.format(model.name),
This conversation was marked as resolved by teabolt

This comment has been minimized.

Copy link
@lopuhin

lopuhin Aug 8, 2019

Contributor
Suggested change
'try passing the "image" argument.'.format(model.name),
'try passing the "image" argument if explaning an image model.'.format(model.name),
)
# TODO (open issue): implement 'other'/differentiable network type explanations


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.
See :func:`eli5.keras.explain_prediction.explain_prediction_keras`
for a description of ``targets`` and ``layer`` parameters.
:param image:
Pillow image over which to overlay the heatmap.
Corresponds to the input ``doc``.
Must have mode 'RGBA'.
:type image: PIL.Image.Image, optional
Returns
-------
expl : :class:`eli5.base.Explanation`
An :class:`eli5.base.Explanation` object with the following attributes:
* ``image`` a Pillow image with mode RGBA.
* ``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 floats in interval [0, 1] \
with the localization map values.
* ``target`` ID of target class.
* ``score`` value for predicted class.
expl : eli5.base.Explanation
An :class:`eli5.base.Explanation` object with the following attributes:
* ``image`` a Pillow image with mode RGBA.
* ``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.
"""
# TODO (open issue): support taking images that are not 'RGBA' -> 'RGB'
# as well (happens with keras load_img)
# and grayscale too
assert image is not None
_validate_doc(estimator, doc)
activation_layer = _get_activation_layer(estimator, layer)
_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(estimator, doc, targets, activation_layer)
values = gradcam_backend(model, doc, targets, activation_layer)
weights, activations, grads, predicted_idx, predicted_val = values
heatmap = gradcam(weights, activations)

return Explanation(
estimator.name,
model.name,
description=DESCRIPTION_KERAS,
error='',
method='Grad-CAM',
@@ -147,14 +192,14 @@ def explain_prediction_keras(estimator, # type: Model
)


def _validate_doc(estimator, doc):
def _validate_doc(model, doc):
# type: (Model, np.ndarray) -> None
"""
Check that the input ``doc`` is suitable for ``estimator``.
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 = estimator.input_shape
input_sh = model.input_shape
doc_sh = doc.shape
if len(input_sh) == 4:
# rank 4 with (batch, ...) shape
@@ -171,55 +216,55 @@ def _validate_doc(estimator, doc):
'input: {}, doc: {}'.format(input_sh, doc_sh))


def _get_activation_layer(estimator, layer):
def _get_activation_layer(model, layer):
# type: (Model, Union[None, int, str, Layer]) -> Layer
"""
Get an instance of the desired activation layer in ``estimator``,
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(estimator, _is_suitable_activation_layer)
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 = estimator.get_layer(index=layer)
activation_layer = model.get_layer(index=layer)
elif isinstance(layer, str):
activation_layer = estimator.get_layer(name=layer)
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(estimator, activation_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(estimator, condition):
def _search_layer_backwards(model, condition):
# type: (Model, Callable[[Model, int], bool]) -> Layer
"""
Search for a layer in ``estimator``, backwards (starting from the output 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 estimator.layers[::-1]:
if condition(estimator, layer):
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(estimator, layer):
def _is_suitable_activation_layer(model, layer):
# type: (Model, Layer) -> bool
"""
Check whether the layer ``layer`` matches what is required
by ``estimator`` to do Grad-CAM on ``layer``.
by ``model`` to do Grad-CAM on ``layer``.
Returns a boolean.
Matching Criteria:
@@ -232,5 +277,5 @@ def _is_suitable_activation_layer(estimator, layer):

# a check that asks "can we resize this activation layer over the image?"
rank = len(layer.output_shape)
required_rank = len(estimator.input_shape)
required_rank = len(model.input_shape)
return rank == required_rank
@@ -37,8 +37,8 @@ def gradcam(weights, activations):
Notes
-----
We currently make two assumptions in this implementation
* We are dealing with images as our input to ``estimator``.
* We are doing a classification. ``estimator``'s output is a class scores or probabilities vector.
* We are dealing with images as our input to ``model``.
* We are doing a classification. ``model``'s output is a class scores or probabilities vector.
Credits
* Jacob Gildenblat for "https://github.com/jacobgil/keras-grad-cam".
@@ -70,7 +70,7 @@ def gradcam(weights, activations):
return lmap


def gradcam_backend(estimator, # type: Model
def gradcam_backend(model, # type: Model
doc, # type: np.ndarray
targets, # type: Optional[List[int]]
activation_layer # type: Layer
@@ -81,7 +81,7 @@ def gradcam_backend(estimator, # type: Model
Parameters
----------
estimator : keras.models.Model
model : keras.models.Model
Differentiable network.
doc : numpy.ndarray
@@ -97,16 +97,16 @@ def gradcam_backend(estimator, # type: Model
See :func:`eli5.keras.explain_prediction` for description of the
``estimator``, ``doc``, ``targets`` parameters.
``model``, ``doc``, ``targets`` parameters.
Returns
-------
(weights, activations, gradients, predicted_idx, predicted_val) : (numpy.ndarray, ..., int, float)
Values of variables.
"""
# score for class in targets
predicted_idx = _get_target_prediction(targets, estimator)
predicted_val = K.gather(estimator.output[0,:], predicted_idx) # access value by index
predicted_idx = _get_target_prediction(targets, model)
predicted_val = K.gather(model.output[0,:], predicted_idx) # access value by index

# output of target activation layer, i.e. activation maps of a convolutional layer
activation_output = activation_layer.output
@@ -119,7 +119,7 @@ def gradcam_backend(estimator, # type: Model
# TODO: decide whether this should go in gradcam_backend() or gradcam()
weights = K.mean(grads, axis=(1, 2))

evaluate = K.function([estimator.input],
evaluate = K.function([model.input],
[weights, activation_output, grads, predicted_val, predicted_idx]
)
# evaluate the graph / do actual computations
@@ -163,26 +163,26 @@ def _calc_gradient(ys, xs):
return grads


def _get_target_prediction(targets, estimator):
def _get_target_prediction(targets, model):
# type: (Optional[list], Model) -> K.variable
"""
Get a prediction ID based on ``targets``,
from the model ``estimator`` (with a rank 2 tensor for its final layer).
from the model ``model`` (with a rank 2 tensor for its final layer).
Returns a rank 1 K.variable tensor.
"""
if isinstance(targets, list):
# take the first prediction from the list
if len(targets) == 1:
target = targets[0]
_validate_target(target, estimator.output_shape)
_validate_target(target, model.output_shape)
predicted_idx = K.constant([target], dtype='int64')
else:
raise ValueError('More than one prediction target '
'is currently not supported '
'(found a list that is not length 1): '
'{}'.format(targets))
elif targets is None:
predicted_idx = K.argmax(estimator.output, axis=-1)
predicted_idx = K.argmax(model.output, axis=-1)
else:
raise TypeError('Invalid argument "targets" (must be list or None): %s' % targets)
return predicted_idx
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.