myanmar-ghost / xai /shap_explainer.py
amkyawdev's picture
Add source code
cfb5e7f verified
Raw
History Blame Contribute Delete
12.3 kB
"""SHAP explainer for Myanmar Ghost model.
Uses SHAP (SHapley Additive exPlanations) to explain
individual predictions and word importance.
"""
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import numpy as np
import shap
import torch
from tqdm import tqdm
logger = logging.getLogger(__name__)
class ThankingSHAPExplainer:
"""SHAP-based explainer for Myanmar text classification."""
def __init__(
self,
model,
tokenizer,
background_size: int = 100,
device: str = "cuda" if torch.cuda.is_available() else "cpu",
):
"""
Args:
model: PyTorch model or HuggingFace model
tokenizer: Tokenizer for the model
background_size: Number of background samples for SHAP
device: Device to run on
"""
self.model = model
self.tokenizer = tokenizer
self.background_size = background_size
self.device = device
self.model.to(device)
self.model.eval()
self.explainer = None
self.background_data = None
def _get_tokenizer(self):
"""Get the tokenizer, handling both HF and custom tokenizers."""
if hasattr(self.tokenizer, "__call__"):
return self.tokenizer
return self.tokenizer.encode
def _predict(self, texts: Union[List[str], np.ndarray]) -> np.ndarray:
"""Model prediction function for SHAP."""
if isinstance(texts, np.ndarray):
texts = texts.tolist()
# Tokenize
if hasattr(self.tokenizer, "batch_encode_plus"):
encoding = self.tokenizer.batch_encode_plus(
texts,
padding=True,
truncation=True,
max_length=128,
return_tensors="pt",
)
input_ids = encoding["input_ids"].to(self.device)
attention_mask = encoding["attention_mask"].to(self.device)
else:
input_ids = torch.tensor(
[self.tokenizer.encode(t) for t in texts]
).to(self.device)
attention_mask = (input_ids != 0).long().to(self.device)
with torch.no_grad():
outputs = self.model(input_ids, attention_mask)
if hasattr(outputs, "logits"):
logits = outputs.logits
else:
logits = outputs
probs = torch.softmax(logits, dim=-1).cpu().numpy()
return probs
def fit_background(
self,
background_texts: List[str],
) -> None:
"""Fit background distribution for SHAP.
Args:
background_texts: List of texts to use as background
"""
logger.info(f"Fitting SHAP background with {len(background_texts)} samples")
# Sample background if too large
if len(background_texts) > self.background_size:
indices = np.random.choice(
len(background_texts),
self.background_size,
replace=False,
)
background_texts = [background_texts[i] for i in indices]
self.background_data = background_texts
# Create SHAP explainer
self.explainer = shap.Explainer(
self._predict,
self.tokenizer,
output_names=["negative", "neutral", "positive", "sarcastic"],
)
# Calculate background values
logger.info("Computing SHAP values for background...")
self.explainer(background_texts[:min(10, len(background_texts))])
logger.info("Background fitting complete")
def explain(
self,
text: str,
num_samples: int = 100,
output_names: Optional[List[str]] = None,
) -> shap.Explanation:
"""Explain a single text.
Args:
text: Myanmar text to explain
num_samples: Number of Monte Carlo samples
output_names: Names for output classes
Returns:
SHAP Explanation object
"""
if self.explainer is None:
logger.warning("No background data. Using default explainer.")
self.explainer = shap.Explainer(
self._predict,
self.tokenizer,
output_names=output_names or ["negative", "neutral", "positive", "sarcastic"],
)
logger.info(f"Explaining text: {text[:50]}...")
explainer = shap.Explainer(
self._predict,
self.tokenizer,
output_names=output_names,
)
shap_values = explainer([text])
return shap_values
def explain_batch(
self,
texts: List[str],
output_names: Optional[List[str]] = None,
) -> List[shap.Explanation]:
"""Explain multiple texts.
Args:
texts: List of Myanmar texts
output_names: Names for output classes
Returns:
List of SHAP Explanation objects
"""
if output_names is None:
output_names = ["negative", "neutral", "positive", "sarcastic"]
explainer = shap.Explainer(
self._predict,
self.tokenizer,
output_names=output_names,
)
explanations = []
for text in tqdm(texts, desc="Explaining texts"):
exp = explainer([text])
explanations.append(exp)
return explanations
def get_word_importance(
self,
text: str,
class_index: int = 2, # positive by default
) -> List[Tuple[str, float]]:
"""Get word importance scores for a specific class.
Args:
text: Myanmar text
class_index: Class index to explain
Returns:
List of (word, importance) tuples
"""
explanation = self.explain(text)
# Get tokens and their SHAP values
tokens = self.tokenizer.tokenize(text)
shap_vals = explanation.values[0, :, class_index]
# Handle tokenization differences
if len(shap_vals) < len(tokens):
# Pad if needed
shap_vals = np.pad(
shap_vals,
(0, len(tokens) - len(shap_vals)),
constant_values=0,
)
elif len(shap_vals) > len(tokens):
tokens = tokens + ["[PAD]"] * (len(shap_vals) - len(tokens))
# Create word-score pairs
word_importance = list(zip(tokens, shap_vals.tolist()))
# Sort by absolute importance
word_importance.sort(key=lambda x: abs(x[1]), reverse=True)
return word_importance
def visualize_text(
self,
explanation: shap.Explanation,
output_path: Optional[str] = None,
) -> None:
"""Visualize SHAP explanation as text.
Args:
explanation: SHAP explanation
output_path: Optional path to save visualization
"""
text = explanation.data[0] if hasattr(explanation, "data") else ""
output = f"\n{'='*60}\n"
output += f"Text: {text}\n"
output += f"{'='*60}\n"
# Get top features for each class
for i, class_name in enumerate(explanation.output_names):
values = explanation.values[0, :, i]
# Get top 5 words
top_indices = np.argsort(np.abs(values))[-5:][::-1]
output += f"\nClass: {class_name}\n"
output += "-" * 40 + "\n"
for idx in top_indices:
if idx < len(text.split()):
word = text.split()[idx]
output += f" {word}: {values[idx]:.4f}\n"
print(output)
if output_path:
with open(output_path, "w", encoding="utf-8") as f:
f.write(output)
logger.info(f"Visualization saved to {output_path}")
class ThankingSHAPValues:
"""Compute SHAP values for thanking expression analysis."""
def __init__(
self,
model,
tokenizer,
class_names: List[str] = None,
):
self.model = model
self.tokenizer = tokenizer
self.class_names = class_names or [
"genuine", "sarcastic", "complaining", "neutral"
]
def compute_feature_importance(
self,
texts: List[str],
feature_names: List[str],
) -> Dict[str, float]:
"""Compute SHAP-based feature importance.
Args:
texts: List of texts
feature_names: Names of features to analyze
Returns:
Dictionary of feature importance scores
"""
import shap
def predict_proba(texts: List[str]) -> np.ndarray:
encoding = self.tokenizer.batch_encode_plus(
texts,
padding=True,
truncation=True,
max_length=128,
return_tensors="pt",
)
with torch.no_grad():
outputs = self.model(
encoding["input_ids"],
encoding["attention_mask"],
)
probs = torch.softmax(outputs.logits, dim=-1)
return probs.numpy()
# Create simple background
background = texts[:min(20, len(texts))]
explainer = shap.Explainer(predict_proba, background)
shap_values = explainer(texts[:5]) # Sample for speed
# Aggregate importance
importance = {}
for i, feature in enumerate(feature_names):
importance[feature] = np.mean(
np.abs(shap_values.values[:, :, i])
)
return importance
def analyze_sentence(
self,
text: str,
) -> Dict[str, Any]:
"""Analyze a single sentence with SHAP.
Args:
text: Myanmar text
Returns:
Analysis results including word importance
"""
import shap
encoding = self.tokenizer(
text,
padding=True,
truncation=True,
max_length=128,
return_tensors="pt",
)
explainer = shap.Explainer(
lambda x: self._predict_batch(x),
self.tokenizer,
)
explanation = explainer([text])
# Extract word-level importance
tokens = self.tokenizer.convert_ids_to_tokens(
encoding["input_ids"][0]
)
result = {
"text": text,
"tokens": tokens,
"prediction": explanation.output_names[
np.argmax(explanation.values[0].mean(axis=1))
],
"shap_values": explanation.values[0].tolist(),
}
return result
def _predict_batch(self, texts: List[str]) -> np.ndarray:
"""Batch prediction for SHAP."""
encoding = self.tokenizer(
texts,
padding=True,
truncation=True,
max_length=128,
return_tensors="pt",
)
with torch.no_grad():
outputs = self.model(
encoding["input_ids"],
encoding["attention_mask"],
)
probs = torch.softmax(outputs.logits, dim=-1)
return probs.numpy()
def create_shap_explainer(
model,
tokenizer,
background_texts: Optional[List[str]] = None,
) -> ThankingSHAPExplainer:
"""Factory function to create SHAP explainer."""
explainer = ThankingSHAPExplainer(model, tokenizer)
if background_texts:
explainer.fit_background(background_texts)
return explainer
if __name__ == "__main__":
print("ThankingSHAPExplainer loaded")
print("Use create_shap_explainer() to create an explainer")