Spaces:
Running
Running
File size: 23,684 Bytes
09801ca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 | """
🔮 EXPLAINABILITY ENGINE v1.0 - SHAP/LIME PREDICTION EXPLANATIONS
==================================================================
Explains WHY each prediction was made - essential for trust and debugging.
Core Capabilities:
1. SHAP Integration - Game-theoretic feature importance
2. LIME Integration - Local interpretable explanations
3. Feature Attribution - Shows contribution of each feature
4. Counterfactual Analysis - "What if" scenarios
5. Prediction Confidence - Uncertainty quantification
6. Global Explanations - Overall model behavior
This makes predictions TRUSTWORTHY and DEBUGGABLE!
"""
import numpy as np
import pandas as pd
from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass
import logging
import warnings
warnings.filterwarnings('ignore')
logger = logging.getLogger(__name__)
# Optional SHAP import
try:
import shap
HAS_SHAP = True
except ImportError:
HAS_SHAP = False
logger.warning("SHAP not installed - some explainability features disabled")
# Optional LIME import
try:
from lime import lime_tabular
HAS_LIME = True
except ImportError:
HAS_LIME = False
logger.warning("LIME not installed - some explainability features disabled")
@dataclass
class PredictionExplanation:
"""Explanation for a single prediction."""
prediction: Any
confidence: float
probability: Optional[float]
class_probabilities: Optional[Dict[str, float]]
# Feature contributions
top_positive_features: List[Dict[str, Any]] # Features pushing toward prediction
top_negative_features: List[Dict[str, Any]] # Features pushing against prediction
# All feature attributions
feature_attributions: Dict[str, float]
# Counterfactual (optional)
counterfactual_suggestion: Optional[str]
# Metadata
explanation_method: str
computation_time_ms: float
@dataclass
class GlobalExplanation:
"""Global explanation of model behavior."""
feature_importance: Dict[str, float] # Average importance across all predictions
feature_interactions: List[Dict[str, Any]] # Top feature interactions
summary_plot_data: Optional[Any] # Data for SHAP summary plot
dependence_data: Optional[Dict[str, Any]] # Feature dependence data
class ExplainabilityEngine:
"""
🔮 Explainability Engine - SHAP/LIME Prediction Explanations
Provides comprehensive explanations for model predictions:
1. Local explanations - Why was THIS specific prediction made?
2. Global explanations - How does the model behave overall?
3. Feature importance - Which features matter most?
4. Counterfactuals - What would change the prediction?
Makes ML models TRANSPARENT and TRUSTWORTHY!
"""
def __init__(
self,
model: Any = None,
feature_names: List[str] = None,
task_type: str = 'classification',
class_names: List[str] = None,
background_data: np.ndarray = None,
use_shap: bool = True,
use_lime: bool = True,
n_background_samples: int = 100
):
self.model = model
self.feature_names = feature_names or []
self.task_type = task_type
self.class_names = class_names or []
self.background_data = background_data
self.use_shap = use_shap and HAS_SHAP
self.use_lime = use_lime and HAS_LIME
self.n_background_samples = n_background_samples
# SHAP explainer (lazy initialization)
self._shap_explainer = None
# LIME explainer
self._lime_explainer = None
# Cache for efficiency
self._global_shap_values = None
logger.info(f"🔮 Explainability Engine initialized (SHAP: {self.use_shap}, LIME: {self.use_lime})")
def set_model(
self,
model: Any,
feature_names: List[str] = None,
background_data: np.ndarray = None,
class_names: List[str] = None
) -> None:
"""Set or update the model to explain."""
self.model = model
if feature_names:
self.feature_names = feature_names
if background_data is not None:
self.background_data = background_data
if class_names:
self.class_names = class_names
# Reset explainers
self._shap_explainer = None
self._lime_explainer = None
self._global_shap_values = None
def _get_shap_explainer(self) -> Any:
"""Get or create SHAP explainer."""
if not self.use_shap:
return None
if self._shap_explainer is not None:
return self._shap_explainer
if self.model is None:
raise ValueError("Model not set. Call set_model first.")
try:
# Prepare background data
if self.background_data is not None:
# Sample if too large
if len(self.background_data) > self.n_background_samples:
indices = np.random.choice(
len(self.background_data),
self.n_background_samples,
replace=False
)
background = self.background_data[indices]
else:
background = self.background_data
else:
background = None
# Determine model type and create appropriate explainer
model_type = type(self.model).__name__
if 'XGB' in model_type or 'LGB' in model_type or 'CatBoost' in model_type:
# Tree-based models - use TreeExplainer
self._shap_explainer = shap.TreeExplainer(self.model)
elif 'Forest' in model_type or 'Tree' in model_type or 'Gradient' in model_type:
# Sklearn tree-based models
self._shap_explainer = shap.TreeExplainer(self.model)
elif hasattr(self.model, 'predict_proba'):
# Classification models with predict_proba
if background is not None:
self._shap_explainer = shap.KernelExplainer(
self.model.predict_proba,
background
)
else:
logger.warning("Background data required for KernelExplainer")
return None
else:
# Generic models
if background is not None:
predict_fn = getattr(self.model, 'predict', None)
if predict_fn:
self._shap_explainer = shap.KernelExplainer(
predict_fn,
background
)
else:
logger.warning("Background data required for KernelExplainer")
return None
return self._shap_explainer
except Exception as e:
logger.error(f"Could not create SHAP explainer: {e}")
return None
def _get_lime_explainer(self) -> Any:
"""Get or create LIME explainer."""
if not self.use_lime:
return None
if self._lime_explainer is not None:
return self._lime_explainer
if self.background_data is None:
logger.warning("Background data required for LIME explainer")
return None
try:
mode = 'classification' if self.task_type == 'classification' else 'regression'
self._lime_explainer = lime_tabular.LimeTabularExplainer(
self.background_data.astype(float),
feature_names=self.feature_names if self.feature_names else None,
class_names=self.class_names if self.class_names else None,
mode=mode,
discretize_continuous=True,
random_state=42
)
return self._lime_explainer
except Exception as e:
logger.error(f"Could not create LIME explainer: {e}")
return None
def explain_prediction(
self,
instance: Union[np.ndarray, pd.DataFrame, Dict],
n_top_features: int = 5,
method: str = 'auto'
) -> PredictionExplanation:
"""
Explain a single prediction.
Args:
instance: Single data point to explain
n_top_features: Number of top features to highlight
method: 'shap', 'lime', or 'auto' (tries SHAP first, then LIME)
Returns:
PredictionExplanation with feature attributions and insights
"""
import time
start_time = time.time()
# Convert to numpy array
if isinstance(instance, dict):
instance = np.array([[instance.get(f, 0) for f in self.feature_names]])
elif isinstance(instance, pd.DataFrame):
instance = instance.values
elif isinstance(instance, np.ndarray) and instance.ndim == 1:
instance = instance.reshape(1, -1)
instance = np.nan_to_num(instance.astype(float), nan=0.0, posinf=0.0, neginf=0.0)
# Get prediction
prediction = self.model.predict(instance)[0]
# Get probability/confidence
probability = None
class_probabilities = None
confidence = 0.5
if hasattr(self.model, 'predict_proba'):
try:
proba = self.model.predict_proba(instance)[0]
if self.task_type == 'classification':
confidence = float(np.max(proba))
probability = confidence
# Map probabilities to class names
if self.class_names:
class_probabilities = {
str(name): float(p) for name, p in zip(self.class_names, proba)
}
else:
class_probabilities = {
f"class_{i}": float(p) for i, p in enumerate(proba)
}
except:
pass
else:
# For regression, confidence based on prediction stability
confidence = 0.7 # Default confidence for regression
# Get feature attributions
feature_attributions = {}
explanation_method = 'none'
# Try SHAP first (if available and method allows)
if method in ['auto', 'shap'] and self.use_shap:
try:
explainer = self._get_shap_explainer()
if explainer:
shap_values = explainer.shap_values(instance)
# Handle different SHAP output formats
if isinstance(shap_values, list):
# Multi-class classification
if self.task_type == 'classification':
pred_class = int(prediction) if isinstance(prediction, (int, np.integer)) else 0
if pred_class < len(shap_values):
values = shap_values[pred_class][0]
else:
values = shap_values[0][0]
else:
values = shap_values[0][0] if len(shap_values) > 0 else shap_values[0]
else:
values = shap_values[0] if shap_values.ndim > 1 else shap_values
# Create feature attributions
for i, (fname, val) in enumerate(zip(self.feature_names[:len(values)], values)):
feature_attributions[fname] = float(val)
explanation_method = 'shap'
except Exception as e:
logger.warning(f"SHAP explanation failed: {e}")
# Try LIME if SHAP failed or method is LIME
if not feature_attributions and method in ['auto', 'lime'] and self.use_lime:
try:
explainer = self._get_lime_explainer()
if explainer:
if self.task_type == 'classification':
exp = explainer.explain_instance(
instance[0],
self.model.predict_proba,
num_features=len(self.feature_names)
)
else:
exp = explainer.explain_instance(
instance[0],
self.model.predict,
num_features=len(self.feature_names)
)
for feature, weight in exp.as_list():
# LIME returns feature conditions, extract feature name
fname = feature.split(' ')[0].strip('<>=')
if fname in self.feature_names:
feature_attributions[fname] = float(weight)
explanation_method = 'lime'
except Exception as e:
logger.warning(f"LIME explanation failed: {e}")
# Fallback to basic feature importance if no explanations available
if not feature_attributions:
if hasattr(self.model, 'feature_importances_'):
importances = self.model.feature_importances_
for i, fname in enumerate(self.feature_names[:len(importances)]):
# Scale by feature value for this instance
feature_attributions[fname] = float(importances[i] * abs(instance[0][i]))
explanation_method = 'feature_importance'
else:
# No explanation available
for fname in self.feature_names:
feature_attributions[fname] = 0.0
explanation_method = 'none'
# Sort features by attribution
sorted_attrs = sorted(feature_attributions.items(), key=lambda x: abs(x[1]), reverse=True)
# Split into positive and negative contributions
top_positive = [
{
'feature': fname,
'attribution': attr,
'value': float(instance[0][self.feature_names.index(fname)]) if fname in self.feature_names else 0.0,
'direction': 'positive'
}
for fname, attr in sorted_attrs if attr > 0
][:n_top_features]
top_negative = [
{
'feature': fname,
'attribution': attr,
'value': float(instance[0][self.feature_names.index(fname)]) if fname in self.feature_names else 0.0,
'direction': 'negative'
}
for fname, attr in sorted_attrs if attr < 0
][:n_top_features]
# Generate counterfactual suggestion
counterfactual = None
if top_positive:
top_feat = top_positive[0]
if self.task_type == 'classification':
counterfactual = f"Prediction might change if '{top_feat['feature']}' was significantly different"
computation_time = (time.time() - start_time) * 1000
return PredictionExplanation(
prediction=prediction,
confidence=confidence,
probability=probability,
class_probabilities=class_probabilities,
top_positive_features=top_positive,
top_negative_features=top_negative,
feature_attributions=feature_attributions,
counterfactual_suggestion=counterfactual,
explanation_method=explanation_method,
computation_time_ms=computation_time
)
def explain_batch(
self,
instances: np.ndarray,
n_top_features: int = 5
) -> List[PredictionExplanation]:
"""Explain multiple predictions."""
return [
self.explain_prediction(instances[i:i+1], n_top_features)
for i in range(len(instances))
]
def get_global_explanation(
self,
X: np.ndarray = None,
max_samples: int = 500
) -> GlobalExplanation:
"""
Get global model explanation showing overall feature importance.
Args:
X: Data to use for explanation (uses background_data if None)
max_samples: Maximum samples to use
Returns:
GlobalExplanation with aggregated feature importance
"""
if X is None:
X = self.background_data
if X is None:
# Return model-based importance if available
if hasattr(self.model, 'feature_importances_'):
importances = self.model.feature_importances_
feature_importance = {
fname: float(imp)
for fname, imp in zip(self.feature_names[:len(importances)], importances)
}
return GlobalExplanation(
feature_importance=feature_importance,
feature_interactions=[],
summary_plot_data=None,
dependence_data=None
)
else:
return GlobalExplanation(
feature_importance={f: 0.0 for f in self.feature_names},
feature_interactions=[],
summary_plot_data=None,
dependence_data=None
)
# Sample data if too large
if len(X) > max_samples:
indices = np.random.choice(len(X), max_samples, replace=False)
X = X[indices]
X = np.nan_to_num(X.astype(float), nan=0.0, posinf=0.0, neginf=0.0)
feature_importance = {}
# Try SHAP for global explanation
if self.use_shap:
try:
explainer = self._get_shap_explainer()
if explainer:
shap_values = explainer.shap_values(X)
# Handle different formats
if isinstance(shap_values, list):
# Average across classes
values = np.mean([np.abs(sv) for sv in shap_values], axis=0)
else:
values = np.abs(shap_values)
# Average importance across samples
mean_importance = np.mean(values, axis=0)
for fname, imp in zip(self.feature_names[:len(mean_importance)], mean_importance):
feature_importance[fname] = float(imp)
self._global_shap_values = shap_values
except Exception as e:
logger.warning(f"SHAP global explanation failed: {e}")
# Fallback to model importance
if not feature_importance:
if hasattr(self.model, 'feature_importances_'):
importances = self.model.feature_importances_
for fname, imp in zip(self.feature_names[:len(importances)], importances):
feature_importance[fname] = float(imp)
elif hasattr(self.model, 'coef_'):
coef = np.abs(self.model.coef_).flatten()
for fname, c in zip(self.feature_names[:len(coef)], coef):
feature_importance[fname] = float(c)
# Sort by importance
feature_importance = dict(sorted(
feature_importance.items(),
key=lambda x: x[1],
reverse=True
))
return GlobalExplanation(
feature_importance=feature_importance,
feature_interactions=[],
summary_plot_data=self._global_shap_values,
dependence_data=None
)
def explain_to_text(self, explanation: PredictionExplanation) -> str:
"""Convert explanation to human-readable text."""
lines = []
lines.append(f"🔮 Prediction: {explanation.prediction}")
lines.append(f"📊 Confidence: {explanation.confidence:.1%}")
if explanation.class_probabilities:
lines.append("\n📈 Class Probabilities:")
for cls, prob in explanation.class_probabilities.items():
lines.append(f" • {cls}: {prob:.1%}")
lines.append("\n✅ Top factors supporting this prediction:")
for feat in explanation.top_positive_features[:3]:
lines.append(f" • {feat['feature']}: +{feat['attribution']:.4f} (value: {feat['value']:.2f})")
if explanation.top_negative_features:
lines.append("\n❌ Factors pushing against this prediction:")
for feat in explanation.top_negative_features[:3]:
lines.append(f" • {feat['feature']}: {feat['attribution']:.4f} (value: {feat['value']:.2f})")
if explanation.counterfactual_suggestion:
lines.append(f"\n💡 Insight: {explanation.counterfactual_suggestion}")
lines.append(f"\n⏱️ Computed in {explanation.computation_time_ms:.1f}ms using {explanation.explanation_method}")
return "\n".join(lines)
def explain_to_dict(self, explanation: PredictionExplanation) -> Dict:
"""Convert explanation to dictionary for API response."""
return {
'prediction': str(explanation.prediction),
'confidence': explanation.confidence,
'probability': explanation.probability,
'class_probabilities': explanation.class_probabilities,
'top_positive_factors': explanation.top_positive_features[:5],
'top_negative_factors': explanation.top_negative_features[:5],
'all_attributions': explanation.feature_attributions,
'counterfactual': explanation.counterfactual_suggestion,
'method': explanation.explanation_method,
'computation_time_ms': explanation.computation_time_ms
}
# =============================================================================
# CONVENIENCE FUNCTION
# =============================================================================
def explain_model_prediction(
model: Any,
instance: np.ndarray,
feature_names: List[str],
background_data: np.ndarray = None,
task_type: str = 'classification',
class_names: List[str] = None
) -> Dict:
"""
Convenience function to explain a single prediction.
Args:
model: Trained model
instance: Data point to explain
feature_names: List of feature names
background_data: Training data for SHAP/LIME
task_type: 'classification' or 'regression'
class_names: Names of target classes
Returns:
Dictionary with explanation details
"""
engine = ExplainabilityEngine(
model=model,
feature_names=feature_names,
task_type=task_type,
class_names=class_names,
background_data=background_data
)
explanation = engine.explain_prediction(instance)
return engine.explain_to_dict(explanation)
|