Gradient-based methods require embedding-level inputs
BERT token IDs are discrete integers — gradients cannot flow through them.
For gradient-based explainers (Saliency, IntegratedGradients, etc.) we pass the continuous embedding tensor as input. For perturbation-based explainers (FeatureAblation, LIME, etc.) we operate on embeddings as well, using a word-level feature_mask to score whole words.
Setup
import time
import torch
from transformers import BertForSequenceClassification, BertTokenizerFast
from torchxai.data_types import SingleTargetAcrossBatch
tokenizer = BertTokenizerFast.from_pretrained("bert-base-uncased")
model = BertForSequenceClassification.from_pretrained(
"bert-base-uncased", num_labels=2
).eval().cuda()
text = "This movie was absolutely fantastic, but the subplot felt unexpectedly overdramatized."
enc = tokenizer(text, return_tensors="pt")
input_ids = enc["input_ids"].cuda() # (1, seq_len)
attention_mask = enc["attention_mask"].cuda() # (1, seq_len)
# Embeddings — used as input for all explainers
embeddings = model.bert.embeddings(input_ids) # (1, seq_len, 768)
baseline_emb = torch.zeros_like(embeddings)
class EmbeddingSeqCls(torch.nn.Module):
def forward(self, emb):
return model(inputs_embeds=emb, attention_mask=attention_mask).logits
embed_model = EmbeddingSeqCls().eval().cuda()
# 2 output classes (positive / negative)
targets = [SingleTargetAcrossBatch(index=i) for i in range(2)]
def compare(explainer_cls, model, explain_kwargs, targets, atol=1e-5, **init_kwargs):
"""Compare sequential single-target calls vs one multi-target call.
Verifies results match and reports timing and attribution shape.
Pass atol>1e-5 for stochastic methods (e.g. GradientShap).
"""
explainer = explainer_cls(model, multi_target=False, **init_kwargs)
t0 = time.perf_counter()
attrs = [explainer.explain(**explain_kwargs, target=t) for t in targets]
elapsed_single = time.perf_counter() - t0
attrs_tensor = torch.stack(attrs)
explainer_mt = explainer_cls(model, multi_target=True, **init_kwargs)
t0 = time.perf_counter()
attrs_mt = explainer_mt.explain(**explain_kwargs, target=targets)
elapsed_mt = time.perf_counter() - t0
attrs_mt_tensor = torch.stack(attrs_mt)
assert torch.allclose(attrs_tensor, attrs_mt_tensor, atol=atol), (
f"Results differ between single-target and multi-target, max diff: {(attrs_tensor - attrs_mt_tensor).abs().max().item():.3e}"
)
speedup = elapsed_single / elapsed_mt if elapsed_mt > 0 else float("inf")
print(f"shape : {attrs_mt_tensor.shape}")
print(
f"single : {elapsed_single:.3f}s | multi : {elapsed_mt:.3f}s | speedup : {speedup:.1f}x"
)
return attrs_mt_tensor
Pattern A — inputs + target
No baseline or mask required. Applies to: SaliencyExplainer, InputXGradientExplainer.
from torchxai.explainers import SaliencyExplainer
# Replace with InputXGradientExplainer as needed
result = compare(SaliencyExplainer, embed_model, {"inputs": embeddings}, targets)
print(f"Output shape={result.shape}, mean={result.mean().item():.3f}, std={result.std().item():.3f}, min={result.min().item():.3f}, max={result.max().item():.3f}")
Pattern B — inputs + baseline + target
A single reference tensor (same shape as inputs) is required. Applies to: IntegratedGradientsExplainer, InputXBaselineGradientExplainer.
from torchxai.explainers import IntegratedGradientsExplainer
# Replace with InputXBaselineGradientExplainer as needed
result = compare(IntegratedGradientsExplainer, embed_model,
{"inputs": embeddings, "baselines": baseline_emb}, targets)
print(f"Output shape={result.shape}, mean={result.mean().item():.3f}, std={result.std().item():.3f}, min={result.min().item():.3f}, max={result.max().item():.3f}")
Pattern C — inputs + baseline distribution + target
baselines is a stacked set of reference samples rather than a single tensor. Applies to: GradientShapExplainer.
Note
GradientShap has internal non-determinism due to random sampling. Use n_samples=200 and a higher atol for reliable comparison.
from torchxai.explainers import GradientShapExplainer
baselines_dist = baseline_emb.expand(5, -1, -1) # (5, seq_len, 768) reference distribution
result = compare(GradientShapExplainer, embed_model,
{"inputs": embeddings, "baselines": baselines_dist}, targets, n_samples=200, atol=0.1)
print(f"Output shape={result.shape}, mean={result.mean().item():.3f}, std={result.std().item():.3f}, min={result.min().item():.3f}, max={result.max().item():.3f}")
Pattern D — inputs + feature_mask + target
A word-level feature_mask groups subword tokens so the explainer scores whole words rather than individual tokens. Applies to: FeatureAblationExplainer, LimeExplainer, KernelShapExplainer.
from torchxai.explainers import FeatureAblationExplainer
# Replace with LimeExplainer or KernelShapExplainer as needed
# Word-level feature mask: subword tokens of the same word share an ID
word_ids = enc.word_ids(batch_index=0) # e.g. [None, 0, 1, 1, 2, None]
seq_len = input_ids.shape[1]
ids = input_ids[0].tolist()
tokens = tokenizer.convert_ids_to_tokens(input_ids[0])
# Shape (1, seq_len, 1): broadcast across 768 embedding dims
feature_mask = torch.zeros(1, seq_len, 1, dtype=torch.long, device=input_ids.device)
for t_idx, w_idx in enumerate(word_ids):
if w_idx is not None:
feature_mask[0, t_idx, 0] = w_idx + 1 # 0 reserved for special tokens
print("Token debug view:")
print(f"{'idx':>3} {'id':>6} {'word_id':>7} {'fmask':>5} token")
print("-" * 46)
feature_mask_ids = feature_mask[0, :, 0].tolist()
for i, (tid, tok, wid, fmask) in enumerate(
zip(ids, tokens, word_ids, feature_mask_ids, strict=True)
):
print(f"{i:>3} {tid:>6} {str(wid):>7} {fmask:>5} {tok}")
print("\nWith word-level feature mask:")
result = compare(FeatureAblationExplainer, embed_model,
{"inputs": embeddings, "feature_mask": feature_mask}, targets)
print(f"Output shape={result.shape}, mean={result.mean().item():.3f}, std={result.std().item():.3f}, min={result.min().item():.3f}, max={result.max().item():.3f}")
Pattern E — inputs + sliding_window_shapes + target
OcclusionExplainer patches out rectangular windows of the embedding sequence. The sliding_window_shapes tuple (tokens, dims) specifies the window size.
from torchxai.explainers import OcclusionExplainer
result = compare(OcclusionExplainer, embed_model,
{"inputs": embeddings, "sliding_window_shapes": (4, 768), "strides": (2, 768)}, targets)
print(f"Output shape={result.shape}, mean={result.mean().item():.3f}, std={result.std().item():.3f}, min={result.min().item():.3f}, max={result.max().item():.3f}")