Repository: mindsdb/lightwood

Issue:
Bug in SHAP analysis block
Trying out this module, the following error arises with the cloud tutorial for home rentals:

```python
  File "/tmp/e74830c1ef5914dfafe52537b8b33cd0479265f508bfd6c616614666305205822.py", line 392, in predict
    insights, global_insights = explain(data=data,encoded_data=encoded_data,predictions=df,ts_analysis=None,problem_definition=self.problem_definition,stat_analysis=self.statistical_analysis,runtime_analysis=self.runtime_analyzer,target_name=self.target,target_dtype=self.dtype_dict[self.target],explainer_blocks=self.analysis_blocks,pred_args=self.pred_args)
  File "/MindsDB/lightwood/lightwood/analysis/explain.py", line 81, in explain
    row_insights, global_insights = block.explain(row_insights, global_insights, **kwargs)
  File "/MindsDB/lightwood/lightwood/analysis/helpers/shap.py", line 79, in explain
    shap_values = shap_explainer.shap_values(ns.data, silent=True)
  File "/MindsDB/nenv/lib/python3.8/site-packages/shap/explainers/_kernel.py", line 186, in shap_values
    explanations.append(self.explain(data, **kwargs))
  File "/MindsDB/nenv/lib/python3.8/site-packages/shap/explainers/_kernel.py", line 207, in explain
    match_instance_to_data(instance, self.data)
  File "/MindsDB/nenv/lib/python3.8/site-packages/shap/utils/_legacy.py", line 87, in match_instance_to_data
    instance.group_display_values = [instance.x[0, group[0]] if len(group) == 1 else "" for group in data.groups]
  File "/MindsDB/nenv/lib/python3.8/site-packages/shap/utils/_legacy.py", line 87, in <listcomp>
    instance.group_display_values = [instance.x[0, group[0]] if len(group) == 1 else "" for group in data.groups]
IndexError: index 7 is out of bounds for axis 1 with size 7

```


Discussion:

Resolving patch:
diff --git a/lightwood/analysis/helpers/shap.py b/lightwood/analysis/helpers/shap.py
--- a/lightwood/analysis/helpers/shap.py
+++ b/lightwood/analysis/helpers/shap.py
@@ -29,6 +29,8 @@ class ShapleyValues(BaseAnalysisBlock):
     def __init__(self, deps: Optional[Tuple] = ...):
         super().__init__(deps=deps)
         self.label_encoder = LabelEncoder()
+        self.columns = []
+        self.target = None
 
     def analyze(self, info: Dict[str, object], **kwargs) -> Dict[str, object]:
         log.info('Preparing to compute feature importance values with SHAP')
@@ -45,9 +47,13 @@ def analyze(self, info: Dict[str, object], **kwargs) -> Dict[str, object]:
             log.warning(f'ShapleyValues analyzers not supported for type: {output_dtype}')
             return info
 
+        self.target = ns.target
+        self.columns = list(set(ns.dtype_dict.keys()) - {self.target})
+        input_df = train_data.data_frame[self.columns]
+
         def model(x: np.ndarray) -> np.ndarray:
             assert(isinstance(x, np.ndarray))
-            df = pd.DataFrame(data=x, columns=train_data.data_frame.columns)
+            df = pd.DataFrame(data=x, columns=self.columns)
             ds = EncodedDs(encoders=train_data.encoders, data_frame=df, target=train_data.target)
 
             decoded_predictions = ns.predictor(ds=ds, args=PredictionArguments())
@@ -58,7 +64,7 @@ def model(x: np.ndarray) -> np.ndarray:
 
             return encoded_predictions
 
-        info['shap_explainer'] = shap.KernelExplainer(model=model, data=train_data.data_frame)
+        info['shap_explainer'] = shap.KernelExplainer(model=model, data=input_df)
 
         return info
 
@@ -76,10 +82,10 @@ def explain(self,
 
         with warnings.catch_warnings():
             warnings.filterwarnings("ignore", category=DeprecationWarning)
-            shap_values = shap_explainer.shap_values(ns.data, silent=True)
+            shap_values = shap_explainer.shap_values(ns.data[self.columns], silent=True)
 
         shap_values_df = pd.DataFrame(shap_values).rename(
-            mapper=lambda i: f"feature_{i}_impact", axis='columns')
+            mapper=lambda i: f"shap_contribution_{self.columns[i]}", axis='columns')
 
         if kwargs.get('target_dtype', None) in (dtype.binary, dtype.categorical, dtype.tags):
             predictions = self.label_encoder.transform(row_insights['prediction'])
@@ -87,8 +93,9 @@ def explain(self,
             predictions = row_insights['prediction']
 
         base_response = (predictions - shap_values_df.sum(axis='columns')).mean()
-        global_insights['base_response'] = base_response
 
         row_insights = pd.concat([row_insights, shap_values_df], axis='columns')
+        row_insights['shap_base_response'] = base_response
+        row_insights['shap_final_response'] = predictions
 
         return row_insights, global_insights


Test patch:
diff --git a/tests/unit_tests/analysis/__init__.py b/tests/unit_tests/analysis/__init__.py
new file mode 100644
diff --git a/tests/unit_tests/analysis/helpers/test_shap.py b/tests/unit_tests/analysis/test_shap.py
similarity index 78%
rename from tests/unit_tests/analysis/helpers/test_shap.py
rename to tests/unit_tests/analysis/test_shap.py
--- a/tests/unit_tests/analysis/helpers/test_shap.py
+++ b/tests/unit_tests/analysis/test_shap.py
@@ -29,6 +29,9 @@ def test_0_shap_analysis(self):
         predictions = predictor.predict(df.head())
 
         self.assertIn('shap_explainer', predictor.runtime_analyzer)
-        self.assertIn('feature_4_impact', predictions.columns)
-        # TODO: once global_insights is exposed, check that all feature impacts
-        # plus base_response sum up to the prediction value
+
+        self.assertIn('shap_base_response', predictions.columns)
+        self.assertIn('shap_final_response', predictions.columns)
+        for input_col in df.columns:
+            if input_col != target:
+                self.assertIn(f'shap_contribution_{input_col}', predictions.columns)