xwhy 0.0.0.1__tar.gz
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.
- xwhy-0.0.0.1/PKG-INFO +12 -0
- xwhy-0.0.0.1/README.md +63 -0
- xwhy-0.0.0.1/setup.cfg +7 -0
- xwhy-0.0.0.1/setup.py +26 -0
- xwhy-0.0.0.1/xwhy/__init__.py +8 -0
- xwhy-0.0.0.1/xwhy/datasets.py +267 -0
- xwhy-0.0.0.1/xwhy/smile_graph.py +263 -0
- xwhy-0.0.0.1/xwhy/smile_image.py +127 -0
- xwhy-0.0.0.1/xwhy/smile_quantum.py +1 -0
- xwhy-0.0.0.1/xwhy/smile_tabular.py +153 -0
- xwhy-0.0.0.1/xwhy/smile_text.py +128 -0
- xwhy-0.0.0.1/xwhy.egg-info/PKG-INFO +12 -0
- xwhy-0.0.0.1/xwhy.egg-info/SOURCES.txt +16 -0
- xwhy-0.0.0.1/xwhy.egg-info/dependency_links.txt +1 -0
- xwhy-0.0.0.1/xwhy.egg-info/not-zip-safe +1 -0
- xwhy-0.0.0.1/xwhy.egg-info/requires.txt +12 -0
- xwhy-0.0.0.1/xwhy.egg-info/top_level.txt +1 -0
xwhy-0.0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: xwhy
|
|
3
|
+
Version: 0.0.0.1
|
|
4
|
+
Summary: Explain Why (XWhy) with Statistical Model-agnostic Interpretability with Local Explanations (SMILE)
|
|
5
|
+
Home-page: https://github.com/Dependable-Intelligent-Systems-Lab/xwhy
|
|
6
|
+
Author: Mojgan Hashemian, Koorosh Aslansefat (corresponding), Mohammad Naveed Akram, Ioannis Sorokos, Martin Walker, Yiannis Papadopoulos
|
|
7
|
+
Author-email: koo.ec2008@gmail.com
|
|
8
|
+
License: BSD
|
|
9
|
+
Description: UNKNOWN
|
|
10
|
+
Platform: UNKNOWN
|
|
11
|
+
Requires-Python: >=3.5
|
|
12
|
+
Provides-Extra: dev
|
xwhy-0.0.0.1/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<p align="left"> </p>
|
|
2
|
+
|
|
3
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT">
|
|
4
|
+
<a href="https://standardjs.com"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Standard - \Python Style Guide"></a>
|
|
5
|
+
|
|
6
|
+
# X-Why
|
|
7
|
+
XWhy: eXplain Why with <b>SMILE</b> -- <b>S</b>tatistical <b>M</b>odel-agnostic <b>I</b>nterpretability with <b>L</b>ocal <b>E</b>xplanations
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="https://github.com/koo-ec/xwhy/blob/main/docs/graphics/XWhy_Logo_v1.png" alt="XWhy, SMILE, Explainability, Interpretability, XAI, machine learning explainability, responsible ai"> </p>
|
|
11
|
+
|
|
12
|
+
## Abstract
|
|
13
|
+
<p align="justify">Machine learning is currently undergoing an explosion in capability, popularity, and sophistication. However, one of the major barriers to widespread acceptance of machine learning (ML) is trustworthiness: most ML models operate as black boxes, their inner workings opaque and mysterious, and it can be difficult to trust their conclusions without understanding how those conclusions are reached. Explainability is therefore a key aspect of improving trustworthiness: the ability to better understand, interpret, and anticipate the behaviour of ML models. To this end, we propose a SMILE, a new method that builds on previous approaches by making use of statistical distance measures to improve explainability while remaining applicable to a wide range of input data domains.</p>
|
|
14
|
+
|
|
15
|
+
<!--
|
|
16
|
+
## Installation
|
|
17
|
+
```
|
|
18
|
+
pip install xwhy
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Simple Example
|
|
22
|
+
```
|
|
23
|
+
import xwhy
|
|
24
|
+
import xgboost
|
|
25
|
+
|
|
26
|
+
# train an XGBoost model
|
|
27
|
+
X, y = xwhy.datasets.boston()
|
|
28
|
+
model = xgboost.XGBRegressor().fit(X, y)
|
|
29
|
+
|
|
30
|
+
# explain the model's predictions using xwhy
|
|
31
|
+
# (same syntax works for LightGBM, CatBoost, scikit-learn, transformers, Spark, etc.)
|
|
32
|
+
explainer = xwhy.Explainer(model)
|
|
33
|
+
xwhy_values = explainer(X)
|
|
34
|
+
|
|
35
|
+
# visualize the first prediction's explanation
|
|
36
|
+
xwhy.plots.waterfall(xwhy_values[0])
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
-->
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Citations
|
|
44
|
+
It would be appreciated a citation to our paper as follows if you use X-Why for your research:
|
|
45
|
+
```
|
|
46
|
+
@article{Aslansefat2021Xwhy,
|
|
47
|
+
author = {{Aslansefat}, Koorosh and {Hashemian}, Mojgan and {Martin}, Walker, {Akram} Mohammed Naveed, {Sorokos} Ioannis and {Papadopoulos}, Yiannis},
|
|
48
|
+
title = "{SMILE: Statistical Model-agnostic Interpretability with Local Explanations}",
|
|
49
|
+
journal = {arXiv e-prints},
|
|
50
|
+
year = {2021},
|
|
51
|
+
url = {https://arxiv.org/abs/...},
|
|
52
|
+
eprint = {},
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Acknowledgment
|
|
57
|
+
<p align="justify">This project is supported by the <a href = "https://www.sesame-project.org"><b>Secure and Safe Multi-Robot Systems (SESAME)</b></a> H2020 Project under Grant Agreement 101017258.</p>
|
|
58
|
+
|
|
59
|
+
## Awards
|
|
60
|
+
<a href = "https://www.turing.ac.uk/post-doctoral-enrichment-awards-pdea">Post-Doctoral Enrichment Award from the Alan Turing Institute</a>
|
|
61
|
+
|
|
62
|
+
## Contribution
|
|
63
|
+
If you are interested in contributing to this project, please check the [contribution guidelines](https://github.com/koo-ec/xwhy/blob/main/docs/contribute/contributing.md).
|
xwhy-0.0.0.1/setup.cfg
ADDED
xwhy-0.0.0.1/setup.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(name='xwhy',
|
|
4
|
+
version='0.0.0.1',
|
|
5
|
+
description='Explain Why (XWhy) with Statistical Model-agnostic Interpretability with Local Explanations (SMILE)',
|
|
6
|
+
url='https://github.com/Dependable-Intelligent-Systems-Lab/xwhy',
|
|
7
|
+
author='Mojgan Hashemian, Koorosh Aslansefat (corresponding), Mohammad Naveed Akram, Ioannis Sorokos, Martin Walker, Yiannis Papadopoulos',
|
|
8
|
+
author_email='koo.ec2008@gmail.com',
|
|
9
|
+
license='BSD',
|
|
10
|
+
packages=find_packages(exclude=['js', 'node_modules', 'tests']),
|
|
11
|
+
python_requires='>=3.5',
|
|
12
|
+
install_requires=[
|
|
13
|
+
'matplotlib',
|
|
14
|
+
'numpy',
|
|
15
|
+
'scipy',
|
|
16
|
+
'tqdm >= 4.29.1',
|
|
17
|
+
'scikit-learn>=0.18',
|
|
18
|
+
'scikit-image>=0.12',
|
|
19
|
+
'pyDOE2==1.3.0',
|
|
20
|
+
'twine==1.13.0'
|
|
21
|
+
],
|
|
22
|
+
extras_require={
|
|
23
|
+
'dev': ['pytest', 'flake8'],
|
|
24
|
+
},
|
|
25
|
+
include_package_data=True,
|
|
26
|
+
zip_safe=False)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# This file is a modified copy of SHAP package.py with some modification
|
|
2
|
+
# source: https://github.com/slundberg/shap/blob/master/shap/datasets.py
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import numpy as np
|
|
6
|
+
import sklearn.datasets
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from urllib.request import urlretrieve
|
|
11
|
+
except ImportError:
|
|
12
|
+
from urllib import urlretrieve
|
|
13
|
+
|
|
14
|
+
github_data_url = "https://github.com/koo-ec/smile/tree/main/data"
|
|
15
|
+
|
|
16
|
+
def imagenet50(display=False, resolution=224):
|
|
17
|
+
""" This is a set of 50 images representative of ImageNet images.
|
|
18
|
+
This dataset was collected by randomly finding a working ImageNet link and then pasting the
|
|
19
|
+
original ImageNet image into Google image search restricted to images licensed for reuse. A
|
|
20
|
+
similar image (now with rights to reuse) was downloaded as a rough replacment for the original
|
|
21
|
+
ImageNet image. The point is to have a random sample of ImageNet for use as a background
|
|
22
|
+
distribution for explaining models trained on ImageNet data.
|
|
23
|
+
Note that because the images are only rough replacements the labels might no longer be correct.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
prefix = github_data_url + "imagenet50_"
|
|
27
|
+
X = np.load(cache(prefix + "%sx%s.npy" % (resolution, resolution))).astype(np.float32)
|
|
28
|
+
y = np.loadtxt(cache(prefix + "labels.csv"))
|
|
29
|
+
return X, y
|
|
30
|
+
|
|
31
|
+
def boston(display=False):
|
|
32
|
+
""" Return the boston housing price prediction data, and here is the list of features:
|
|
33
|
+
CRIM: Per capita crime rate by town
|
|
34
|
+
ZN: Proportion of residential land zoned for lots over 25,000 sq. ft
|
|
35
|
+
INDUS: Proportion of non-retail business acres per town
|
|
36
|
+
CHAS: Charles River dummy variable (= 1 if tract bounds river; 0 otherwise)
|
|
37
|
+
NOX: Nitric oxide concentration (parts per 10 million)RM: Average number of rooms per dwelling
|
|
38
|
+
AGE: Proportion of owner-occupied units built prior to 1940DIS: Weighted distances to five Boston employment centers
|
|
39
|
+
RAD: Index of accessibility to radial highwaysTAX: Full-value property tax rate per $10,000
|
|
40
|
+
PTRATIO: Pupil-teacher ratio by townB: 1000(Bk — 0.63)², where Bk is the proportion of [people of African American descent] by town
|
|
41
|
+
LSTAT: Percentage of lower status of the populationMEDV: Median value of owner-occupied homes in $1000s
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
d = sklearn.datasets.load_boston()
|
|
45
|
+
df = pd.DataFrame(data=d.data, columns=d.feature_names) # pylint: disable=E1101
|
|
46
|
+
return df, d.target # pylint: disable=E1101
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def linnerud(display=False):
|
|
50
|
+
""" Return the linnerud data in a nice package (multi-target regression). """
|
|
51
|
+
|
|
52
|
+
d = sklearn.datasets.load_linnerud()
|
|
53
|
+
X = pd.DataFrame(d.data, columns=d.feature_names) # pylint: disable=E1101
|
|
54
|
+
y = pd.DataFrame(d.target, columns=d.target_names) # pylint: disable=E1101
|
|
55
|
+
return X, y # pylint: disable=E1101
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def imdb(display=False):
|
|
59
|
+
""" Return the clssic IMDB sentiment analysis training data in a nice package.
|
|
60
|
+
Full data is at: http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
|
|
61
|
+
Paper to cite when using the data is: http://www.aclweb.org/anthology/P11-1015
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
with open(cache(github_data_url + "imdb_train.txt")) as f:
|
|
65
|
+
data = f.readlines()
|
|
66
|
+
y = np.ones(25000, dtype=np.bool)
|
|
67
|
+
y[:12500] = 0
|
|
68
|
+
return data, y
|
|
69
|
+
|
|
70
|
+
def communitiesandcrime(display=False):
|
|
71
|
+
""" Predict total number of non-violent crimes per 100K popuation.
|
|
72
|
+
This dataset is from the classic UCI Machine Learning repository:
|
|
73
|
+
https://archive.ics.uci.edu/ml/datasets/Communities+and+Crime+Unnormalized
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
raw_data = pd.read_csv(
|
|
77
|
+
cache(github_data_url + "CommViolPredUnnormalizedData.txt"),
|
|
78
|
+
na_values="?"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# find the indices where the total violent crimes are known
|
|
82
|
+
valid_inds = np.where(np.invert(np.isnan(raw_data.iloc[:,-2])))[0]
|
|
83
|
+
y = np.array(raw_data.iloc[valid_inds,-2], dtype=np.float)
|
|
84
|
+
|
|
85
|
+
# extract the predictive features and remove columns with missing values
|
|
86
|
+
X = raw_data.iloc[valid_inds,5:-18]
|
|
87
|
+
valid_cols = np.where(np.isnan(X.values).sum(0) == 0)[0]
|
|
88
|
+
X = X.iloc[:,valid_cols]
|
|
89
|
+
|
|
90
|
+
return X, y
|
|
91
|
+
|
|
92
|
+
def diabetes(display=False):
|
|
93
|
+
""" Return the diabetes data in a nice package. """
|
|
94
|
+
|
|
95
|
+
d = sklearn.datasets.load_diabetes()
|
|
96
|
+
df = pd.DataFrame(data=d.data, columns=d.feature_names) # pylint: disable=E1101
|
|
97
|
+
return df, d.target # pylint: disable=E1101
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def iris(display=False):
|
|
101
|
+
""" Return the classic iris data in a nice package. """
|
|
102
|
+
|
|
103
|
+
d = sklearn.datasets.load_iris()
|
|
104
|
+
df = pd.DataFrame(data=d.data, columns=d.feature_names) # pylint: disable=E1101
|
|
105
|
+
if display:
|
|
106
|
+
return df, [d.target_names[v] for v in d.target] # pylint: disable=E1101
|
|
107
|
+
else:
|
|
108
|
+
return df, d.target # pylint: disable=E1101
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def adult(display=False):
|
|
112
|
+
""" Return the Adult census data in a nice package. """
|
|
113
|
+
dtypes = [
|
|
114
|
+
("Age", "float32"), ("Workclass", "category"), ("fnlwgt", "float32"),
|
|
115
|
+
("Education", "category"), ("Education-Num", "float32"), ("Marital Status", "category"),
|
|
116
|
+
("Occupation", "category"), ("Relationship", "category"), ("Race", "category"),
|
|
117
|
+
("Sex", "category"), ("Capital Gain", "float32"), ("Capital Loss", "float32"),
|
|
118
|
+
("Hours per week", "float32"), ("Country", "category"), ("Target", "category")
|
|
119
|
+
]
|
|
120
|
+
raw_data = pd.read_csv(
|
|
121
|
+
cache(github_data_url + "adult.data"),
|
|
122
|
+
names=[d[0] for d in dtypes],
|
|
123
|
+
na_values="?",
|
|
124
|
+
dtype=dict(dtypes)
|
|
125
|
+
)
|
|
126
|
+
data = raw_data.drop(["Education"], axis=1) # redundant with Education-Num
|
|
127
|
+
filt_dtypes = list(filter(lambda x: not (x[0] in ["Target", "Education"]), dtypes))
|
|
128
|
+
data["Target"] = data["Target"] == " >50K"
|
|
129
|
+
rcode = {
|
|
130
|
+
"Not-in-family": 0,
|
|
131
|
+
"Unmarried": 1,
|
|
132
|
+
"Other-relative": 2,
|
|
133
|
+
"Own-child": 3,
|
|
134
|
+
"Husband": 4,
|
|
135
|
+
"Wife": 5
|
|
136
|
+
}
|
|
137
|
+
for k, dtype in filt_dtypes:
|
|
138
|
+
if dtype == "category":
|
|
139
|
+
if k == "Relationship":
|
|
140
|
+
data[k] = np.array([rcode[v.strip()] for v in data[k]])
|
|
141
|
+
else:
|
|
142
|
+
data[k] = data[k].cat.codes
|
|
143
|
+
|
|
144
|
+
if display:
|
|
145
|
+
return raw_data.drop(["Education", "Target", "fnlwgt"], axis=1), data["Target"].values
|
|
146
|
+
else:
|
|
147
|
+
return data.drop(["Target", "fnlwgt"], axis=1), data["Target"].values
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def nhanesi(display=False):
|
|
151
|
+
""" A nicely packaged version of NHANES I data with surivival times as labels.
|
|
152
|
+
"""
|
|
153
|
+
X = pd.read_csv(cache(github_data_url + "NHANESI_X.csv"), index_col=0)
|
|
154
|
+
y = pd.read_csv(cache(github_data_url + "NHANESI_y.csv"), index_col=0)["y"]
|
|
155
|
+
if display:
|
|
156
|
+
X_display = X.copy()
|
|
157
|
+
#X_display["sex_isFemale"] = ["Female" if v else "Male" for v in X["sex_isFemale"]]
|
|
158
|
+
return X_display, np.array(y)
|
|
159
|
+
else:
|
|
160
|
+
return X, np.array(y)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def corrgroups60(display=False):
|
|
164
|
+
""" Correlated Groups 60
|
|
165
|
+
|
|
166
|
+
A simulated dataset with tight correlations among distinct groups of features.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
# set a constant seed
|
|
170
|
+
old_seed = np.random.seed()
|
|
171
|
+
np.random.seed(0)
|
|
172
|
+
|
|
173
|
+
# generate dataset with known correlation
|
|
174
|
+
N = 1000
|
|
175
|
+
M = 60
|
|
176
|
+
|
|
177
|
+
# set one coefficent from each group of 3 to 1
|
|
178
|
+
beta = np.zeros(M)
|
|
179
|
+
beta[0:30:3] = 1
|
|
180
|
+
|
|
181
|
+
# build a correlation matrix with groups of 3 tightly correlated features
|
|
182
|
+
C = np.eye(M)
|
|
183
|
+
for i in range(0,30,3):
|
|
184
|
+
C[i,i+1] = C[i+1,i] = 0.99
|
|
185
|
+
C[i,i+2] = C[i+2,i] = 0.99
|
|
186
|
+
C[i+1,i+2] = C[i+2,i+1] = 0.99
|
|
187
|
+
f = lambda X: np.matmul(X, beta)
|
|
188
|
+
|
|
189
|
+
# Make sure the sample correlation is a perfect match
|
|
190
|
+
X_start = np.random.randn(N, M)
|
|
191
|
+
X_centered = X_start - X_start.mean(0)
|
|
192
|
+
Sigma = np.matmul(X_centered.T, X_centered) / X_centered.shape[0]
|
|
193
|
+
W = np.linalg.cholesky(np.linalg.inv(Sigma)).T
|
|
194
|
+
X_white = np.matmul(X_centered, W.T)
|
|
195
|
+
assert np.linalg.norm(np.corrcoef(np.matmul(X_centered, W.T).T) - np.eye(M)) < 1e-6 # ensure this decorrelates the data
|
|
196
|
+
|
|
197
|
+
# create the final data
|
|
198
|
+
X_final = np.matmul(X_white, np.linalg.cholesky(C).T)
|
|
199
|
+
X = X_final
|
|
200
|
+
y = f(X) + np.random.randn(N) * 1e-2
|
|
201
|
+
|
|
202
|
+
# restore the previous numpy random seed
|
|
203
|
+
np.random.seed(old_seed)
|
|
204
|
+
|
|
205
|
+
return pd.DataFrame(X), y
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def independentlinear60(display=False):
|
|
209
|
+
"""
|
|
210
|
+
A simulated dataset with tight correlations among distinct groups of features.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
# set a constant seed
|
|
214
|
+
old_seed = np.random.seed()
|
|
215
|
+
np.random.seed(0)
|
|
216
|
+
|
|
217
|
+
# generate dataset with known correlation
|
|
218
|
+
N = 1000
|
|
219
|
+
M = 60
|
|
220
|
+
|
|
221
|
+
# set one coefficent from each group of 3 to 1
|
|
222
|
+
beta = np.zeros(M)
|
|
223
|
+
beta[0:30:3] = 1
|
|
224
|
+
f = lambda X: np.matmul(X, beta)
|
|
225
|
+
|
|
226
|
+
# Make sure the sample correlation is a perfect match
|
|
227
|
+
X_start = np.random.randn(N, M)
|
|
228
|
+
X = X_start - X_start.mean(0)
|
|
229
|
+
y = f(X) + np.random.randn(N) * 1e-2
|
|
230
|
+
|
|
231
|
+
# restore the previous numpy random seed
|
|
232
|
+
np.random.seed(old_seed)
|
|
233
|
+
|
|
234
|
+
return pd.DataFrame(X), y
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def a1a():
|
|
238
|
+
"""
|
|
239
|
+
A sparse dataset in scipy csr matrix format.
|
|
240
|
+
"""
|
|
241
|
+
return sklearn.datasets.load_svmlight_file(cache(github_data_url + 'a1a.svmlight'))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def rank():
|
|
245
|
+
"""
|
|
246
|
+
Ranking datasets from lightgbm repository.
|
|
247
|
+
"""
|
|
248
|
+
rank_data_url = 'https://raw.githubusercontent.com/Microsoft/LightGBM/master/examples/lambdarank/'
|
|
249
|
+
x_train, y_train = sklearn.datasets.load_svmlight_file(cache(rank_data_url + 'rank.train'))
|
|
250
|
+
x_test, y_test = sklearn.datasets.load_svmlight_file(cache(rank_data_url + 'rank.test'))
|
|
251
|
+
q_train = np.loadtxt(cache(rank_data_url + 'rank.train.query'))
|
|
252
|
+
q_test = np.loadtxt(cache(rank_data_url + 'rank.test.query'))
|
|
253
|
+
return x_train, y_train, x_test, y_test, q_train, q_test
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cache(url, file_name=None):
|
|
257
|
+
if file_name is None:
|
|
258
|
+
file_name = os.path.basename(url)
|
|
259
|
+
data_dir = os.path.join(os.path.dirname(__file__), "cached_data")
|
|
260
|
+
if not os.path.isdir(data_dir):
|
|
261
|
+
os.mkdir(data_dir)
|
|
262
|
+
|
|
263
|
+
file_path = os.path.join(data_dir, file_name)
|
|
264
|
+
if not os.path.isfile(file_path):
|
|
265
|
+
urlretrieve(url, file_path)
|
|
266
|
+
|
|
267
|
+
return file_path
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from matplotlib import pyplot as plt
|
|
3
|
+
from matplotlib.ticker import MultipleLocator
|
|
4
|
+
import scipy.linalg as la
|
|
5
|
+
import networkx as nx
|
|
6
|
+
import random, time, math
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
import fun as f
|
|
10
|
+
from Graph import Graph
|
|
11
|
+
from Watts_Strogatz import watts_strogatz_graph
|
|
12
|
+
from Erdos_Renyi import erdos_renyi_graph
|
|
13
|
+
|
|
14
|
+
from sklearn.linear_model import LinearRegression
|
|
15
|
+
from sklearn.preprocessing import normalize
|
|
16
|
+
|
|
17
|
+
import graph_nets
|
|
18
|
+
from graph_nets.graphs import GraphsTuple
|
|
19
|
+
import graph_attribution as gatt
|
|
20
|
+
|
|
21
|
+
def Wasserstein_Dist(cdfX, cdfY):
|
|
22
|
+
|
|
23
|
+
Res = 0
|
|
24
|
+
power = 1
|
|
25
|
+
n = len(cdfX)
|
|
26
|
+
|
|
27
|
+
for ii in range(0, n-2):
|
|
28
|
+
height = abs(cdfX[ii]-cdfY[ii])
|
|
29
|
+
width = cdfX[ii+1] - cdfX[ii]
|
|
30
|
+
Res = Res + (height ** power) * width
|
|
31
|
+
|
|
32
|
+
return Res
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def r_eigenv(G_i, G_j):
|
|
36
|
+
#Eigen-decomposition of G_j
|
|
37
|
+
A_Gi = (nx.adjacency_matrix(G_i)).todense()
|
|
38
|
+
D_i = np.diag(np.asarray(sum(A_Gi))[0])
|
|
39
|
+
eigenvalues_Gi, eigenvectors_Gi = la.eig(D_i - A_Gi)
|
|
40
|
+
r_eigenv_Gi = sorted(zip(eigenvalues_Gi.real, eigenvectors_Gi.T), key=lambda x: x[0])
|
|
41
|
+
|
|
42
|
+
#Eigen-decomposition of G_j
|
|
43
|
+
A_Gj = (nx.adjacency_matrix(G_j)).todense()
|
|
44
|
+
D_j = np.diag(np.asarray(sum(A_Gj))[0])
|
|
45
|
+
eigenvalues_Gj, eigenvectors_Gj = la.eig(D_j - A_Gj)
|
|
46
|
+
r_eigenv_Gj = sorted(zip(eigenvalues_Gj.real, eigenvectors_Gj.T), key=lambda x: x[0])
|
|
47
|
+
|
|
48
|
+
r = 4
|
|
49
|
+
signs =[-1,1]
|
|
50
|
+
temp = []
|
|
51
|
+
for sign_s in signs:
|
|
52
|
+
for sign_l in signs:
|
|
53
|
+
vri = sorted(f.normalize_eigenv(sign_s * r_eigenv_Gi[r][1]))
|
|
54
|
+
vrj = sorted(f.normalize_eigenv(sign_l * r_eigenv_Gj[r][1]))
|
|
55
|
+
cdf_dist = f.cdf_dist(vri, vrj)
|
|
56
|
+
temp.append(cdf_dist)
|
|
57
|
+
|
|
58
|
+
#Compute empirical CDF
|
|
59
|
+
step = 0.005
|
|
60
|
+
x=np.arange(0, 1, step)
|
|
61
|
+
cdf_grid_Gip = f.cdf(len(r_eigenv_Gi[r][1]),x,
|
|
62
|
+
f.normalize_eigenv(sorted(r_eigenv_Gi[r][1], key=lambda x: x)))
|
|
63
|
+
cdf_grid_Gin = f.cdf(len(r_eigenv_Gi[r][1]),x,
|
|
64
|
+
f.normalize_eigenv(sorted(-r_eigenv_Gi[r][1], key=lambda x: x)))
|
|
65
|
+
|
|
66
|
+
cdf_grid_Gjp = f.cdf(len(r_eigenv_Gj[r][1]),x,
|
|
67
|
+
f.normalize_eigenv(sorted(r_eigenv_Gj[r][1], key=lambda x: x)))
|
|
68
|
+
cdf_grid_Gjn = f.cdf(len(r_eigenv_Gj[r][1]),x,
|
|
69
|
+
f.normalize_eigenv(sorted(-r_eigenv_Gj[r][1], key=lambda x: x)))
|
|
70
|
+
|
|
71
|
+
WD1 = Wasserstein_Dist(cdf_grid_Gip, cdf_grid_Gjp)
|
|
72
|
+
WD2 = Wasserstein_Dist(cdf_grid_Gip, cdf_grid_Gjn)
|
|
73
|
+
WD3 = Wasserstein_Dist(cdf_grid_Gin, cdf_grid_Gjp)
|
|
74
|
+
WD4 = Wasserstein_Dist(cdf_grid_Gin, cdf_grid_Gjn)
|
|
75
|
+
|
|
76
|
+
WD = [WD1, WD2, WD3, WD4]
|
|
77
|
+
|
|
78
|
+
return max(temp), max(WD)
|
|
79
|
+
|
|
80
|
+
def xwhy_graph_edges(X_input_graph, model, num_perturb = 50, kernel_width = 0.25, num_top_features = 10, eps=1):
|
|
81
|
+
#num_perturb = 5000
|
|
82
|
+
|
|
83
|
+
num_uniqe_words = len(X_input_graph.edges)
|
|
84
|
+
|
|
85
|
+
perturbations = np.random.binomial(1, 0.5, size=(num_perturb, num_uniqe_words))
|
|
86
|
+
|
|
87
|
+
def perturb_graph_edge(input_graph, perturbation):
|
|
88
|
+
perturbed_graph = input_graph.copy()
|
|
89
|
+
ebun = []
|
|
90
|
+
for ii, ed in enumerate(perturbed_graph.edges):
|
|
91
|
+
if perturbation[ii] == 0:
|
|
92
|
+
ebun.append(ed)
|
|
93
|
+
perturbed_graph.remove_edges_from(ebun)
|
|
94
|
+
return perturbed_graph
|
|
95
|
+
|
|
96
|
+
predictions = []
|
|
97
|
+
WD_dist = []
|
|
98
|
+
for pert in perturbations:
|
|
99
|
+
p_graph = perturb_graph_edge(X_input_graph, pert)
|
|
100
|
+
pred = model.predict(graph_nets.utils_np.networkxs_to_graphs_tuple([p_graph]))
|
|
101
|
+
predictions.append(pred)
|
|
102
|
+
Sscore, WD_score = r_eigenv(X_input_graph, p_graph)
|
|
103
|
+
WD_dist.append(WD_score)
|
|
104
|
+
|
|
105
|
+
predictions = np.array(predictions)
|
|
106
|
+
WD_dist = np.array(WD_dist)
|
|
107
|
+
|
|
108
|
+
weights = np.sqrt(np.exp(-((eps*WD_dist)**2)/kernel_width**2)) #Kernel function
|
|
109
|
+
|
|
110
|
+
class_to_explain = 0
|
|
111
|
+
simpler_model = LinearRegression()
|
|
112
|
+
simpler_model.fit(X=perturbations, y=predictions, sample_weight=weights)
|
|
113
|
+
coeff = simpler_model.coef_[0]
|
|
114
|
+
|
|
115
|
+
top_features = np.argsort(abs(coeff))[-num_top_features:]
|
|
116
|
+
|
|
117
|
+
coeff2 = simpler_model.coef_
|
|
118
|
+
|
|
119
|
+
odds = np.exp(coeff2)
|
|
120
|
+
|
|
121
|
+
Bounded_coeff = 2*(normalize(coeff[:,np.newaxis], axis=0).ravel()+1)
|
|
122
|
+
|
|
123
|
+
return coeff, odds, top_features, Bounded_coeff, simpler_model
|
|
124
|
+
|
|
125
|
+
def perturb_graph_node(input_graph, perturbation, Remove_Zero_Degree_Nodes=False):
|
|
126
|
+
"""
|
|
127
|
+
This function modifies an input graph by removing nodes based on a specified perturbation.
|
|
128
|
+
|
|
129
|
+
Parameters:
|
|
130
|
+
- input_graph (networkx.Graph): The input graph to be perturbed.
|
|
131
|
+
- perturbation (List[List[int]]): A 2D list of 0/1 values indicating whether each node in the input graph should be removed (1) or not (0).
|
|
132
|
+
- Remove_Zero_Degree_Nodes (bool): Indicates whether nodes with zero degree should be removed (True) or not (False).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
- perturbed_node_graph_reordered (networkx.Graph): The perturbed graph with reordered node labels.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
- ValueError: If the length of `perturbation` does not match the number of nodes in `input_graph`.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
if len(perturbation[0]) != len(input_graph.nodes):
|
|
142
|
+
raise ValueError("The length of `perturbation` must match the number of nodes in `input_graph`.")
|
|
143
|
+
|
|
144
|
+
perturbed_node_graph = input_graph.copy()
|
|
145
|
+
ebun = []
|
|
146
|
+
for ii, nd in enumerate(perturbed_node_graph.nodes):
|
|
147
|
+
if perturbation[0][ii] == 1:
|
|
148
|
+
ebun.append(nd)
|
|
149
|
+
perturbed_node_graph.remove_nodes_from(ebun)
|
|
150
|
+
|
|
151
|
+
if Remove_Zero_Degree_Nodes:
|
|
152
|
+
Zdegree_nodes = []
|
|
153
|
+
for n, d in perturbed_node_graph.degree():
|
|
154
|
+
if d == 0:
|
|
155
|
+
Zdegree_nodes.append(n)
|
|
156
|
+
|
|
157
|
+
perturbed_node_graph.remove_nodes_from(Zdegree_nodes)
|
|
158
|
+
|
|
159
|
+
perturbed_node_graph_reordered = nx.convert_node_labels_to_integers(perturbed_node_graph, first_label=0)
|
|
160
|
+
|
|
161
|
+
return perturbed_node_graph_reordered
|
|
162
|
+
|
|
163
|
+
def perturb_graph_edge(input_graph, perturbation):
|
|
164
|
+
"""
|
|
165
|
+
This function modifies an input graph by removing edges based on a specified perturbation.
|
|
166
|
+
|
|
167
|
+
Parameters:
|
|
168
|
+
- input_graph (networkx.Graph): The input graph to be perturbed.
|
|
169
|
+
- perturbation (List[int]): A list of 0/1 values indicating whether each edge in the input graph should be removed (1) or not (0).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
- perturbed_graph (networkx.Graph): The perturbed graph.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
- ValueError: If the length of `perturbation` does not match the number of edges in `input_graph`.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
if len(perturbation) != len(input_graph.edges):
|
|
179
|
+
raise ValueError("The length of `perturbation` must match the number of edges in `input_graph`.")
|
|
180
|
+
|
|
181
|
+
perturbed_graph = input_graph.copy()
|
|
182
|
+
ebun = []
|
|
183
|
+
for ii, ed in enumerate(perturbed_graph.edges):
|
|
184
|
+
if perturbation[ii] == 1:
|
|
185
|
+
ebun.append(ed)
|
|
186
|
+
perturbed_graph.remove_edges_from(ebun)
|
|
187
|
+
|
|
188
|
+
return perturbed_graph
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def explain_graph_nodes(input_graph, model, num_perturbations=5000, kernel_width=0.25, num_top_features=10, epsilon=1, remove_zero_degree_nodes=False):
|
|
192
|
+
"""
|
|
193
|
+
This function explains the contribution of each node to the prediction of a given graph-based model.
|
|
194
|
+
It uses a white-box model interpretation method XWhy to calculate the contribution of each node by
|
|
195
|
+
perturbing the input graph and measuring the change in the model's prediction.
|
|
196
|
+
|
|
197
|
+
Parameters:
|
|
198
|
+
input_graph (nx.Graph) : A networkx graph representing the input graph.
|
|
199
|
+
model (sklearn.BaseEstimator) : The graph-based model to be explained.
|
|
200
|
+
num_perturbations (int) : The number of perturbations to be applied to the input graph (default is 5000).
|
|
201
|
+
kernel_width (float) : The width of the kernel function (default is 0.25).
|
|
202
|
+
num_top_features (int) : The number of top features to be returned (default is 10).
|
|
203
|
+
epsilon (float) : The hyperparameter for the kernel function (default is 1).
|
|
204
|
+
remove_zero_degree_nodes (bool) : A flag indicating whether to remove nodes with zero degree after perturbation (default is False).
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
coeff (np.ndarray) : The coefficients of the linear regression model that explains the relationship between the node perturbations and the change in prediction.
|
|
208
|
+
odds (np.ndarray) : The odds ratio of the linear regression model.
|
|
209
|
+
top_features (np.ndarray) : The indices of the top `num_top_features` features based on the absolute values of the coefficients.
|
|
210
|
+
bounded_coeff (np.ndarray) : The normalized coefficients of the linear regression model.
|
|
211
|
+
simpler_model (LinearRegression) : The linear regression model used to explain the relationship between the node perturbations and the change in prediction.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError : If the number of perturbations is not a positive integer.
|
|
215
|
+
ValueError : If the kernel width is not positive.
|
|
216
|
+
ValueError : If the number of top features is not a positive integer.
|
|
217
|
+
ValueError : If the epsilon is not positive.
|
|
218
|
+
"""
|
|
219
|
+
# Validate the input arguments
|
|
220
|
+
if not isinstance(num_perturbations, int) or num_perturbations <= 0:
|
|
221
|
+
raise ValueError("The number of perturbations must be a positive integer.")
|
|
222
|
+
if not isinstance(kernel_width, (int, float)) or kernel_width <= 0:
|
|
223
|
+
raise ValueError("The kernel width must be a positive number.")
|
|
224
|
+
if not isinstance(num_top_features, int) or num_top_features <= 0:
|
|
225
|
+
raise ValueError("The number of top features must be a positive integer.")
|
|
226
|
+
if not isinstance(epsilon, (int, float)) or epsilon <= 0:
|
|
227
|
+
raise ValueError("The epsilon must be a positive number.")
|
|
228
|
+
|
|
229
|
+
num_uniqe_nodes = len(X_input_graph.nodes)
|
|
230
|
+
|
|
231
|
+
perturbations = np.random.binomial(1, 0.5, size=(num_perturb, num_uniqe_nodes))
|
|
232
|
+
|
|
233
|
+
predictions = []
|
|
234
|
+
WD_dist = []
|
|
235
|
+
for pert in perturbations:
|
|
236
|
+
p_graph = perturb_graph_node(X_input_graph, pert)
|
|
237
|
+
if len(p_graph.nodes) != 0:
|
|
238
|
+
pred = model.predict(graph_nets.utils_np.networkxs_to_graphs_tuple([p_graph]))
|
|
239
|
+
predictions.append(pred)
|
|
240
|
+
Sscore, WD_score = r_eigenv(X_input_graph, p_graph)
|
|
241
|
+
WD_dist.append(WD_score)
|
|
242
|
+
|
|
243
|
+
predictions = np.array(predictions)
|
|
244
|
+
WD_dist = np.array(WD_dist)
|
|
245
|
+
|
|
246
|
+
weights = np.sqrt(np.exp(-((eps*WD_dist)**2)/kernel_width**2)) #Kernel function
|
|
247
|
+
|
|
248
|
+
class_to_explain = 0
|
|
249
|
+
simpler_model = LinearRegression()
|
|
250
|
+
simpler_model.fit(X=perturbations, y=predictions, sample_weight=weights)
|
|
251
|
+
coeff = simpler_model.coef_[0]
|
|
252
|
+
|
|
253
|
+
top_features = np.argsort(abs(coeff))[-num_top_features:]
|
|
254
|
+
|
|
255
|
+
coeff2 = simpler_model.coef_
|
|
256
|
+
|
|
257
|
+
odds = np.exp(coeff2)
|
|
258
|
+
|
|
259
|
+
from sklearn.preprocessing import normalize
|
|
260
|
+
|
|
261
|
+
Bounded_coeff = normalize(coeff[:,np.newaxis], axis=0).ravel()
|
|
262
|
+
|
|
263
|
+
return coeff, odds, top_features, Bounded_coeff, simpler_model
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import keras
|
|
3
|
+
from keras.applications.imagenet_utils import decode_predictions
|
|
4
|
+
import skimage.io
|
|
5
|
+
import skimage.segmentation
|
|
6
|
+
import copy
|
|
7
|
+
import sklearn
|
|
8
|
+
import sklearn.metrics
|
|
9
|
+
from sklearn.linear_model import LinearRegression
|
|
10
|
+
|
|
11
|
+
def Wasserstein_Dist(XX, YY):
|
|
12
|
+
|
|
13
|
+
'''
|
|
14
|
+
Wasserstein_Dist_PVal is for Wasserstein distance measure with Boostrap-based p-value calculation.
|
|
15
|
+
The p-Value can be used to validate statistical distance measures.
|
|
16
|
+
|
|
17
|
+
XX: The first input vector. It should be a numpy array with length of n.
|
|
18
|
+
YY: The second input vector. It should be a numpy array with lenght of m.
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
nx = len(XX)
|
|
23
|
+
ny = len(YY)
|
|
24
|
+
n = nx + ny
|
|
25
|
+
|
|
26
|
+
XY = np.concatenate([XX,YY])
|
|
27
|
+
X2 = np.concatenate([np.repeat(1/nx, nx), np.repeat(0, ny)])
|
|
28
|
+
Y2 = np.concatenate([np.repeat(0, nx), np.repeat(1/ny, ny)])
|
|
29
|
+
|
|
30
|
+
S_Ind = np.argsort(XY)
|
|
31
|
+
XY_Sorted = XY[S_Ind]
|
|
32
|
+
X2_Sorted = X2[S_Ind]
|
|
33
|
+
Y2_Sorted = Y2[S_Ind]
|
|
34
|
+
|
|
35
|
+
Res = 0
|
|
36
|
+
E_CDF = 0
|
|
37
|
+
F_CDF = 0
|
|
38
|
+
power = 1
|
|
39
|
+
|
|
40
|
+
for ii in range(0, n-2):
|
|
41
|
+
E_CDF = E_CDF + X2_Sorted[ii]
|
|
42
|
+
F_CDF = F_CDF + Y2_Sorted[ii]
|
|
43
|
+
height = abs(F_CDF-E_CDF)
|
|
44
|
+
width = XY_Sorted[ii+1] - XY_Sorted[ii]
|
|
45
|
+
Res = Res + (height ** power) * width;
|
|
46
|
+
|
|
47
|
+
return Res
|
|
48
|
+
|
|
49
|
+
def Wasserstein_Dist_PVal(XX, YY):
|
|
50
|
+
# Information about Bootstrap:
|
|
51
|
+
# https://towardsdatascience.com/an-introduction-to-the-bootstrap-method-58bcb51b4d60
|
|
52
|
+
import random
|
|
53
|
+
nboots = 1000
|
|
54
|
+
WD = Wasserstein_Dist(XX,YY)
|
|
55
|
+
na = len(XX)
|
|
56
|
+
nb = len(YY)
|
|
57
|
+
n = na + nb
|
|
58
|
+
comb = np.concatenate([XX,YY])
|
|
59
|
+
reps = 0
|
|
60
|
+
bigger = 0
|
|
61
|
+
for ii in range(1, nboots):
|
|
62
|
+
e = random.sample(range(n), na)
|
|
63
|
+
f = random.sample(range(n), nb)
|
|
64
|
+
boost_WD = Wasserstein_Dist(comb[e],comb[f]);
|
|
65
|
+
if (boost_WD > WD):
|
|
66
|
+
bigger = 1 + bigger
|
|
67
|
+
|
|
68
|
+
pVal = bigger/nboots;
|
|
69
|
+
|
|
70
|
+
return pVal, WD
|
|
71
|
+
|
|
72
|
+
def Wasserstein_Dist_Image(img1, img2):
|
|
73
|
+
if img1.shape[0] != img2.shape[0] or img1.shape[1] != img2.shape[1]:
|
|
74
|
+
print('input images should have the same size')
|
|
75
|
+
else:
|
|
76
|
+
WD = []
|
|
77
|
+
for ii in range(3):
|
|
78
|
+
|
|
79
|
+
im1 = np.array(img1[:,:,ii].flatten())
|
|
80
|
+
im2 = np.array(img2[:,:,ii].flatten())
|
|
81
|
+
|
|
82
|
+
WD.append(Wasserstein_Dist(im1, im2))
|
|
83
|
+
|
|
84
|
+
return sum(WD)
|
|
85
|
+
|
|
86
|
+
def xwhy_image2(X_input, model, perturbations, num_perturb = 150, kernel_width = 0.25):
|
|
87
|
+
|
|
88
|
+
superpixels = skimage.segmentation.quickshift(X_input, kernel_size=4,max_dist=200, ratio=0.2)
|
|
89
|
+
num_superpixels = np.unique(superpixels).shape[0]
|
|
90
|
+
#perturbations = np.random.binomial(1, 0.5, size=(num_perturb, num_superpixels))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def perturb_image(img,perturbation,segments):
|
|
94
|
+
active_pixels = np.where(perturbation == 1)[0]
|
|
95
|
+
mask = np.zeros(segments.shape)
|
|
96
|
+
for active in active_pixels:
|
|
97
|
+
mask[segments == active] = 1
|
|
98
|
+
perturbed_image = copy.deepcopy(img)
|
|
99
|
+
perturbed_image = perturbed_image*mask[:,:,np.newaxis]
|
|
100
|
+
return perturbed_image
|
|
101
|
+
|
|
102
|
+
predictions = []
|
|
103
|
+
WD_dist = []
|
|
104
|
+
for pert in perturbations:
|
|
105
|
+
perturbed_img = perturb_image(X_input,pert,superpixels)
|
|
106
|
+
pred = model.predict(perturbed_img[np.newaxis,:,:,:])
|
|
107
|
+
predictions.append(pred)
|
|
108
|
+
WD_dist = Wasserstein_Dist_Image(X_input, perturbed_img)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
predictions = np.array(predictions)
|
|
112
|
+
|
|
113
|
+
original_image = np.ones(num_superpixels)[np.newaxis,:] #Perturbation with all superpixels enabled
|
|
114
|
+
# distances = sklearn.metrics.pairwise_distances(perturbations,original_image, metric='cosine').ravel()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
weights = np.sqrt(np.exp(-(WD_dist**2)/kernel_width**2)) #Kernel function
|
|
118
|
+
|
|
119
|
+
preds = model.predict(X_input[np.newaxis,:,:,:])
|
|
120
|
+
decode_predictions(preds)
|
|
121
|
+
top_pred_classes = preds[0].argsort()[-5:][::-1]
|
|
122
|
+
|
|
123
|
+
class_to_explain = top_pred_classes[0]
|
|
124
|
+
simpler_model = LinearRegression()
|
|
125
|
+
simpler_model.fit(X=perturbations, y=predictions[:,:,class_to_explain], sample_weight=weights)
|
|
126
|
+
coeff = simpler_model.coef_[0]
|
|
127
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# reserved for future developement
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import sklearn
|
|
4
|
+
import sklearn.metrics
|
|
5
|
+
from sklearn.linear_model import LinearRegression
|
|
6
|
+
from SafeML.Wasserstein_Dist_PVal import Wasserstein_Dist
|
|
7
|
+
import SafeML
|
|
8
|
+
|
|
9
|
+
def WasserstainLIME2(X_input, model, num_perturb = 500, L_num_perturb = 100, kernel_width2 = 0.75, epsilon = 0.1):
|
|
10
|
+
'''
|
|
11
|
+
WasserstainLIME is a statistical version of LIME (local interpretable model-agnostic explanations)
|
|
12
|
+
in which instead of Euclidean distance, the ECDF-based distance is used.
|
|
13
|
+
|
|
14
|
+
X_input: should be a numpy array that represents one point in a n-dimensional space.
|
|
15
|
+
|
|
16
|
+
num_perturb: Is the number of perturbations that the algorithm uses.
|
|
17
|
+
|
|
18
|
+
L_num_perturb: Is the number of perturbations in the local areas that the algorithm uses.
|
|
19
|
+
|
|
20
|
+
kernel_width: Is the Kernel Width. When the decision space is very dynamic, the kernel width should be low like 0.2,
|
|
21
|
+
otherwise kernel with around 0.75 would be ideal.
|
|
22
|
+
|
|
23
|
+
model: It is the trained model that can be for a classification or regression.
|
|
24
|
+
|
|
25
|
+
epsilon: It is used to normalize the WD values.
|
|
26
|
+
|
|
27
|
+
'''
|
|
28
|
+
|
|
29
|
+
X_input = (X_input - np.mean(X_input,axis=0)) / np.std(X_input,axis=0) #Standarization of data
|
|
30
|
+
|
|
31
|
+
X_lime = np.random.normal(0,1,size=(num_perturb,X_input.shape[0]))
|
|
32
|
+
|
|
33
|
+
Xi2 = np.zeros((L_num_perturb,X_input.shape[0]))
|
|
34
|
+
|
|
35
|
+
for jj in range(X_input.shape[0]):
|
|
36
|
+
Xi2[:,jj] = X_input[jj] + np.random.normal(0,0.05,L_num_perturb)
|
|
37
|
+
|
|
38
|
+
y_lime2 = np.zeros((num_perturb,1))
|
|
39
|
+
WD = np.zeros((num_perturb,1))
|
|
40
|
+
weights2 = np.zeros((num_perturb,1))
|
|
41
|
+
|
|
42
|
+
for ind, ii in enumerate(X_lime):
|
|
43
|
+
|
|
44
|
+
df2 = pd.DataFrame()
|
|
45
|
+
|
|
46
|
+
for jj in range(X_input.shape[0]):
|
|
47
|
+
temp1 = ii[jj] + np.random.normal(0,0.3,L_num_perturb)
|
|
48
|
+
df2[len(df2.columns)] = temp1
|
|
49
|
+
|
|
50
|
+
temp3 = model.predict(df2.to_numpy())
|
|
51
|
+
|
|
52
|
+
y_lime2[ind] = np.mean(temp3) # For classification: np.argmax(np.bincount(temp3))
|
|
53
|
+
|
|
54
|
+
WD1 = np.zeros((X_input.shape[0],1))
|
|
55
|
+
|
|
56
|
+
df2 = df2.to_numpy()
|
|
57
|
+
|
|
58
|
+
for kk in range(X_input.shape[0]):
|
|
59
|
+
#print( df2.shape)
|
|
60
|
+
WD1[kk] = Wasserstein_Dist(Xi2[:,kk], df2[:,kk])
|
|
61
|
+
|
|
62
|
+
#print(WD1)
|
|
63
|
+
#print(ind)
|
|
64
|
+
WD[ind] = sum(WD1)
|
|
65
|
+
#print(WD)
|
|
66
|
+
|
|
67
|
+
weights2[ind] = np.sqrt(np.exp(-((epsilon*WD[ind])**2)/(kernel_width2**2)))
|
|
68
|
+
#print(weights2[ind])
|
|
69
|
+
|
|
70
|
+
del df2
|
|
71
|
+
|
|
72
|
+
weights2 = weights2.flatten()
|
|
73
|
+
#print(weights2)
|
|
74
|
+
|
|
75
|
+
simpler_model2 = LinearRegression()
|
|
76
|
+
simpler_model2.fit(X_lime, y_lime2, sample_weight=weights2)
|
|
77
|
+
y_linmodel2 = simpler_model2.predict(X_lime)
|
|
78
|
+
y_linmodel2 = y_linmodel2 < 0.5 #Conver to binary class
|
|
79
|
+
y_linmodel2 = y_linmodel2.flatten()
|
|
80
|
+
|
|
81
|
+
return X_lime, y_lime2, weights2, y_linmodel2, simpler_model2.coef_.flatten()
|
|
82
|
+
|
|
83
|
+
def WasserstainLIME(X_input, model, num_perturb = 500, kernel_width2 = 0.2):
|
|
84
|
+
|
|
85
|
+
'''
|
|
86
|
+
WasserstainLIME(X_input, num_perturb = 500, kernel_width2 = 0.2):
|
|
87
|
+
|
|
88
|
+
X_input: The input feature data for the WassersteinLIME function. It should be a 1D numpy array with two elements.
|
|
89
|
+
|
|
90
|
+
kernel_width2: The kernel width parameter for the Wasserstein distance calculation. It determines the size of the
|
|
91
|
+
region around the original feature data that is considered for the linear regression model. Larger
|
|
92
|
+
values of kernel_width2 result in a wider region and more perturbations being included in the explanation,
|
|
93
|
+
while smaller values result in a more localized explanation. The default value is 0.2.
|
|
94
|
+
|
|
95
|
+
This function uses Wasserstein distance to generate local explanations for a binary classifier.
|
|
96
|
+
It creates num_perturb number of perturbed versions of the input feature data, and for each perturbation
|
|
97
|
+
it predicts the class probabilities, computes the Wasserstein distances between the original
|
|
98
|
+
and perturbed feature data, and uses the distances to weight a linear regression model that explains the binary predictions.
|
|
99
|
+
The function returns the perturbed feature data, binary predictions, weight of each perturbation,
|
|
100
|
+
coefficients of the linear regression model, and the predictions of the linear regression model.
|
|
101
|
+
'''
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
if not isinstance(X_input, np.ndarray) or X_input.ndim != 2:
|
|
105
|
+
raise TypeError("X_input must be a 2-dimensional array.")
|
|
106
|
+
except TypeError as te:
|
|
107
|
+
print(te)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if not isinstance(num_perturb, int):
|
|
111
|
+
raise ValueError("num_perturb must be an integer.")
|
|
112
|
+
except ValueError as ve:
|
|
113
|
+
print(ve)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if not np.isscalar(kernel_width2):
|
|
117
|
+
raise ValueError("kernel_width2 must be a scalar.")
|
|
118
|
+
except ValueError as ve:
|
|
119
|
+
print(ve)
|
|
120
|
+
|
|
121
|
+
X_lime = np.random.normal(0,1,size=(num_perturb,X_input.shape[1]))
|
|
122
|
+
|
|
123
|
+
Xi2 = np.zeros((100,2))
|
|
124
|
+
Xi2[:,0] = X_input[0] + np.random.normal(0,0.05,100)
|
|
125
|
+
Xi2[:,1] = X_input[1] + np.random.normal(0,0.05,100)
|
|
126
|
+
|
|
127
|
+
y_lime2 = np.zeros((num_perturb,1))
|
|
128
|
+
WD = np.zeros((num_perturb,1))
|
|
129
|
+
weights2 = np.zeros((num_perturb,1))
|
|
130
|
+
|
|
131
|
+
for ind, ii in enumerate(X_lime):
|
|
132
|
+
temp1 = ii[0] + np.random.normal(0,0.4,100)
|
|
133
|
+
temp2 = ii[1] + np.random.normal(0,0.4,100)
|
|
134
|
+
df2 = pd.DataFrame()
|
|
135
|
+
df2['x1'] = temp1
|
|
136
|
+
df2['x2'] = temp2
|
|
137
|
+
temp3 = model.predict(df2)
|
|
138
|
+
y_lime2[ind] = np.argmax(np.bincount(temp3))
|
|
139
|
+
WD1 = Wasserstein_Dist(Xi2[:,0], df2[:]['x1'])
|
|
140
|
+
WD2 = Wasserstein_Dist(Xi2[:,1], df2[:]['x2'])
|
|
141
|
+
WD[ind] = sum([WD1, WD2])
|
|
142
|
+
|
|
143
|
+
weights2[ind] = np.sqrt(np.exp(-(WD[ind]**2)/(kernel_width2**2)))
|
|
144
|
+
|
|
145
|
+
weights2 = weights2.flatten()
|
|
146
|
+
|
|
147
|
+
simpler_model2 = LinearRegression()
|
|
148
|
+
simpler_model2.fit(X_lime, y_lime2, sample_weight=weights2)
|
|
149
|
+
y_linmodel2 = simpler_model2.predict(X_lime)
|
|
150
|
+
y_linmodel2 = y_linmodel2 < 0.5 #Conver to binary class
|
|
151
|
+
y_linmodel2 = y_linmodel2.flatten()
|
|
152
|
+
|
|
153
|
+
return X_lime, y_lime2, weights2, y_linmodel2, simpler_model2.coef_.flatten()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
import lime
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import sklearn
|
|
8
|
+
import sklearn.ensemble
|
|
9
|
+
import sklearn.metrics
|
|
10
|
+
#from __future__ import print_function
|
|
11
|
+
|
|
12
|
+
import sklearn
|
|
13
|
+
import sklearn.metrics
|
|
14
|
+
from sklearn.linear_model import LinearRegression
|
|
15
|
+
from sklearn.datasets import fetch_20newsgroups
|
|
16
|
+
|
|
17
|
+
import string
|
|
18
|
+
import re
|
|
19
|
+
import nltk
|
|
20
|
+
from nltk.tokenize import TweetTokenizer
|
|
21
|
+
|
|
22
|
+
# embd = embedding_google
|
|
23
|
+
|
|
24
|
+
def xwhy_text(X_input_text, model, perturbations, embd, num_perturb = 50, kernel_width = 0.25, num_top_features = 10, eps=0.5):
|
|
25
|
+
|
|
26
|
+
# # NLP pre-processing
|
|
27
|
+
# # remove urls, handles, and the hashtag from hashtags
|
|
28
|
+
# # (taken from https://stackoverflow.com/questions/8376691/how-to-remove-hashtag-user-link-of-a-tweet-using-regular-expression)
|
|
29
|
+
# def remove_urls(text):
|
|
30
|
+
# new_text = ' '.join(re.sub("(@[A-Za-z0-9]+)|([^0-9A-Za-z \t])|(\w+:\/\/\S+)"," ",text).split())
|
|
31
|
+
# return new_text
|
|
32
|
+
|
|
33
|
+
# # make all text lowercase
|
|
34
|
+
# def text_lowercase(text):
|
|
35
|
+
# return text.lower()
|
|
36
|
+
|
|
37
|
+
# # remove numbers
|
|
38
|
+
# def remove_numbers(text):
|
|
39
|
+
# result = re.sub(r'\d+', '', text)
|
|
40
|
+
# return result
|
|
41
|
+
|
|
42
|
+
# # remove punctuation
|
|
43
|
+
# def remove_punctuation(text):
|
|
44
|
+
# translator = str.maketrans('', '', string.punctuation)
|
|
45
|
+
# return text.translate(translator)
|
|
46
|
+
|
|
47
|
+
# # function for all pre-processing steps
|
|
48
|
+
# def preprocessing(text):
|
|
49
|
+
# text = text_lowercase(text)
|
|
50
|
+
# text = remove_urls(text)
|
|
51
|
+
# text = remove_numbers(text)
|
|
52
|
+
# text = remove_punctuation(text)
|
|
53
|
+
# return text
|
|
54
|
+
|
|
55
|
+
# # pre-processing the text body column
|
|
56
|
+
# pp_text = []
|
|
57
|
+
# for text_data in X_input_text:
|
|
58
|
+
# # check if string
|
|
59
|
+
# if isinstance(text_data, str):
|
|
60
|
+
# pp_text_data = preprocessing(text_data)
|
|
61
|
+
# pp_text.append(pp_text_data)
|
|
62
|
+
# # if not string
|
|
63
|
+
# else:
|
|
64
|
+
# pp_text.append(np.NaN)
|
|
65
|
+
# cleaned_words = clean_text(X_input_text)
|
|
66
|
+
|
|
67
|
+
wod = X_input_text.split()
|
|
68
|
+
|
|
69
|
+
wod = [word.strip('-.,!;()[]@><:') for word in wod]
|
|
70
|
+
# wod = [word.replace("'s", '') for word in wod]
|
|
71
|
+
wod = [word.replace(".", '') for word in wod]
|
|
72
|
+
wod = [word.replace("-", '') for word in wod]
|
|
73
|
+
# words = [word.replace(":", '') for word in words]
|
|
74
|
+
# words = [word.replace(">", '') for word in words]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#finding unique
|
|
78
|
+
unique = []
|
|
79
|
+
for word in wod:
|
|
80
|
+
if word not in unique:
|
|
81
|
+
unique.append(word)
|
|
82
|
+
|
|
83
|
+
# Sorting the Unique Values
|
|
84
|
+
unique.sort()
|
|
85
|
+
|
|
86
|
+
#num_perturb = 150
|
|
87
|
+
num_uniqe_words = len(wod)
|
|
88
|
+
perturbations = np.random.binomial(1, 0.5, size=(num_perturb, num_uniqe_words))
|
|
89
|
+
text_list = wod.copy()
|
|
90
|
+
|
|
91
|
+
def perturb_text(text_list, perturbation):
|
|
92
|
+
for x, y in enumerate(text_list):
|
|
93
|
+
if perturbation[x] == 0:
|
|
94
|
+
text_list.remove(y)
|
|
95
|
+
|
|
96
|
+
predictions = []
|
|
97
|
+
WD_dist = []
|
|
98
|
+
for pert in perturbations:
|
|
99
|
+
perturbed_text = perturb_text(text_list, pert)
|
|
100
|
+
pred = model.predict_proba([str(perturbed_text)])
|
|
101
|
+
predictions.append(pred)
|
|
102
|
+
WD_score = embd.wmdistance(str(wod), str(perturbed_text))
|
|
103
|
+
WD_dist.append(WD_score)
|
|
104
|
+
|
|
105
|
+
predictions = np.array(predictions)
|
|
106
|
+
WD_dist = np.array(WD_dist)
|
|
107
|
+
|
|
108
|
+
weights = np.sqrt(np.exp(-((eps*WD_dist)**2)/kernel_width**2)) #Kernel function
|
|
109
|
+
|
|
110
|
+
class_to_explain = 0
|
|
111
|
+
simpler_model = LinearRegression()
|
|
112
|
+
simpler_model.fit(X=perturbations, y=predictions[:,:, class_to_explain], sample_weight=weights)
|
|
113
|
+
coeff = simpler_model.coef_[0]
|
|
114
|
+
|
|
115
|
+
print(coeff.shape)
|
|
116
|
+
|
|
117
|
+
coeff3 = coeff[0:50]
|
|
118
|
+
|
|
119
|
+
num_top_features = 50
|
|
120
|
+
top_features = np.argsort(abs(coeff))[-num_top_features:]
|
|
121
|
+
|
|
122
|
+
coeff2 = simpler_model.coef_
|
|
123
|
+
|
|
124
|
+
print(coeff3.shape)
|
|
125
|
+
# https://stackoverflow.com/questions/39626401/how-to-get-odds-ratios-and-other-related-features-with-scikit-learn
|
|
126
|
+
odds = np.exp(coeff2)
|
|
127
|
+
|
|
128
|
+
return coeff3, coeff, odds, top_features, wod
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: xwhy
|
|
3
|
+
Version: 0.0.0.1
|
|
4
|
+
Summary: Explain Why (XWhy) with Statistical Model-agnostic Interpretability with Local Explanations (SMILE)
|
|
5
|
+
Home-page: https://github.com/Dependable-Intelligent-Systems-Lab/xwhy
|
|
6
|
+
Author: Mojgan Hashemian, Koorosh Aslansefat (corresponding), Mohammad Naveed Akram, Ioannis Sorokos, Martin Walker, Yiannis Papadopoulos
|
|
7
|
+
Author-email: koo.ec2008@gmail.com
|
|
8
|
+
License: BSD
|
|
9
|
+
Description: UNKNOWN
|
|
10
|
+
Platform: UNKNOWN
|
|
11
|
+
Requires-Python: >=3.5
|
|
12
|
+
Provides-Extra: dev
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.cfg
|
|
3
|
+
setup.py
|
|
4
|
+
xwhy/__init__.py
|
|
5
|
+
xwhy/datasets.py
|
|
6
|
+
xwhy/smile_graph.py
|
|
7
|
+
xwhy/smile_image.py
|
|
8
|
+
xwhy/smile_quantum.py
|
|
9
|
+
xwhy/smile_tabular.py
|
|
10
|
+
xwhy/smile_text.py
|
|
11
|
+
xwhy.egg-info/PKG-INFO
|
|
12
|
+
xwhy.egg-info/SOURCES.txt
|
|
13
|
+
xwhy.egg-info/dependency_links.txt
|
|
14
|
+
xwhy.egg-info/not-zip-safe
|
|
15
|
+
xwhy.egg-info/requires.txt
|
|
16
|
+
xwhy.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xwhy
|