File size: 5,355 Bytes
72fdabd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import logging
import shap
import numpy as np
import pandas as pd
from datetime import datetime

from models.artifacts_loader import get_artifacts
from schemas.explain import ExplanationResponse, SHAPFeature, FeatureImportanceResponse
from utils.plot_utils import (
    generate_shap_summary_plot_base64,
    plot_feature_importance_heatmap,
    plotly_to_base64,
)

logger = logging.getLogger(__name__)


class ExplainerService:
    @staticmethod
    def explain_shap(
        item: dict, top_n: int = 10, plot: bool = False
    ) -> ExplanationResponse:
        artifacts = get_artifacts()
        if not artifacts.model:
            raise RuntimeError("Model not loaded")

        try:
            # Prepare Input
            model = artifacts.model
            df = model._to_dataframe([item])
            df_norm = model._normalize_boq_schema(df)

            # The background data for SHAP is expected to be transformed data.
            # Explaining the inner classifier using transformed input.
            preprocessor = model.preprocessor
            calibrator = model.calibrator

            X_proc = preprocessor.transform(df_norm)
            
            # Predict to find the target class index
            try:
                pred_label = model.predict(df)[0]
                target_class_index = list(calibrator.classes_).index(pred_label)
            except Exception:
                pred_label = "Unknown"
                target_class_index = 0

            # 2. Compute SHAP
            # Using KernelExplainer because CalibratedClassifierCV doesn't support TreeExplainer natively easily
            if artifacts.shap_background is None:
                raise RuntimeError(
                    "SHAP background data is missing but required for KernelExplainer"
                )

            explainer = shap.KernelExplainer(
                calibrator.predict_proba, artifacts.shap_background
            )
            shap_values_all = explainer.shap_values(X_proc, nsamples=100)

            # Robustly extract values for the target class
            if isinstance(shap_values_all, list):
                if len(shap_values_all) > target_class_index:
                    shap_vals_target = shap_values_all[target_class_index]
                else:
                    shap_vals_target = shap_values_all[0]
            elif isinstance(shap_values_all, np.ndarray):
                if len(shap_values_all.shape) == 3 and shap_values_all.shape[2] > target_class_index:
                    shap_vals_target = shap_values_all[:, :, target_class_index]
                else:
                    shap_vals_target = shap_values_all
            else:
                shap_vals_target = shap_values_all

            if len(shap_vals_target.shape) == 2:
                sv = shap_vals_target[0]
            else:
                sv = shap_vals_target

            # 3. Features Names
            try:
                if hasattr(preprocessor, "get_feature_names_out"):
                    feature_names = preprocessor.get_feature_names_out()
                else:
                    feature_names = preprocessor.pipeline.get_feature_names_out()
            except Exception:
                feature_names = [f"fet_{i}" for i in range(len(sv))]

            # 4. Top Features
            abs_importance = np.abs(sv)
            top_indices = np.argsort(abs_importance)[-top_n:][::-1]

            top_features = [
                SHAPFeature(feature=str(feature_names[i]), value=float(sv[i]))
                for i in top_indices
            ]

            # 5. Plot
            plot_b64 = None
            if plot:
                plot_b64 = generate_shap_summary_plot_base64(
                    shap_vals_target, X_proc, feature_names=feature_names, target_class=pred_label
                )

            return ExplanationResponse(
                top_features=top_features,
                shap_plot_base64=plot_b64,
                metadata={
                    "method": "SHAP KernelExplainer",
                    "timestamp": datetime.utcnow().isoformat(),
                },
            )
        except Exception as e:
            logger.exception("Error during SHAP explanation")
            raise RuntimeError(f"Explanation failed: {e}")

    @staticmethod
    def explain_feature_importance(
        top_n: int = 30, skip_top: int = 0
    ) -> FeatureImportanceResponse:
        artifacts = get_artifacts()
        if not artifacts.model:
            raise RuntimeError("Model not loaded")

        try:
            fig, df_plot = plot_feature_importance_heatmap(
                model=artifacts.model,
                top_n=top_n,
                skip_top=skip_top,
                title="Global Feature Importance",
            )
            plot_base64 = plotly_to_base64(fig)

            return FeatureImportanceResponse(
                plot_base64=plot_base64,
                metadata={
                    "method": "Averaged Calibrated Feature Importances",
                    "timestamp": datetime.utcnow().isoformat(),
                    "top_n": top_n,
                    "skip_top": skip_top,
                },
            )
        except Exception as e:
            logger.exception("Error during feature importance generation")
            raise RuntimeError(f"Feature importance generation failed: {e}")