What is SHAP?¶
SHAP unifies several explainability methods under a single theoretical framework based on Shapley values from cooperative game theory.
Key Concepts:¶
- Shapley Values: Fair distribution of a "payout" (prediction) among "players" (features)
- Additive: SHAP values sum to the difference between prediction and expected value
- Theoretically Grounded: Satisfies desirable properties (local accuracy, missingness, consistency)
- Unified Framework: Connects and improves upon LIME, DeepLIFT, and other methods
Mathematical Foundation:¶
For a prediction $f(x)$ and a feature $i$, the Shapley value $\phi_i$ is:
$$\phi_i = \sum_{S \subseteq F \setminus \{i\}} \frac{|S|!(|F|-|S|-1)!}{|F|!} [f(S \cup \{i\}) - f(S)]$$
Where:
- $F$ = set of all features
- $S$ = subset of features (coalition)
- The formula averages marginal contributions across all possible coalitions
SHAP Explainers:¶
- GradientExplainer: Uses gradients (fast, approximate)
- DeepExplainer: Uses DeepLIFT algorithm (accurate for deep networks)
- KernelExplainer: Model-agnostic (slower, like LIME)
Advantages:¶
- ✅ Theoretically sound (game theory)
- ✅ Consistent explanations
- ✅ Locally accurate
- ✅ Fast for deep learning (GradientExplainer)
Disadvantages:¶
- ❌ Complex interpretation (requires understanding Shapley values)
- ❌ Computational cost for exact values
- ❌ Background distribution matters
1. Setup and Imports¶
In [1]:
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import datasets, transforms
import shap
from pathlib import Path
print(f"PyTorch version: {torch.__version__}")
print(f"SHAP version: {shap.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
PyTorch version: 2.9.1+cu128 SHAP version: 0.49.1 CUDA available: True
2. Define the CNN Model¶
Same architecture as LIME tutorial for consistency.
In [2]:
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout2d(0.25)
self.dropout2 = nn.Dropout2d(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.conv1(x)
x = F.relu(x)
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = F.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
output = F.softmax(x, dim=1) # Softmax for SHAP
return output
print("Model architecture defined!")
Model architecture defined!
3. Load MNIST Dataset¶
In [3]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
print("Loading MNIST dataset...")
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, transform=transform)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=1, shuffle=True)
print(f"Training samples: {len(train_dataset)}")
print(f"Test samples: {len(test_dataset)}")
Using device: cuda Loading MNIST dataset... Training samples: 60000 Test samples: 10000
4. Train the Model¶
In [4]:
def train_model(model, device, train_loader, epochs=3):
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
model.train()
for epoch in range(epochs):
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
if batch_idx % 100 == 0:
print(f'Epoch {epoch+1}/{epochs}, Batch {batch_idx}/{len(train_loader)}, Loss: {loss.item():.4f}')
print("Initializing model...")
model = SimpleCNN().to(device)
print("\nTraining model...")
train_model(model, device, train_loader, epochs=3)
print("Training complete!")
Initializing model... Training model...
/home/matthewcockayne/.local/lib/python3.10/site-packages/torch/nn/functional.py:1531: UserWarning: dropout2d: Received a 2-D input to dropout2d, which is deprecated and will result in an error in a future release. To retain the behavior and silence this warning, please use dropout instead. Note that dropout2d exists to provide channel-wise dropout on inputs with 2 spatial dimensions, a channel dimension, and an optional batch dimension (i.e. 3D or 4D inputs). warnings.warn(warn_msg)
Epoch 1/3, Batch 0/938, Loss: 2.3022 Epoch 1/3, Batch 100/938, Loss: 1.6632 Epoch 1/3, Batch 100/938, Loss: 1.6632 Epoch 1/3, Batch 200/938, Loss: 1.5886 Epoch 1/3, Batch 200/938, Loss: 1.5886 Epoch 1/3, Batch 300/938, Loss: 1.6544 Epoch 1/3, Batch 300/938, Loss: 1.6544 Epoch 1/3, Batch 400/938, Loss: 1.5739 Epoch 1/3, Batch 400/938, Loss: 1.5739 Epoch 1/3, Batch 500/938, Loss: 1.5417 Epoch 1/3, Batch 500/938, Loss: 1.5417 Epoch 1/3, Batch 600/938, Loss: 1.5076 Epoch 1/3, Batch 600/938, Loss: 1.5076 Epoch 1/3, Batch 700/938, Loss: 1.4776 Epoch 1/3, Batch 700/938, Loss: 1.4776 Epoch 1/3, Batch 800/938, Loss: 1.4631 Epoch 1/3, Batch 800/938, Loss: 1.4631 Epoch 1/3, Batch 900/938, Loss: 1.5656 Epoch 2/3, Batch 0/938, Loss: 1.5064 Epoch 1/3, Batch 900/938, Loss: 1.5656 Epoch 2/3, Batch 0/938, Loss: 1.5064 Epoch 2/3, Batch 100/938, Loss: 1.4917 Epoch 2/3, Batch 100/938, Loss: 1.4917 Epoch 2/3, Batch 200/938, Loss: 1.4950 Epoch 2/3, Batch 200/938, Loss: 1.4950 Epoch 2/3, Batch 300/938, Loss: 1.5139 Epoch 2/3, Batch 300/938, Loss: 1.5139 Epoch 2/3, Batch 400/938, Loss: 1.5170 Epoch 2/3, Batch 400/938, Loss: 1.5170 Epoch 2/3, Batch 500/938, Loss: 1.4702 Epoch 2/3, Batch 500/938, Loss: 1.4702 Epoch 2/3, Batch 600/938, Loss: 1.5136 Epoch 2/3, Batch 600/938, Loss: 1.5136 Epoch 2/3, Batch 700/938, Loss: 1.4890 Epoch 2/3, Batch 700/938, Loss: 1.4890 Epoch 2/3, Batch 800/938, Loss: 1.4794 Epoch 2/3, Batch 800/938, Loss: 1.4794 Epoch 2/3, Batch 900/938, Loss: 1.5371 Epoch 3/3, Batch 0/938, Loss: 1.5096 Epoch 2/3, Batch 900/938, Loss: 1.5371 Epoch 3/3, Batch 0/938, Loss: 1.5096 Epoch 3/3, Batch 100/938, Loss: 1.5140 Epoch 3/3, Batch 100/938, Loss: 1.5140 Epoch 3/3, Batch 200/938, Loss: 1.4774 Epoch 3/3, Batch 200/938, Loss: 1.4774 Epoch 3/3, Batch 300/938, Loss: 1.5005 Epoch 3/3, Batch 300/938, Loss: 1.5005 Epoch 3/3, Batch 400/938, Loss: 1.5128 Epoch 3/3, Batch 400/938, Loss: 1.5128 Epoch 3/3, Batch 500/938, Loss: 1.5022 Epoch 3/3, Batch 500/938, Loss: 1.5022 Epoch 3/3, Batch 600/938, Loss: 1.4807 Epoch 3/3, Batch 600/938, Loss: 1.4807 Epoch 3/3, Batch 700/938, Loss: 1.5085 Epoch 3/3, Batch 700/938, Loss: 1.5085 Epoch 3/3, Batch 800/938, Loss: 1.4945 Epoch 3/3, Batch 800/938, Loss: 1.4945 Epoch 3/3, Batch 900/938, Loss: 1.4926 Training complete! Epoch 3/3, Batch 900/938, Loss: 1.4926 Training complete!
5. Evaluate Model¶
In [5]:
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
output = model(data)
pred = output.argmax(dim=1)
correct += pred.eq(target).sum().item()
total += target.size(0)
if total >= 1000:
break
accuracy = 100. * correct / total
print(f"Model Accuracy: {accuracy:.2f}%")
Model Accuracy: 97.40%
6. Prepare Data for SHAP¶
SHAP requires:
- Background data: Reference distribution (representative samples)
- Test data: Instances to explain
In [6]:
print("Preparing background and test samples...")
# Background samples (from training set)
background_samples = []
train_iter = iter(train_loader)
for _ in range(2): # 128 background samples
data, _ = next(train_iter)
background_samples.append(data)
background_data = torch.cat(background_samples, dim=0).to(device)
# Test samples
test_samples = []
test_labels = []
test_iter = iter(test_loader)
for _ in range(3): # 3 test samples to explain
data, label = next(test_iter)
test_samples.append(data)
test_labels.append(label.item())
test_data = torch.cat(test_samples, dim=0).to(device)
# Get predictions
with torch.no_grad():
predictions = model(test_data)
pred_labels = predictions.argmax(dim=1).cpu().numpy()
print(f"Background samples: {background_data.shape}")
print(f"Test samples: {test_data.shape}")
print(f"True labels: {test_labels}")
print(f"Predicted labels: {pred_labels.tolist()}")
Preparing background and test samples... Background samples: torch.Size([128, 1, 28, 28]) Test samples: torch.Size([3, 1, 28, 28]) True labels: [9, 2, 9] Predicted labels: [9, 2, 9]
7. SHAP Method 1: GradientExplainer¶
GradientExplainer uses gradients to approximate SHAP values quickly.
In [7]:
print("Generating SHAP explanations using GradientExplainer...")
model.eval()
explainer = shap.GradientExplainer(model, background_data)
shap_values_gradient = explainer.shap_values(test_data)
print(f"✓ SHAP values computed!")
print(f"Structure: {len(shap_values_gradient)} samples")
print(f"Shape per sample: {shap_values_gradient[0].shape}")
Generating SHAP explanations using GradientExplainer... ✓ SHAP values computed! Structure: 3 samples Shape per sample: (1, 28, 28, 10) ✓ SHAP values computed! Structure: 3 samples Shape per sample: (1, 28, 28, 10)
Visualize GradientExplainer Results¶
In [8]:
output_dir = Path('../results/shap')
output_dir.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(3, 3, figsize=(12, 12))
for idx in range(3):
image = test_samples[idx].squeeze().cpu().numpy()
pred_class = pred_labels[idx]
true_class = test_labels[idx]
# Extract SHAP values for predicted class
shap_array = np.array(shap_values_gradient[idx])
if len(shap_array.shape) == 4:
shap_img = shap_array[0, :, :, pred_class]
else:
shap_img = shap_array[:, :, pred_class]
# Original
axes[idx, 0].imshow(image, cmap='gray')
axes[idx, 0].set_title(f'Original\nTrue: {true_class}, Pred: {pred_class}')
axes[idx, 0].axis('off')
# Positive attribution
axes[idx, 1].imshow(image, cmap='gray', alpha=0.5)
im1 = axes[idx, 1].imshow(shap_img, cmap='Reds', alpha=0.6)
axes[idx, 1].set_title('Positive Attribution')
axes[idx, 1].axis('off')
plt.colorbar(im1, ax=axes[idx, 1], fraction=0.046)
# All attribution
axes[idx, 2].imshow(image, cmap='gray', alpha=0.3)
im2 = axes[idx, 2].imshow(shap_img, cmap='seismic', alpha=0.7,
vmin=-np.abs(shap_img).max(),
vmax=np.abs(shap_img).max())
axes[idx, 2].set_title('All Attribution\n(Red: +, Blue: -)')
axes[idx, 2].axis('off')
plt.colorbar(im2, ax=axes[idx, 2], fraction=0.046)
plt.suptitle('SHAP GradientExplainer', fontsize=16, y=0.99)
plt.tight_layout()
plt.savefig(output_dir / 'shap_gradient_explanations.png', dpi=150, bbox_inches='tight')
plt.show()
print("✓ Saved GradientExplainer visualizations")
✓ Saved GradientExplainer visualizations
8. SHAP Method 2: DeepExplainer¶
DeepExplainer uses the DeepLIFT algorithm for more accurate attributions.
In [9]:
print("Generating SHAP explanations using DeepExplainer...")
model.eval()
explainer = shap.DeepExplainer(model, background_data)
shap_values_deep = explainer.shap_values(test_data, check_additivity=False)
print("✓ SHAP values computed!")
Generating SHAP explanations using DeepExplainer... ✓ SHAP values computed!
Visualize DeepExplainer Results¶
In [10]:
fig, axes = plt.subplots(3, 3, figsize=(12, 12))
for idx in range(3):
image = test_samples[idx].squeeze().cpu().numpy()
pred_class = pred_labels[idx]
true_class = test_labels[idx]
# Extract SHAP values for predicted class
shap_array = np.array(shap_values_deep[idx])
if len(shap_array.shape) == 4:
shap_img = shap_array[0, :, :, pred_class]
else:
shap_img = shap_array[:, :, pred_class]
# Original
axes[idx, 0].imshow(image, cmap='gray')
axes[idx, 0].set_title(f'Original\nTrue: {true_class}, Pred: {pred_class}')
axes[idx, 0].axis('off')
# Positive attribution
axes[idx, 1].imshow(image, cmap='gray', alpha=0.5)
im1 = axes[idx, 1].imshow(shap_img, cmap='Reds', alpha=0.6)
axes[idx, 1].set_title('Positive Attribution')
axes[idx, 1].axis('off')
plt.colorbar(im1, ax=axes[idx, 1], fraction=0.046)
# All attribution
axes[idx, 2].imshow(image, cmap='gray', alpha=0.3)
im2 = axes[idx, 2].imshow(shap_img, cmap='seismic', alpha=0.7,
vmin=-np.abs(shap_img).max(),
vmax=np.abs(shap_img).max())
axes[idx, 2].set_title('All Attribution\n(Red: +, Blue: -)')
axes[idx, 2].axis('off')
plt.colorbar(im2, ax=axes[idx, 2], fraction=0.046)
plt.suptitle('SHAP DeepExplainer', fontsize=16, y=0.99)
plt.tight_layout()
plt.savefig(output_dir / 'shap_deep_explanations.png', dpi=150, bbox_inches='tight')
plt.show()
print("✓ Saved DeepExplainer visualizations")
✓ Saved DeepExplainer visualizations
9. Compare Both Methods¶
In [11]:
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
for idx in range(3):
image = test_samples[idx].squeeze().cpu().numpy()
pred_class = pred_labels[idx]
true_class = test_labels[idx]
# Original
axes[idx, 0].imshow(image, cmap='gray')
axes[idx, 0].set_title(f'Original\nTrue: {true_class}, Pred: {pred_class}')
axes[idx, 0].axis('off')
# GradientExplainer
shap_grad_array = np.array(shap_values_gradient[idx])
shap_grad = shap_grad_array[0, :, :, pred_class] if len(shap_grad_array.shape) == 4 else shap_grad_array[:, :, pred_class]
axes[idx, 1].imshow(image, cmap='gray', alpha=0.3)
im1 = axes[idx, 1].imshow(shap_grad, cmap='seismic', alpha=0.7,
vmin=-np.abs(shap_grad).max(),
vmax=np.abs(shap_grad).max())
axes[idx, 1].set_title('GradientExplainer')
axes[idx, 1].axis('off')
plt.colorbar(im1, ax=axes[idx, 1], fraction=0.046)
# DeepExplainer
shap_deep_array = np.array(shap_values_deep[idx])
shap_deep = shap_deep_array[0, :, :, pred_class] if len(shap_deep_array.shape) == 4 else shap_deep_array[:, :, pred_class]
axes[idx, 2].imshow(image, cmap='gray', alpha=0.3)
im2 = axes[idx, 2].imshow(shap_deep, cmap='seismic', alpha=0.7,
vmin=-np.abs(shap_deep).max(),
vmax=np.abs(shap_deep).max())
axes[idx, 2].set_title('DeepExplainer')
axes[idx, 2].axis('off')
plt.colorbar(im2, ax=axes[idx, 2], fraction=0.046)
plt.suptitle('Comparing SHAP Methods', fontsize=16, y=0.99)
plt.tight_layout()
plt.savefig(output_dir / 'shap_methods_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
print("✓ Saved comparison visualization")
✓ Saved comparison visualization
10. Summary¶
Key Takeaways:¶
- SHAP provides theoretically-grounded explanations based on Shapley values from game theory
- Additive property: SHAP values sum to prediction difference from baseline
- Red regions = positive contribution (increase prediction for target class)
- Blue regions = negative contribution (decrease prediction for target class)
- Two methods compared:
- GradientExplainer: Fast, uses gradients
- DeepExplainer: More accurate, uses DeepLIFT
When to Use SHAP:¶
- ✅ Need theoretically sound explanations
- ✅ Want consistent, reproducible attributions
- ✅ Need fast explanations (GradientExplainer)
- ✅ Comparing feature importance across instances
SHAP vs LIME:¶
| Aspect | SHAP | LIME |
|---|---|---|
| Theory | Game theory (Shapley values) | Local linear approximation |
| Speed | Fast (GradientExplainer) | Slow (~1000 samples) |
| Consistency | Yes (same input → same output) | No (stochastic) |
| Additivity | Yes (values sum to prediction) | No |
| Interpretation | Feature contributions | Feature importance |
Mathematical Properties:¶
SHAP satisfies three desirable properties:
- Local Accuracy: $f(x) = \phi_0 + \sum_{i=1}^M \phi_i$
- Missingness: $x_i = 0 \Rightarrow \phi_i = 0$
- Consistency: If model changes so feature contributes more, attribution shouldn't decrease
Next Steps:¶
- Try GradCAM for faster gradient-based explanations
- Experiment with different background distributions
- Use SHAP summary plots for global feature importance
- Compare with LIME to understand different perspectives
All visualizations saved to: ../results/shap/