Spaces:
Sleeping
Sleeping
File size: 8,014 Bytes
a01f5b3 | 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 | """Phase 10: SHAP explainability helpers."""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
try:
import shap
except ImportError as exc: # pragma: no cover - surfaced at app startup
shap = None
_SHAP_IMPORT_ERROR = exc
else:
_SHAP_IMPORT_ERROR = None
FEATURE_LABELS: dict[str, str] = {
"amount": "Transaction amount",
"use_chip": "Chip usage",
"merchant_city": "Merchant city",
"merchant_state": "Merchant state",
"mcc": "Merchant category code",
"errors": "Input errors",
"current_age": "Current age",
"retirement_age": "Retirement age",
"birth_year": "Birth year",
"birth_month": "Birth month",
"credit_score": "Credit score",
"transaction_velocity": "Transaction velocity",
"transaction_gap_from_first_day": "Gap from first transaction",
"user_tx_frequency": "User transaction frequency",
"user_active_day_index": "User active day index",
"amount_deviation": "Amount deviation",
"rolling_mean_amount": "Rolling mean amount",
"rolling_std_amount": "Rolling std amount",
"transaction_history_length": "Transaction history length",
"is_new_user": "New user indicator",
"card_to_history_ratio": "Card-to-history ratio",
"high_card_velocity_flag": "High card velocity flag",
"merchant_fraud_rate": "Merchant fraud rate",
"merchant_tx_count": "Merchant transaction count",
"merchant_avg_amount": "Merchant average amount",
"merchant_std_amount": "Merchant amount dispersion",
"merchant_risk_score": "Merchant risk score",
"merchant_outlier_score": "Merchant outlier score",
"geo_cluster_fraud_rate": "Geo cluster fraud rate",
"peer_cluster_fraud_rate": "Peer cluster fraud rate",
"cluster_avg_amount": "Cluster average amount",
"cluster_std_amount": "Cluster amount dispersion",
"cluster_outlier_score": "Cluster outlier score",
"anomaly_score": "Anomaly score",
"card_on_dark_web": "Card seen on dark web",
"cvv": "CVV",
"expires": "Card expiry",
"card_number": "Card number",
"has_chip": "Chip availability",
"num_cards_issued": "Cards issued to user",
"credit_limit": "Credit limit",
}
CATEGORY_RULES: dict[str, set[str]] = {
"account_takeover": {
"high_card_velocity_flag",
"transaction_velocity",
"user_tx_frequency",
"transaction_gap_from_first_day",
"rolling_mean_amount",
"rolling_std_amount",
},
"synthetic_identity": {
"is_new_user",
"transaction_history_length",
"card_to_history_ratio",
"num_cards_issued",
"credit_score",
"birth_year",
"birth_month",
"current_age",
},
"merchant_risk": {
"merchant_risk_score",
"merchant_fraud_rate",
"merchant_outlier_score",
"merchant_tx_count",
"merchant_avg_amount",
"merchant_std_amount",
"mcc",
},
"geo_anomaly": {
"geo_cluster_fraud_rate",
"peer_cluster_fraud_rate",
"cluster_outlier_score",
"merchant_city",
"merchant_state",
},
"card_testing": {
"card_on_dark_web",
"cvv",
"expires",
"card_number",
"use_chip",
"has_chip",
"amount",
"errors",
},
}
def build_explainer(model: Any, feature_columns: list[str] | None = None) -> Any:
if shap is None: # pragma: no cover - dependency issue should fail fast at startup
raise RuntimeError("shap is required for explainability") from _SHAP_IMPORT_ERROR
if feature_columns is None:
inferred = getattr(model, "feature_names_in_", None)
if inferred is None:
inferred = getattr(model, "feature_names", None)
feature_columns = list(inferred) if inferred is not None else []
try:
if hasattr(model, "set_params"):
try:
model.set_params(base_score=0.5)
except Exception:
pass
return shap.TreeExplainer(model)
except Exception:
background = pd.DataFrame(
np.zeros((1, len(feature_columns)), dtype=np.float32),
columns=feature_columns,
)
return shap.Explainer(model.predict_proba, background)
def _feature_label(feature_name: str) -> str:
return FEATURE_LABELS.get(feature_name, feature_name.replace("_", " ").title())
def _as_float(value: Any) -> float | None:
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError:
pass
try:
return float(value)
except (TypeError, ValueError):
return None
def _extract_shap_values(explanation: Any) -> tuple[np.ndarray, float]:
values = getattr(explanation, "values", explanation)
base_values = getattr(explanation, "base_values", 0.0)
if isinstance(values, list):
values = values[-1]
values_array = np.asarray(values)
if values_array.ndim == 3:
values_array = values_array[0, :, -1]
elif values_array.ndim == 2:
values_array = values_array[0]
else:
values_array = values_array.reshape(-1)
base_array = np.asarray(base_values).reshape(-1)
base_value = float(base_array[-1] if base_array.size else 0.0)
return values_array.astype(float), base_value
def _build_signal(feature: str, shap_value: float, value: Any) -> dict[str, Any]:
impact = "increased risk" if shap_value > 0 else "reduced risk"
return {
"feature": feature,
"label": _feature_label(feature),
"value": _as_float(value),
"shap_value": round(float(shap_value), 6),
"direction": impact,
}
def _category_scores(signal_map: dict[str, float]) -> dict[str, float]:
raw_scores: dict[str, float] = {}
for category, feature_names in CATEGORY_RULES.items():
raw_scores[category] = float(
sum(max(signal_map.get(feature_name, 0.0), 0.0) for feature_name in feature_names)
)
total = sum(raw_scores.values())
if total <= 0:
return {name: 0.0 for name in raw_scores}
return {name: round(score / total, 6) for name, score in raw_scores.items()}
def _fraud_type(category_scores: dict[str, float], classification: str) -> tuple[str, float]:
if classification == "legitimate":
return "legitimate", 1.0
if not category_scores:
return "general_fraud", 0.0
category, score = max(category_scores.items(), key=lambda item: item[1])
if score <= 0:
return "general_fraud", 0.0
return category, round(float(score), 6)
def explain_prediction(
*,
explainer: Any,
features_df: pd.DataFrame,
feature_columns: list[str],
aligned_features: dict[str, float | None],
classification: str,
fraud_probability: float,
top_k: int = 5,
) -> dict[str, Any]:
explanation = explainer(features_df)
shap_values, base_value = _extract_shap_values(explanation)
signal_rows = [
_build_signal(feature, shap_values[index], aligned_features.get(feature))
for index, feature in enumerate(feature_columns)
]
positive_signals = sorted(
(signal for signal in signal_rows if signal["shap_value"] > 0),
key=lambda item: abs(item["shap_value"]),
reverse=True,
)[:top_k]
negative_signals = sorted(
(signal for signal in signal_rows if signal["shap_value"] < 0),
key=lambda item: abs(item["shap_value"]),
reverse=True,
)[:top_k]
signal_map = {signal["feature"]: signal["shap_value"] for signal in signal_rows}
category_scores = _category_scores(signal_map)
fraud_type, fraud_type_confidence = _fraud_type(category_scores, classification)
if classification == "legitimate":
reason_source = negative_signals[0] if negative_signals else None
explanation_summary = (
f"The model leaned legitimate because {reason_source['label'].lower()} lowered the risk most."
if reason_source
else "The model leaned legitimate because no strong risk-driving signals dominated the baseline."
)
else:
reason_source = positive_signals[0] if positive_signals else None
explanation_summary = (
f"The model leaned {classification} because {reason_source['label'].lower()} raised the risk most."
if reason_source
else f"The model leaned {classification} because the combined signal pattern exceeded the fraud threshold."
)
return {
"base_value": round(float(base_value), 6),
"fraud_type": fraud_type,
"fraud_type_confidence": fraud_type_confidence,
"fraud_probability": round(float(fraud_probability), 6),
"explanation_summary": explanation_summary,
"category_scores": category_scores,
"top_positive_signals": positive_signals,
"top_negative_signals": negative_signals,
}
|