xailens 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xai_compare/__init__.py +6 -0
- xai_compare/_version.py +2 -0
- xai_compare/adapters/__init__.py +0 -0
- xai_compare/adapters/comparison/__init__.py +0 -0
- xai_compare/adapters/comparison/coefficient_treeshap_adapter.py +56 -0
- xai_compare/adapters/comparison/comparison_base.py +28 -0
- xai_compare/adapters/explainers/__init__.py +1 -0
- xai_compare/adapters/explainers/coefficients_adapter.py +49 -0
- xai_compare/adapters/explainers/cor_adapter.py +107 -0
- xai_compare/adapters/explainers/kernelshap_adapter.py +37 -0
- xai_compare/adapters/explainers/treeshap_adapter.py +77 -0
- xai_compare/adapters/explainers/xaimethod_base.py +64 -0
- xai_compare/adapters/models/__init__.py +0 -0
- xai_compare/adapters/models/llm_adapter.py +11 -0
- xai_compare/adapters/models/logisticregression_adapter.py +8 -0
- xai_compare/adapters/models/model_base.py +15 -0
- xai_compare/adapters/models/xgboost_adapter.py +8 -0
- xai_compare/adapters/viz/__init__.py +0 -0
- xai_compare/adapters/viz/forceplot_viz_adapter.py +40 -0
- xai_compare/adapters/viz/viz_base.py +15 -0
- xai_compare/adapters/viz/waterfall_viz_adapter.py +38 -0
- xai_compare/artifacts.py +70 -0
- xai_compare/cli.py +10 -0
- xai_compare/config.py +39 -0
- xai_compare/constants.py +12 -0
- xai_compare/context.py +74 -0
- xai_compare/dashboard/__init__.py +0 -0
- xai_compare/dashboard/app.py +16 -0
- xai_compare/dashboard/components/__init__.py +0 -0
- xai_compare/dashboard/components/breadcrumb.py +24 -0
- xai_compare/dashboard/components/formatting.py +39 -0
- xai_compare/dashboard/components/layout.py +21 -0
- xai_compare/dashboard/components/run_display.py +62 -0
- xai_compare/dashboard/components/sidebar.py +62 -0
- xai_compare/dashboard/components/styles.py +9 -0
- xai_compare/dashboard/pages/__init__.py +0 -0
- xai_compare/dashboard/pages/detail.py +83 -0
- xai_compare/dashboard/pages/home.py +16 -0
- xai_compare/dashboard/pages/overview.py +49 -0
- xai_compare/dashboard/services/__init__.py +1 -0
- xai_compare/dashboard/services/explorer.py +8 -0
- xai_compare/dashboard/utils/__init__.py +0 -0
- xai_compare/dashboard/utils/comparison.py +66 -0
- xai_compare/dashboard/utils/params.py +21 -0
- xai_compare/encoder.py +18 -0
- xai_compare/exceptions.py +12 -0
- xai_compare/registry/__init__.py +0 -0
- xai_compare/registry/autodiscover.py +29 -0
- xai_compare/registry/comparison_registry.py +22 -0
- xai_compare/registry/model_registry.py +19 -0
- xai_compare/registry/viz_registry.py +18 -0
- xai_compare/registry/xai_registry.py +18 -0
- xai_compare/run.py +62 -0
- xai_compare/run_explorer.py +117 -0
- xai_compare/runner.py +59 -0
- xai_compare/services/__init__.py +0 -0
- xai_compare/services/comparison.py +100 -0
- xai_compare/services/preprocess.py +12 -0
- xai_compare/storage/__init__.py +0 -0
- xai_compare/storage/filesystem_store.py +40 -0
- xai_compare/storage/store_base.py +23 -0
- xai_compare/strings.py +38 -0
- xailens-0.1.2.dist-info/METADATA +79 -0
- xailens-0.1.2.dist-info/RECORD +68 -0
- xailens-0.1.2.dist-info/WHEEL +5 -0
- xailens-0.1.2.dist-info/entry_points.txt +2 -0
- xailens-0.1.2.dist-info/licenses/LICENSE +674 -0
- xailens-0.1.2.dist-info/top_level.txt +1 -0
xai_compare/__init__.py
ADDED
xai_compare/_version.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import plotly.graph_objects as go
|
|
2
|
+
|
|
3
|
+
from xai_compare.context import ComparisonContext
|
|
4
|
+
from xai_compare.registry.comparison_registry import register_comparison
|
|
5
|
+
from xai_compare.adapters.comparison.comparison_base import ComparisonAdapter
|
|
6
|
+
from xai_compare.dashboard.components.formatting import format_model_name
|
|
7
|
+
from xai_compare import config, context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_comparison("coefficients", "shap_tree")
|
|
11
|
+
class CoefficientTreeShapComparison(ComparisonAdapter):
|
|
12
|
+
"""For comparing coefficients with shap tree"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _viz_diff_bar(self, features_a, features_b, run_a, run_b, axis_range=None):
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def _viz_dumbbell(self, features_a, features_b, run_a, run_b, axis_range=None):
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def _viz_alignment_scale(self, ctx: ComparisonContext ):
|
|
22
|
+
diff = ctx.features_a.sub(ctx.features_b, fill_value=0)
|
|
23
|
+
diff = diff.sort_values()
|
|
24
|
+
|
|
25
|
+
colors = [config.PLOT_LEFT_COLOR if v < 0 else config.PLOT_RIGHT_COLOR for v in diff]
|
|
26
|
+
|
|
27
|
+
fig = go.Figure(go.Bar(
|
|
28
|
+
x=diff.values,
|
|
29
|
+
y=diff.index,
|
|
30
|
+
orientation='h',
|
|
31
|
+
marker_color=colors,
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
label_a = format_model_name(ctx.run_a)
|
|
35
|
+
label_b = format_model_name(ctx.run_b)
|
|
36
|
+
|
|
37
|
+
fig.update_layout(
|
|
38
|
+
title=f"Explanation Difference: {label_a} − {label_b}",
|
|
39
|
+
xaxis_title=f"Attribution difference ({label_a} − {label_b})",
|
|
40
|
+
xaxis=config.PLOT_ZERO_LINE,
|
|
41
|
+
yaxis_title="Feature",
|
|
42
|
+
height=400 + len(diff) * 15,
|
|
43
|
+
)
|
|
44
|
+
if ctx.axis_range:
|
|
45
|
+
fig.update_layout(xaxis={"range": ctx.axis_range})
|
|
46
|
+
return fig
|
|
47
|
+
|
|
48
|
+
visualisations_available = {
|
|
49
|
+
"Difference (bar)": _viz_diff_bar,
|
|
50
|
+
"Dumbbell": _viz_dumbbell,
|
|
51
|
+
"Alignment Scale": _viz_alignment_scale
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def render_comparison(self, ctx: context.ComparisonContext, viz_type):
|
|
55
|
+
|
|
56
|
+
return self.visualisations_available[viz_type](self, ctx)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xai_compare.context import ComparisonContext
|
|
6
|
+
from xai_compare.services.preprocess import normalise_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ComparisonAdapter(ABC):
|
|
10
|
+
"""Handles comparison between two XAI explanations."""
|
|
11
|
+
|
|
12
|
+
compatible_pairs: set[frozenset[str]]
|
|
13
|
+
visualisations_available = []
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def get_abs_max(cls, explanation_a: pd.Series, explanation_b: pd.Series, sample_id_column) -> float:
|
|
17
|
+
""" calculate absolute max value from explanation
|
|
18
|
+
required for ensuring charts use the same scales """
|
|
19
|
+
features_a = normalise_values(explanation_a, sample_id_column)
|
|
20
|
+
features_b = normalise_values(explanation_b, sample_id_column)
|
|
21
|
+
a = pd.to_numeric(features_a, errors='coerce')
|
|
22
|
+
b = pd.to_numeric(features_b, errors='coerce')
|
|
23
|
+
diff = a.sub(b, fill_value=0)
|
|
24
|
+
return diff.abs().max()
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def render_comparison(self, ctx: ComparisonContext, viz_type) -> Any:
|
|
28
|
+
raise NotImplementedError
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xai_compare.context import RunData
|
|
6
|
+
from xai_compare.exceptions import MissingDataError
|
|
7
|
+
from xai_compare.registry.xai_registry import register_xai
|
|
8
|
+
from xai_compare.adapters.explainers.xaimethod_base import XAIMethodAdapter
|
|
9
|
+
from xai_compare.run import Run
|
|
10
|
+
from xai_compare import constants, strings, config
|
|
11
|
+
from xai_compare.context import RunContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register_xai("coefficients")
|
|
15
|
+
class CoefficientsAdapter(XAIMethodAdapter):
|
|
16
|
+
"""For models with native interpretability"""
|
|
17
|
+
|
|
18
|
+
display_name = strings.COEFFICIENTS_DISPLAY_NAME
|
|
19
|
+
default_visualisation = config.COEFFICIENTS_DEFAULT_VIZ
|
|
20
|
+
compatible_visualisations = config.COEFFICIENTS_COMPATIBLE_VIZ
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def load_explanation(cls, run: Run, sample_id: str):
|
|
24
|
+
df = run.store.load_dataframe(f"explanations_{cls.key}")
|
|
25
|
+
row = df[df[run.get(constants.DATA_SCHEMA_ID_COLUMN)] == sample_id]
|
|
26
|
+
if row.empty:
|
|
27
|
+
raise MissingDataError(f"Sample '{sample_id}' not found in explanations for {cls.key}")
|
|
28
|
+
return row.iloc[0]
|
|
29
|
+
|
|
30
|
+
def explain_local_df(self, ctx: RunContext, model=None) -> pd.DataFrame:
|
|
31
|
+
"""Computes the local explanation from the coefficients of the model
|
|
32
|
+
Args:
|
|
33
|
+
ctx: RunContext object
|
|
34
|
+
model: Trained model object
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
pd.DataFrame: dataframe containing local explanations
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
MissingDataError: if data.x is not provided
|
|
41
|
+
"""
|
|
42
|
+
if ctx.data.x is None:
|
|
43
|
+
raise MissingDataError("CoefficientsAdapter requires x in RunData")
|
|
44
|
+
coef = model.coef_[0]
|
|
45
|
+
local_explanations = ctx.data.x * coef
|
|
46
|
+
return local_explanations
|
|
47
|
+
|
|
48
|
+
def explain_local_json(self, ctx: RunContext) -> dict[str, Any]:
|
|
49
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from typing import Any
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from xai_compare.context import RunContext, ResponseSchema
|
|
8
|
+
from xai_compare.exceptions import MissingDataError
|
|
9
|
+
from xai_compare.registry.xai_registry import register_xai
|
|
10
|
+
from xai_compare.adapters.explainers.xaimethod_base import XAIMethodAdapter
|
|
11
|
+
from xai_compare import strings, config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ContribScore:
|
|
16
|
+
score: int
|
|
17
|
+
reasoning: str
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LLMResponse:
|
|
21
|
+
reasoning: str
|
|
22
|
+
prediction: int | str
|
|
23
|
+
confidence: float
|
|
24
|
+
contributions: dict[str, ContribScore]
|
|
25
|
+
raw: dict
|
|
26
|
+
|
|
27
|
+
def to_dict(self, sample_id_col: str, sample_id: str) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
sample_id_col: sample_id,
|
|
30
|
+
"reasoning": self.reasoning,
|
|
31
|
+
"prediction": self.prediction,
|
|
32
|
+
"confidence": self.confidence,
|
|
33
|
+
"contributions": {
|
|
34
|
+
feature: {"score": c.score, "reasoning": c.reasoning}
|
|
35
|
+
for feature, c in self.contributions.items()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@register_xai("cor")
|
|
40
|
+
class CoRAdapter(XAIMethodAdapter):
|
|
41
|
+
"""For chain-of-reasoning/chain-of-thought explanations"""
|
|
42
|
+
|
|
43
|
+
display_name = strings.COR_DISPLAY_NAME
|
|
44
|
+
default_visualisation = config.COR_DEFAULT_VIZ
|
|
45
|
+
compatible_visualisations = config.COR_COMPATIBLE_VIZ
|
|
46
|
+
|
|
47
|
+
# no features as such so will process the explanations in this adapter
|
|
48
|
+
requires_encoded_features: bool = False
|
|
49
|
+
|
|
50
|
+
# uses a json file for explanation storage
|
|
51
|
+
explanation_file_type: str = "json"
|
|
52
|
+
|
|
53
|
+
def explain_local_df(self, ctx: RunContext, model=None) -> pd.DataFrame:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def explain_local_json(self, ctx: RunContext) -> list[dict[str, Any]]:
|
|
57
|
+
"""Processes the CoR local explanations
|
|
58
|
+
Args:
|
|
59
|
+
ctx: RunContext object
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
list[dict[str, Any]]: list of dicts containing local explanations
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
MissingDataError: if data.x is not provided
|
|
66
|
+
"""
|
|
67
|
+
if ctx.data.y_exp is None:
|
|
68
|
+
raise MissingDataError("CoRAdapter requires y_exp in RunData")
|
|
69
|
+
if ctx.response_schema is None:
|
|
70
|
+
raise MissingDataError(
|
|
71
|
+
"CoRAdapter requires a ResponseSchema on RunContext")
|
|
72
|
+
|
|
73
|
+
sample_id_col = ctx.data.data_schema.sample_id_column
|
|
74
|
+
output = []
|
|
75
|
+
for idx, row in ctx.data.y_exp.iterrows():
|
|
76
|
+
raw = json.loads(row["explanation"])
|
|
77
|
+
response = self._normalise(raw, ctx.response_schema)
|
|
78
|
+
output.append(response.to_dict(sample_id_col, row[sample_id_col]))
|
|
79
|
+
return output
|
|
80
|
+
|
|
81
|
+
def _normalise(self, raw: dict, schema: ResponseSchema) -> LLMResponse:
|
|
82
|
+
raw_contribs = raw[schema.contributions_field]
|
|
83
|
+
|
|
84
|
+
if schema.contribution_key is not None:
|
|
85
|
+
contributions = {
|
|
86
|
+
item[schema.contribution_key]: ContribScore(
|
|
87
|
+
score=item["score"],
|
|
88
|
+
reasoning=item["reasoning"],
|
|
89
|
+
)
|
|
90
|
+
for item in raw_contribs
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
contributions = {
|
|
94
|
+
feature: ContribScore(
|
|
95
|
+
score=values["score"],
|
|
96
|
+
reasoning=values["reasoning"],
|
|
97
|
+
)
|
|
98
|
+
for feature, values in raw_contribs.items()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return LLMResponse(
|
|
102
|
+
reasoning=raw["reasoning"],
|
|
103
|
+
prediction=raw["prediction"],
|
|
104
|
+
confidence=raw["confidence"],
|
|
105
|
+
contributions=contributions,
|
|
106
|
+
raw=raw,
|
|
107
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import shap
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xai_compare.exceptions import MissingDataError
|
|
6
|
+
from xai_compare.context import RunContext
|
|
7
|
+
from xai_compare.registry.xai_registry import register_xai
|
|
8
|
+
from xai_compare.adapters.explainers.xaimethod_base import XAIMethodAdapter
|
|
9
|
+
from xai_compare import config, strings
|
|
10
|
+
|
|
11
|
+
@register_xai("shap_kernel")
|
|
12
|
+
class KernalSHAPAdapter(XAIMethodAdapter):
|
|
13
|
+
"""Model-agnostic Kernel Shap Explainer"""
|
|
14
|
+
|
|
15
|
+
display_name = strings.KERNEL_SHAP_DISPLAY_NAME
|
|
16
|
+
default_visualisation = config.KERNEL_SHAP_DEFAULT_VIZ
|
|
17
|
+
compatible_visualisations = config.KERNEL_SHAP_COMPATIBLE_VIZ
|
|
18
|
+
|
|
19
|
+
def explain_local_df(self, ctx: RunContext, model=None) -> pd.DataFrame:
|
|
20
|
+
"""Computes the local explanation using KernelShap method
|
|
21
|
+
Args:
|
|
22
|
+
ctx: RunContext object
|
|
23
|
+
model: Trained model object
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
pd.DataFrame: dataframe containing local explanations
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
MissingDataError: if x_train or x_test is not provided
|
|
30
|
+
"""
|
|
31
|
+
if ctx.data.x_train is None or ctx.data.x_test is None:
|
|
32
|
+
raise MissingDataError("KernalSHAPAdapter requires x_train and x_test in RunData")
|
|
33
|
+
explainer = shap.KernelExplainer(model.predict_proba, ctx.data.x_train)
|
|
34
|
+
return explainer.shap_values(ctx.data.x_test)
|
|
35
|
+
|
|
36
|
+
def explain_local_json(self, data: RunContext) -> dict[str, Any]:
|
|
37
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import shap
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xai_compare.exceptions import MissingDataError
|
|
6
|
+
from xai_compare.context import RunContext
|
|
7
|
+
from xai_compare.registry.xai_registry import register_xai
|
|
8
|
+
from xai_compare.adapters.explainers.xaimethod_base import XAIMethodAdapter
|
|
9
|
+
from xai_compare.run import Run
|
|
10
|
+
from xai_compare import constants, strings, config
|
|
11
|
+
|
|
12
|
+
@register_xai("shap_tree")
|
|
13
|
+
class TreeSHAPAdapter(XAIMethodAdapter):
|
|
14
|
+
"""Tree Shap Explainer"""
|
|
15
|
+
|
|
16
|
+
display_name = strings.TREE_SHAP_DISPLAY_NAME
|
|
17
|
+
default_visualisation = config.TREE_SHAP_DEFAULT_VIZ
|
|
18
|
+
compatible_visualisations = config.TREE_SHAP_COMPATIBLE_VIZ
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def load_explanation(cls, run: Run, sample_id: str):
|
|
22
|
+
df = run.store.load_dataframe(f"explanations_{cls.key}")
|
|
23
|
+
row = df[df[run.get(constants.DATA_SCHEMA_ID_COLUMN)] == sample_id]
|
|
24
|
+
if row.empty:
|
|
25
|
+
raise ValueError(f"Sample '{sample_id}' not found in explanations for {cls.key}")
|
|
26
|
+
return row.iloc[0]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def explain_local_df(self, ctx: RunContext, model=None) -> pd.DataFrame:
|
|
30
|
+
"""Computes the local explanation from the coefficients of the model
|
|
31
|
+
Args:
|
|
32
|
+
ctx: RunContext object
|
|
33
|
+
model: Trained model object
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
pd.DataFrame: dataframe containing local explanations
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
MissingDataError: if data.x_test is not provided
|
|
40
|
+
"""
|
|
41
|
+
if not hasattr(ctx.data, "x_test") or ctx.data.x_test is None:
|
|
42
|
+
raise MissingDataError("CoefficientsAdapter requires x_test in RunData")
|
|
43
|
+
|
|
44
|
+
data = ctx.data.x_test
|
|
45
|
+
if ctx.data.data_schema.sample_id_column in data.columns:
|
|
46
|
+
data = data.set_index(ctx.data.data_schema.sample_id_column)
|
|
47
|
+
data = data.select_dtypes(include=['number'])
|
|
48
|
+
|
|
49
|
+
explainer = shap.TreeExplainer(model)
|
|
50
|
+
shap_values = explainer.shap_values(data)
|
|
51
|
+
|
|
52
|
+
# (n_samples, 1, n_features)
|
|
53
|
+
if shap_values.ndim == 2:
|
|
54
|
+
dfs = [pd.DataFrame(shap_values, index=data.index, columns=ctx.data.x_test.columns)]
|
|
55
|
+
dfs[0]['class'] = 0
|
|
56
|
+
return dfs[0].set_index('class', append=True)
|
|
57
|
+
|
|
58
|
+
# SHAP returns (n_samples, n_features, n_classes) for multiclass tree models
|
|
59
|
+
# slice last axis per class to get (n_samples, n_features) per DataFrame
|
|
60
|
+
if shap_values.ndim == 3:
|
|
61
|
+
n_classes = shap_values.shape[2]
|
|
62
|
+
dfs = []
|
|
63
|
+
|
|
64
|
+
for i in range(n_classes):
|
|
65
|
+
# Slice the last axis [:, :, i] to get (n_samples, n_features)
|
|
66
|
+
df = pd.DataFrame(
|
|
67
|
+
shap_values[:, :, i],
|
|
68
|
+
index=data.index,
|
|
69
|
+
columns=data.columns
|
|
70
|
+
)
|
|
71
|
+
dfs.append(df)
|
|
72
|
+
|
|
73
|
+
keys = ctx.data.data_schema.class_names if ctx.data.data_schema.class_names is not None else range(n_classes)
|
|
74
|
+
return pd.concat(dfs, keys=keys, names=['class', data.index.name])
|
|
75
|
+
|
|
76
|
+
def explain_local_json(self, ctx: RunContext) -> dict[str, Any]:
|
|
77
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from xai_compare.context import RunContext, RunData
|
|
6
|
+
from xai_compare.services.preprocess import normalise_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class XAIMethodAdapter(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Abstract class for XAI method adapter.
|
|
12
|
+
"""
|
|
13
|
+
display_name: str | None = None
|
|
14
|
+
default_visualisation: str | None = None
|
|
15
|
+
compatible_visualisations: list[str] = []
|
|
16
|
+
|
|
17
|
+
# default to true for most explanation adapters
|
|
18
|
+
requires_encoded_features: bool = True
|
|
19
|
+
|
|
20
|
+
# type of explanation storage - df or json
|
|
21
|
+
explanation_file_type: str = "df"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load_explanation(cls, store, sample_id: str):
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def get_abs_max(cls, explanation: pd.Series, sample_id_column) -> float:
|
|
29
|
+
""" calculate absolute max value from explanation
|
|
30
|
+
required for ensuring charts use the same scales """
|
|
31
|
+
features = normalise_values(explanation, sample_id_column)
|
|
32
|
+
return pd.to_numeric(features, errors='coerce').abs().max()
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def explain_local_df(self, ctx: RunContext, model=None) -> pd.DataFrame:
|
|
36
|
+
"""Computes the local explanation for the model
|
|
37
|
+
Args:
|
|
38
|
+
ctx: RunContext object
|
|
39
|
+
model: Trained model object
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
pd.DataFrame: dataframe containing local explanations
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
MissingDataError: if required data is not provided
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def explain_local_json(self, ctx: RunContext) -> list[dict[str, Any]]:
|
|
51
|
+
"""Computes the local explanation for the model
|
|
52
|
+
Args:
|
|
53
|
+
ctx: RunContext object
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
list of dicts - json containing local explanations
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
MissingDataError: if required data is not provided
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from xai_compare.registry.model_registry import register_model
|
|
2
|
+
from xai_compare.adapters.models.model_base import ModelAdapter
|
|
3
|
+
from xai_compare import strings
|
|
4
|
+
|
|
5
|
+
@register_model("llm")
|
|
6
|
+
class LLMAdapter(ModelAdapter):
|
|
7
|
+
|
|
8
|
+
display_name = strings.LLM_DISPLAY_NAME
|
|
9
|
+
|
|
10
|
+
def prepare(self):
|
|
11
|
+
pass
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from xai_compare.registry.model_registry import register_model
|
|
2
|
+
from xai_compare.adapters.models.model_base import ModelAdapter
|
|
3
|
+
from xai_compare import strings
|
|
4
|
+
|
|
5
|
+
@register_model("logistic_regression")
|
|
6
|
+
class LogisticRegressionAdapter(ModelAdapter):
|
|
7
|
+
|
|
8
|
+
display_name = strings.LOGISTIC_REGRESSION_DISPLAY_NAME
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from xai_compare.registry.model_registry import register_model
|
|
2
|
+
from xai_compare.adapters.models.model_base import ModelAdapter
|
|
3
|
+
from xai_compare import strings
|
|
4
|
+
|
|
5
|
+
@register_model("xgboost")
|
|
6
|
+
class XGBoostAdapter(ModelAdapter):
|
|
7
|
+
|
|
8
|
+
display_name = strings.XGBOOST_DISPLAY_NAME
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import plotly.graph_objects as go
|
|
2
|
+
|
|
3
|
+
from xai_compare.adapters.viz.viz_base import VizAdapter
|
|
4
|
+
from xai_compare.registry.viz_registry import register_viz
|
|
5
|
+
from xai_compare import strings, config
|
|
6
|
+
from xai_compare.services.preprocess import normalise_values
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register_viz("forceplot")
|
|
10
|
+
class ForcePlotVizAdapter(VizAdapter):
|
|
11
|
+
|
|
12
|
+
display_name = strings.FORCE_PLOT_DISPLAY_NAME
|
|
13
|
+
compatible_explanations = config.FORCE_PLOT_COMPATIBLE_XAI
|
|
14
|
+
|
|
15
|
+
def render_local(self, explanation, sample_id_col, axis_range=None):
|
|
16
|
+
features = normalise_values(explanation, sample_id_col)
|
|
17
|
+
|
|
18
|
+
colors = [config.PLOT_LEFT_COLOR if v < 0 else config.PLOT_RIGHT_COLOR for v in features.values]
|
|
19
|
+
|
|
20
|
+
fig = go.Figure(go.Bar(
|
|
21
|
+
orientation="h",
|
|
22
|
+
y=features.index.tolist(),
|
|
23
|
+
x=features.values.tolist(),
|
|
24
|
+
marker_color=colors,
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
fig.update_layout(
|
|
28
|
+
title=strings.FORCE_PLOT_TITLE,
|
|
29
|
+
xaxis_title=strings.FORCE_PLOT_X_AXIS,
|
|
30
|
+
xaxis=config.PLOT_ZERO_LINE,
|
|
31
|
+
showlegend=False,
|
|
32
|
+
)
|
|
33
|
+
if axis_range:
|
|
34
|
+
fig.update_layout(xaxis={"range":axis_range})
|
|
35
|
+
|
|
36
|
+
return fig
|
|
37
|
+
|
|
38
|
+
def render_global(self, explanations):
|
|
39
|
+
# Placeholder function
|
|
40
|
+
return
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
class VizAdapter(ABC):
|
|
5
|
+
|
|
6
|
+
display_name: str | None = None
|
|
7
|
+
compatible_explanations: list[str] = []
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def render_local(self, explanation, sample_id_col, axis_range=None) -> Any:
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def render_global(self, explanations) -> Any:
|
|
15
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import plotly.graph_objects as go
|
|
2
|
+
from xai_compare.adapters.viz.viz_base import VizAdapter
|
|
3
|
+
from xai_compare.registry.viz_registry import register_viz
|
|
4
|
+
from xai_compare import strings, config
|
|
5
|
+
|
|
6
|
+
@register_viz("waterfall")
|
|
7
|
+
class WaterfallVizAdapter(VizAdapter):
|
|
8
|
+
|
|
9
|
+
display_name = strings.WATERFALL_DISPLAY_NAME
|
|
10
|
+
compatible_explanations = config.WATERFALL_COMPATIBLE_XAI
|
|
11
|
+
|
|
12
|
+
def render_local(self, explanation, sample_id_col, axis_range=None) -> go.Figure:
|
|
13
|
+
features = explanation.drop([sample_id_col, "class"], errors="ignore")
|
|
14
|
+
|
|
15
|
+
# Sort by absolute value descending
|
|
16
|
+
features = features.reindex(features.abs().sort_values(ascending=True).index)
|
|
17
|
+
|
|
18
|
+
# Normalise by sum of absolute values
|
|
19
|
+
features = features / features.abs().sum()
|
|
20
|
+
|
|
21
|
+
fig = go.Figure(go.Waterfall(
|
|
22
|
+
orientation="h",
|
|
23
|
+
measure=["relative"] * len(features),
|
|
24
|
+
y=features.index.tolist(),
|
|
25
|
+
x=features.values.tolist(),
|
|
26
|
+
connector={"line": {"color": "rgb(63, 63, 63)"}},
|
|
27
|
+
))
|
|
28
|
+
fig.update_layout(
|
|
29
|
+
title=strings.WATERFALL_TITLE,
|
|
30
|
+
xaxis_title=strings.WATERFALL_X_AXIS,
|
|
31
|
+
)
|
|
32
|
+
if axis_range:
|
|
33
|
+
fig.update_layout(xaxis={"range": axis_range})
|
|
34
|
+
return fig
|
|
35
|
+
|
|
36
|
+
def render_global(self, explanations):
|
|
37
|
+
# Placeholder function
|
|
38
|
+
return
|
xai_compare/artifacts.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
|
|
9
|
+
from xai_compare import RunContext
|
|
10
|
+
from xai_compare import config
|
|
11
|
+
from xai_compare.storage.store_base import DataStore
|
|
12
|
+
from xai_compare.storage.filesystem_store import FilesystemStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArtifactStore:
|
|
16
|
+
"""ArtifactStore - handles the data storage
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
ctx (RunContext): The current run configuration.
|
|
20
|
+
store (DataStore): Storage backend. Defaults to FilesystemStore.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ctx: RunContext, store: DataStore = None):
|
|
24
|
+
self.ctx = ctx
|
|
25
|
+
self.run_datetime = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
26
|
+
dirname = self._resolve_dirname(ctx)
|
|
27
|
+
self.store = store or FilesystemStore(dirname)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_dirname(self, ctx):
|
|
31
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
32
|
+
if ctx.run_name:
|
|
33
|
+
return os.path.join(BASE_DIR, config.RUNS_DIR_NAME, ctx.run_name)
|
|
34
|
+
return os.path.join(BASE_DIR, config.RUNS_DIR_NAME, self.run_datetime)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_metadata(self):
|
|
38
|
+
"""Writes the artifact metadata directly from the context dataclass
|
|
39
|
+
"""
|
|
40
|
+
excluded = {'data', 'model'}
|
|
41
|
+
obj = {k: v for k, v in asdict(self.ctx).items() if k not in excluded}
|
|
42
|
+
obj['datetime'] = self.run_datetime
|
|
43
|
+
obj['run_uuid'] = str(uuid.uuid4())
|
|
44
|
+
|
|
45
|
+
# Add the dataschema info
|
|
46
|
+
obj['data_schema'] = asdict(self.ctx.data.data_schema)
|
|
47
|
+
|
|
48
|
+
self.store.save_json(config.META_INFO_FILENAME, obj)
|
|
49
|
+
|
|
50
|
+
def write_model(self):
|
|
51
|
+
if self.ctx.model:
|
|
52
|
+
self.store.save_model(config.MODEL_FILENAME ,self.ctx.model)
|
|
53
|
+
|
|
54
|
+
def write_data(self, name, data):
|
|
55
|
+
if data is None:
|
|
56
|
+
return
|
|
57
|
+
if isinstance(data, pd.Series):
|
|
58
|
+
data = data.to_frame()
|
|
59
|
+
elif isinstance(data, np.ndarray):
|
|
60
|
+
data = pd.DataFrame(data)
|
|
61
|
+
elif isinstance(data, list):
|
|
62
|
+
data = pd.DataFrame(data)
|
|
63
|
+
self.store.save_dataframe(name, data)
|
|
64
|
+
|
|
65
|
+
def write_explanation(self, name, data, type="df"):
|
|
66
|
+
self.store.save_explanation(name, data, type)
|
|
67
|
+
|
|
68
|
+
def load_dataframe(self, name:str) -> pd.DataFrame:
|
|
69
|
+
return self.store.load_dataframe(name)
|
|
70
|
+
|
xai_compare/cli.py
ADDED