case-explainer 0.1.1__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.
- case_explainer/__init__.py +13 -0
- case_explainer/explainer.py +318 -0
- case_explainer/explanation.py +233 -0
- case_explainer/indexing.py +108 -0
- case_explainer/metrics.py +111 -0
- case_explainer-0.1.1.dist-info/METADATA +424 -0
- case_explainer-0.1.1.dist-info/RECORD +10 -0
- case_explainer-0.1.1.dist-info/WHEEL +5 -0
- case_explainer-0.1.1.dist-info/licenses/LICENSE +21 -0
- case_explainer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Case-Explainer: General-Purpose Case-Based Explainability Module
|
|
3
|
+
|
|
4
|
+
Provides model-agnostic explanations through training set precedent and
|
|
5
|
+
nearest neighbor correspondence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .explainer import CaseExplainer
|
|
9
|
+
from .explanation import Explanation
|
|
10
|
+
from .metrics import compute_correspondence, euclidean_distance
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = ["CaseExplainer", "Explanation", "compute_correspondence", "euclidean_distance"]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CaseExplainer class for case-based explanations.
|
|
3
|
+
|
|
4
|
+
Based on refined Method 2 (case-based) from hardware trojan detection pipeline.
|
|
5
|
+
Uses sklearn's NearestNeighbors for efficient k-NN lookups with pre-built index.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from typing import List, Optional, Dict, Any, Union
|
|
12
|
+
from sklearn.preprocessing import StandardScaler
|
|
13
|
+
from sklearn.neighbors import NearestNeighbors
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
from .explanation import Explanation, Neighbor
|
|
18
|
+
from .metrics import compute_correspondence
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CaseExplainer:
|
|
22
|
+
"""
|
|
23
|
+
General-purpose case-based explainability module.
|
|
24
|
+
|
|
25
|
+
Provides model-agnostic explanations through training set precedent
|
|
26
|
+
and nearest neighbor correspondence. Builds k-NN index during initialization
|
|
27
|
+
for fast lookups during explanation.
|
|
28
|
+
|
|
29
|
+
Based on refined Method 2 from hardware trojan detection pipeline:
|
|
30
|
+
- Pre-builds NearestNeighbors index on training data
|
|
31
|
+
- Uses distance-weighted correspondence: weight = 1 / (distance + 1)^3
|
|
32
|
+
- Supports class weights for imbalanced datasets
|
|
33
|
+
- Compatible with any classifier (sklearn, XGBoost, etc.)
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from case_explainer import CaseExplainer
|
|
37
|
+
>>> explainer = CaseExplainer(X_train, y_train, k=5)
|
|
38
|
+
>>> explanation = explainer.explain_instance(test_sample, model=clf)
|
|
39
|
+
>>> print(f"Correspondence: {explanation.correspondence:.2%}")
|
|
40
|
+
>>> explanation.plot()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
X_train: Union[np.ndarray, pd.DataFrame],
|
|
46
|
+
y_train: Union[np.ndarray, pd.Series, List],
|
|
47
|
+
k: int = 5,
|
|
48
|
+
feature_names: Optional[List[str]] = None,
|
|
49
|
+
class_names: Optional[Dict[int, str]] = None,
|
|
50
|
+
metric: str = 'euclidean',
|
|
51
|
+
algorithm: str = 'auto',
|
|
52
|
+
scale_data: bool = True,
|
|
53
|
+
class_weights: Optional[Dict[int, float]] = None,
|
|
54
|
+
metadata: Optional[Dict[str, List]] = None,
|
|
55
|
+
n_jobs: int = -1
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Initialize CaseExplainer with training data and build k-NN index.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
X_train: Training features (n_samples, n_features)
|
|
62
|
+
y_train: Training labels (n_samples,)
|
|
63
|
+
k: Number of nearest neighbors for explanations (default: 5)
|
|
64
|
+
feature_names: Names of features (optional)
|
|
65
|
+
class_names: Mapping from class labels to names (optional)
|
|
66
|
+
metric: Distance metric (default: 'euclidean')
|
|
67
|
+
algorithm: k-NN algorithm - 'auto', 'ball_tree', 'kd_tree', 'brute' (default: 'auto')
|
|
68
|
+
scale_data: Whether to standardize features (recommended: True)
|
|
69
|
+
class_weights: Optional weights for each class in correspondence computation
|
|
70
|
+
e.g., {0: 1.0, 1: 2.0} to weight class 1 twice as much
|
|
71
|
+
metadata: Optional dict with metadata for each training sample
|
|
72
|
+
e.g., {'sample_id': [...], 'source': [...], ...}
|
|
73
|
+
n_jobs: Number of parallel jobs for k-NN search (-1 = all CPUs)
|
|
74
|
+
"""
|
|
75
|
+
# Convert inputs to numpy arrays
|
|
76
|
+
if isinstance(X_train, pd.DataFrame):
|
|
77
|
+
if feature_names is None:
|
|
78
|
+
feature_names = X_train.columns.tolist()
|
|
79
|
+
X_train = X_train.values
|
|
80
|
+
else:
|
|
81
|
+
X_train = np.asarray(X_train)
|
|
82
|
+
|
|
83
|
+
if isinstance(y_train, (pd.Series, list)):
|
|
84
|
+
y_train = np.asarray(y_train)
|
|
85
|
+
|
|
86
|
+
# Store original data
|
|
87
|
+
self.X_train_original = X_train.copy()
|
|
88
|
+
self.y_train = y_train.copy()
|
|
89
|
+
|
|
90
|
+
# Validate shapes
|
|
91
|
+
if len(X_train) != len(y_train):
|
|
92
|
+
raise ValueError(f"X_train and y_train must have same length "
|
|
93
|
+
f"(got {len(X_train)} and {len(y_train)})")
|
|
94
|
+
|
|
95
|
+
self.n_samples, self.n_features = X_train.shape
|
|
96
|
+
self.k = k
|
|
97
|
+
self.feature_names = feature_names or [f"feature_{i}" for i in range(self.n_features)]
|
|
98
|
+
self.class_names = class_names or {}
|
|
99
|
+
self.metric = metric
|
|
100
|
+
self.algorithm = algorithm
|
|
101
|
+
self.scale_data = scale_data
|
|
102
|
+
self.class_weights = class_weights or {}
|
|
103
|
+
self.metadata = metadata or {}
|
|
104
|
+
self.n_jobs = n_jobs
|
|
105
|
+
|
|
106
|
+
# Validate metadata
|
|
107
|
+
if self.metadata:
|
|
108
|
+
for key, values in self.metadata.items():
|
|
109
|
+
if len(values) != self.n_samples:
|
|
110
|
+
raise ValueError(f"Metadata '{key}' has {len(values)} items, "
|
|
111
|
+
f"expected {self.n_samples}")
|
|
112
|
+
|
|
113
|
+
# Scale data if requested
|
|
114
|
+
if scale_data:
|
|
115
|
+
self.scaler = StandardScaler()
|
|
116
|
+
self.X_train_scaled = self.scaler.fit_transform(X_train)
|
|
117
|
+
else:
|
|
118
|
+
self.scaler = None
|
|
119
|
+
self.X_train_scaled = X_train.copy()
|
|
120
|
+
|
|
121
|
+
# Build k-NN index using sklearn's NearestNeighbors
|
|
122
|
+
# This is done once during initialization for efficiency
|
|
123
|
+
logger.info("Building k-NN index (k=%d, metric=%s, algorithm=%s)...", k, metric, algorithm)
|
|
124
|
+
self.nn_index = NearestNeighbors(
|
|
125
|
+
n_neighbors=min(k, self.n_samples), # Handle case where k > n_samples
|
|
126
|
+
metric=metric,
|
|
127
|
+
algorithm=algorithm,
|
|
128
|
+
n_jobs=n_jobs
|
|
129
|
+
)
|
|
130
|
+
self.nn_index.fit(self.X_train_scaled)
|
|
131
|
+
logger.info("Index built on %d training samples", self.n_samples)
|
|
132
|
+
|
|
133
|
+
def explain_instance(
|
|
134
|
+
self,
|
|
135
|
+
test_sample: Union[np.ndarray, pd.Series, List],
|
|
136
|
+
test_index: Optional[int] = None,
|
|
137
|
+
true_class: Optional[int] = None,
|
|
138
|
+
predicted_class: Optional[int] = None,
|
|
139
|
+
model: Optional[Any] = None,
|
|
140
|
+
k: Optional[int] = None,
|
|
141
|
+
return_provenance: bool = True,
|
|
142
|
+
distance_weighted: bool = True
|
|
143
|
+
) -> Explanation:
|
|
144
|
+
"""
|
|
145
|
+
Explain a prediction using case-based reasoning with k-NN precedent.
|
|
146
|
+
|
|
147
|
+
This method:
|
|
148
|
+
1. Finds k nearest neighbors in the pre-built index
|
|
149
|
+
2. Computes weighted correspondence based on neighbor labels
|
|
150
|
+
3. Returns explanation with neighbor details and correspondence score
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
test_sample: Sample to explain (n_features,)
|
|
154
|
+
test_index: Index in test set (optional, for tracking)
|
|
155
|
+
true_class: True class label (optional, for validation)
|
|
156
|
+
predicted_class: Predicted class (optional, will use model if not provided)
|
|
157
|
+
model: Trained model with predict() method (optional)
|
|
158
|
+
k: Number of neighbors (optional, uses default from init if not provided)
|
|
159
|
+
return_provenance: Include metadata in explanation
|
|
160
|
+
distance_weighted: Use distance weighting for correspondence
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Explanation object with neighbors and correspondence
|
|
164
|
+
"""
|
|
165
|
+
# Convert test sample to numpy array
|
|
166
|
+
if isinstance(test_sample, (pd.Series, list)):
|
|
167
|
+
test_sample = np.asarray(test_sample)
|
|
168
|
+
|
|
169
|
+
if len(test_sample) != self.n_features:
|
|
170
|
+
raise ValueError(f"test_sample has {len(test_sample)} features, "
|
|
171
|
+
f"expected {self.n_features}")
|
|
172
|
+
|
|
173
|
+
# Scale test sample if needed
|
|
174
|
+
if self.scale_data:
|
|
175
|
+
test_sample_scaled = self.scaler.transform([test_sample])[0]
|
|
176
|
+
else:
|
|
177
|
+
test_sample_scaled = test_sample.copy()
|
|
178
|
+
|
|
179
|
+
# Get prediction if not provided
|
|
180
|
+
if predicted_class is None:
|
|
181
|
+
if model is None:
|
|
182
|
+
raise ValueError("Either predicted_class or model must be provided")
|
|
183
|
+
predicted_class = int(model.predict([test_sample])[0])
|
|
184
|
+
|
|
185
|
+
# Query pre-built k-NN index
|
|
186
|
+
k_actual = k if k is not None else self.k
|
|
187
|
+
k_actual = min(k_actual, self.n_samples) # Handle case where k > n_samples
|
|
188
|
+
|
|
189
|
+
distances, indices = self.nn_index.kneighbors(
|
|
190
|
+
[test_sample_scaled],
|
|
191
|
+
n_neighbors=k_actual
|
|
192
|
+
)
|
|
193
|
+
distances = distances[0]
|
|
194
|
+
indices = indices[0]
|
|
195
|
+
|
|
196
|
+
# Create Neighbor objects
|
|
197
|
+
neighbors = []
|
|
198
|
+
for idx, dist in zip(indices, distances):
|
|
199
|
+
neighbor_metadata = {}
|
|
200
|
+
if return_provenance and self.metadata:
|
|
201
|
+
for key, values in self.metadata.items():
|
|
202
|
+
neighbor_metadata[key] = values[idx]
|
|
203
|
+
|
|
204
|
+
neighbor = Neighbor(
|
|
205
|
+
index=int(idx),
|
|
206
|
+
distance=float(dist),
|
|
207
|
+
label=int(self.y_train[idx]),
|
|
208
|
+
features=self.X_train_original[idx].copy(),
|
|
209
|
+
metadata=neighbor_metadata if neighbor_metadata else None
|
|
210
|
+
)
|
|
211
|
+
neighbors.append(neighbor)
|
|
212
|
+
|
|
213
|
+
# Compute correspondence with optional class weighting
|
|
214
|
+
neighbor_tuples = [(n.index, n.distance, n.label) for n in neighbors]
|
|
215
|
+
correspondence, interpretation = compute_correspondence(
|
|
216
|
+
neighbor_tuples,
|
|
217
|
+
predicted_class,
|
|
218
|
+
distance_weighted=distance_weighted,
|
|
219
|
+
class_weights=self.class_weights
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Create explanation
|
|
223
|
+
explanation = Explanation(
|
|
224
|
+
test_sample=test_sample.copy(),
|
|
225
|
+
test_index=test_index,
|
|
226
|
+
neighbors=neighbors,
|
|
227
|
+
predicted_class=predicted_class,
|
|
228
|
+
true_class=true_class,
|
|
229
|
+
correspondence=correspondence,
|
|
230
|
+
correspondence_interpretation=interpretation,
|
|
231
|
+
feature_names=self.feature_names,
|
|
232
|
+
class_names=self.class_names
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return explanation
|
|
236
|
+
|
|
237
|
+
def explain_batch(
|
|
238
|
+
self,
|
|
239
|
+
X_test: Union[np.ndarray, pd.DataFrame],
|
|
240
|
+
y_test: Optional[Union[np.ndarray, pd.Series, List]] = None,
|
|
241
|
+
predictions: Optional[Union[np.ndarray, List]] = None,
|
|
242
|
+
model: Optional[Any] = None,
|
|
243
|
+
k: Optional[int] = None,
|
|
244
|
+
return_provenance: bool = True,
|
|
245
|
+
distance_weighted: bool = True
|
|
246
|
+
) -> List[Explanation]:
|
|
247
|
+
"""
|
|
248
|
+
Explain multiple predictions efficiently.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
X_test: Test samples (n_samples, n_features)
|
|
252
|
+
y_test: True labels (optional)
|
|
253
|
+
predictions: Predicted labels (optional, will use model if not provided)
|
|
254
|
+
model: Trained model (optional)
|
|
255
|
+
k: Number of neighbors (optional, uses default from init)
|
|
256
|
+
return_provenance: Include metadata
|
|
257
|
+
distance_weighted: Use distance weighting
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of Explanation objects
|
|
261
|
+
"""
|
|
262
|
+
# Convert inputs
|
|
263
|
+
if isinstance(X_test, pd.DataFrame):
|
|
264
|
+
X_test = X_test.values
|
|
265
|
+
else:
|
|
266
|
+
X_test = np.asarray(X_test)
|
|
267
|
+
|
|
268
|
+
if y_test is not None:
|
|
269
|
+
if isinstance(y_test, (pd.Series, list)):
|
|
270
|
+
y_test = np.asarray(y_test)
|
|
271
|
+
|
|
272
|
+
if predictions is not None:
|
|
273
|
+
if isinstance(predictions, list):
|
|
274
|
+
predictions = np.asarray(predictions)
|
|
275
|
+
|
|
276
|
+
# Generate explanations
|
|
277
|
+
explanations = []
|
|
278
|
+
for i, sample in enumerate(X_test):
|
|
279
|
+
true_class = None if y_test is None else int(y_test[i])
|
|
280
|
+
pred_class = None if predictions is None else int(predictions[i])
|
|
281
|
+
|
|
282
|
+
explanation = self.explain_instance(
|
|
283
|
+
test_sample=sample,
|
|
284
|
+
test_index=i,
|
|
285
|
+
true_class=true_class,
|
|
286
|
+
predicted_class=pred_class,
|
|
287
|
+
model=model,
|
|
288
|
+
k=k,
|
|
289
|
+
return_provenance=return_provenance,
|
|
290
|
+
distance_weighted=distance_weighted
|
|
291
|
+
)
|
|
292
|
+
explanations.append(explanation)
|
|
293
|
+
|
|
294
|
+
return explanations
|
|
295
|
+
|
|
296
|
+
def get_training_info(self) -> Dict[str, Any]:
|
|
297
|
+
"""Get information about the training data."""
|
|
298
|
+
unique_classes, class_counts = np.unique(self.y_train, return_counts=True)
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"n_samples": self.n_samples,
|
|
302
|
+
"n_features": self.n_features,
|
|
303
|
+
"n_classes": len(unique_classes),
|
|
304
|
+
"classes": unique_classes.tolist(),
|
|
305
|
+
"class_counts": dict(zip(unique_classes.tolist(), class_counts.tolist())),
|
|
306
|
+
"feature_names": self.feature_names,
|
|
307
|
+
"class_names": self.class_names,
|
|
308
|
+
"algorithm": self.algorithm,
|
|
309
|
+
"metric": self.metric,
|
|
310
|
+
"scaled": self.scale_data,
|
|
311
|
+
"has_metadata": bool(self.metadata),
|
|
312
|
+
"default_k": self.k
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def __repr__(self) -> str:
|
|
316
|
+
return (f"CaseExplainer(n_samples={self.n_samples}, "
|
|
317
|
+
f"n_features={self.n_features}, "
|
|
318
|
+
f"k={self.k}, algorithm='{self.algorithm}')")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Explanation object for case-based explanations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from typing import List, Tuple, Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Neighbor:
|
|
11
|
+
"""Represents a single nearest neighbor."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
index: int,
|
|
16
|
+
distance: float,
|
|
17
|
+
label: int,
|
|
18
|
+
features: np.ndarray,
|
|
19
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
20
|
+
):
|
|
21
|
+
self.index = index
|
|
22
|
+
self.distance = distance
|
|
23
|
+
self.label = label
|
|
24
|
+
self.features = features
|
|
25
|
+
self.metadata = metadata or {}
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
meta_str = f", {self.metadata}" if self.metadata else ""
|
|
29
|
+
return f"Neighbor(index={self.index}, distance={self.distance:.4f}, label={self.label}{meta_str})"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Explanation:
|
|
33
|
+
"""
|
|
34
|
+
Explanation object containing case-based explanation details.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
test_sample: np.ndarray,
|
|
40
|
+
test_index: Optional[int],
|
|
41
|
+
neighbors: List[Neighbor],
|
|
42
|
+
predicted_class: int,
|
|
43
|
+
true_class: Optional[int],
|
|
44
|
+
correspondence: float,
|
|
45
|
+
correspondence_interpretation: str,
|
|
46
|
+
feature_names: Optional[List[str]] = None,
|
|
47
|
+
class_names: Optional[Dict[int, str]] = None
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize explanation.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
test_sample: The test sample being explained
|
|
54
|
+
test_index: Index in test set (if applicable)
|
|
55
|
+
neighbors: List of Neighbor objects
|
|
56
|
+
predicted_class: Predicted class label
|
|
57
|
+
true_class: True class label (if available)
|
|
58
|
+
correspondence: Correspondence score [0, 1]
|
|
59
|
+
correspondence_interpretation: "high", "medium", or "low"
|
|
60
|
+
feature_names: Names of features (optional)
|
|
61
|
+
class_names: Mapping from class labels to names (optional)
|
|
62
|
+
"""
|
|
63
|
+
self.test_sample = test_sample
|
|
64
|
+
self.test_index = test_index
|
|
65
|
+
self.neighbors = neighbors
|
|
66
|
+
self.predicted_class = predicted_class
|
|
67
|
+
self.true_class = true_class
|
|
68
|
+
self.correspondence = correspondence
|
|
69
|
+
self.correspondence_interpretation = correspondence_interpretation
|
|
70
|
+
self.feature_names = feature_names or [f"feature_{i}" for i in range(len(test_sample))]
|
|
71
|
+
# Convert class_names list to dict if needed
|
|
72
|
+
if isinstance(class_names, list):
|
|
73
|
+
self.class_names = {i: name for i, name in enumerate(class_names)}
|
|
74
|
+
else:
|
|
75
|
+
self.class_names = class_names or {}
|
|
76
|
+
|
|
77
|
+
def get_predicted_class_name(self) -> str:
|
|
78
|
+
"""Get the predicted class name."""
|
|
79
|
+
return self.class_names.get(self.predicted_class, str(self.predicted_class))
|
|
80
|
+
|
|
81
|
+
def get_true_class_name(self) -> Optional[str]:
|
|
82
|
+
"""Get the true class name."""
|
|
83
|
+
if self.true_class is None:
|
|
84
|
+
return None
|
|
85
|
+
return self.class_names.get(self.true_class, str(self.true_class))
|
|
86
|
+
|
|
87
|
+
def is_correct(self) -> Optional[bool]:
|
|
88
|
+
"""Check if prediction matches true label (if available)."""
|
|
89
|
+
if self.true_class is None:
|
|
90
|
+
return None
|
|
91
|
+
return self.predicted_class == self.true_class
|
|
92
|
+
|
|
93
|
+
def summary(self) -> str:
|
|
94
|
+
"""Generate a text summary of the explanation."""
|
|
95
|
+
lines = []
|
|
96
|
+
lines.append("=" * 60)
|
|
97
|
+
lines.append("CASE-BASED EXPLANATION")
|
|
98
|
+
lines.append("=" * 60)
|
|
99
|
+
|
|
100
|
+
if self.test_index is not None:
|
|
101
|
+
lines.append(f"Test sample index: {self.test_index}")
|
|
102
|
+
|
|
103
|
+
lines.append(f"Predicted class: {self.get_predicted_class_name()}")
|
|
104
|
+
|
|
105
|
+
if self.true_class is not None:
|
|
106
|
+
correct_str = "[OK]" if self.is_correct() else "[X]"
|
|
107
|
+
lines.append(f"True class: {self.get_true_class_name()} {correct_str}")
|
|
108
|
+
|
|
109
|
+
lines.append(f"Correspondence: {self.correspondence:.2%} ({self.correspondence_interpretation})")
|
|
110
|
+
lines.append("")
|
|
111
|
+
lines.append(f"Nearest {len(self.neighbors)} neighbors:")
|
|
112
|
+
lines.append("-" * 60)
|
|
113
|
+
|
|
114
|
+
for i, neighbor in enumerate(self.neighbors, 1):
|
|
115
|
+
neighbor_class = self.class_names.get(neighbor.label, str(neighbor.label))
|
|
116
|
+
match_str = "*" if neighbor.label == self.predicted_class else " "
|
|
117
|
+
lines.append(f"{i}. [{match_str}] Index {neighbor.index}: class {neighbor_class}, "
|
|
118
|
+
f"distance {neighbor.distance:.4f}")
|
|
119
|
+
|
|
120
|
+
# Add metadata if available
|
|
121
|
+
if neighbor.metadata:
|
|
122
|
+
for key, value in neighbor.metadata.items():
|
|
123
|
+
lines.append(f" {key}: {value}")
|
|
124
|
+
|
|
125
|
+
lines.append("=" * 60)
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
129
|
+
"""Export explanation as dictionary (for JSON serialization)."""
|
|
130
|
+
return {
|
|
131
|
+
"test_index": self.test_index,
|
|
132
|
+
"test_sample": self.test_sample.tolist(),
|
|
133
|
+
"predicted_class": self.predicted_class,
|
|
134
|
+
"predicted_class_name": self.get_predicted_class_name(),
|
|
135
|
+
"true_class": self.true_class,
|
|
136
|
+
"true_class_name": self.get_true_class_name(),
|
|
137
|
+
"is_correct": self.is_correct(),
|
|
138
|
+
"correspondence": float(self.correspondence),
|
|
139
|
+
"correspondence_interpretation": self.correspondence_interpretation,
|
|
140
|
+
"neighbors": [
|
|
141
|
+
{
|
|
142
|
+
"index": n.index,
|
|
143
|
+
"distance": float(n.distance),
|
|
144
|
+
"label": n.label,
|
|
145
|
+
"label_name": self.class_names.get(n.label, str(n.label)),
|
|
146
|
+
"features": n.features.tolist(),
|
|
147
|
+
"metadata": n.metadata
|
|
148
|
+
}
|
|
149
|
+
for n in self.neighbors
|
|
150
|
+
],
|
|
151
|
+
"feature_names": self.feature_names
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def plot(
|
|
155
|
+
self,
|
|
156
|
+
plot_type: str = 'radar',
|
|
157
|
+
highlight_differences: bool = True,
|
|
158
|
+
show_distances: bool = True,
|
|
159
|
+
save_path: Optional[str] = None,
|
|
160
|
+
figsize: Tuple[int, int] = (12, 8)
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Visualize the explanation.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
plot_type: 'radar', 'bar', or 'parallel'
|
|
167
|
+
highlight_differences: Whether to highlight feature differences
|
|
168
|
+
show_distances: Whether to show distance values
|
|
169
|
+
save_path: Path to save figure (if provided)
|
|
170
|
+
figsize: Figure size
|
|
171
|
+
"""
|
|
172
|
+
if plot_type == 'bar':
|
|
173
|
+
self._plot_bar(figsize, save_path)
|
|
174
|
+
elif plot_type == 'radar':
|
|
175
|
+
self._plot_radar(figsize, save_path)
|
|
176
|
+
elif plot_type == 'parallel':
|
|
177
|
+
self._plot_parallel(figsize, save_path)
|
|
178
|
+
else:
|
|
179
|
+
raise ValueError(f"Unknown plot type: {plot_type}")
|
|
180
|
+
|
|
181
|
+
def _plot_bar(self, figsize: Tuple[int, int], save_path: Optional[str]) -> None:
|
|
182
|
+
"""Create bar plot comparing features."""
|
|
183
|
+
n_features = len(self.test_sample)
|
|
184
|
+
n_neighbors = len(self.neighbors)
|
|
185
|
+
|
|
186
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
187
|
+
|
|
188
|
+
x = np.arange(n_features)
|
|
189
|
+
width = 0.8 / (n_neighbors + 1)
|
|
190
|
+
|
|
191
|
+
# Plot test sample
|
|
192
|
+
ax.bar(x, self.test_sample, width, label='Test Sample',
|
|
193
|
+
color='red', alpha=0.8, edgecolor='black', linewidth=2)
|
|
194
|
+
|
|
195
|
+
# Plot neighbors
|
|
196
|
+
colors = plt.cm.Blues(np.linspace(0.3, 0.8, n_neighbors))
|
|
197
|
+
for i, neighbor in enumerate(self.neighbors):
|
|
198
|
+
offset = width * (i + 1)
|
|
199
|
+
match_str = "*" if neighbor.label == self.predicted_class else ""
|
|
200
|
+
ax.bar(x + offset, neighbor.features, width,
|
|
201
|
+
label=f'Neighbor {i+1} {match_str}',
|
|
202
|
+
color=colors[i], alpha=0.6)
|
|
203
|
+
|
|
204
|
+
ax.set_xlabel('Features')
|
|
205
|
+
ax.set_ylabel('Feature Values')
|
|
206
|
+
ax.set_title(f'Case-Based Explanation (Correspondence: {self.correspondence:.2%})')
|
|
207
|
+
ax.set_xticks(x + width * n_neighbors / 2)
|
|
208
|
+
ax.set_xticklabels(self.feature_names, rotation=45, ha='right')
|
|
209
|
+
ax.legend()
|
|
210
|
+
ax.grid(axis='y', alpha=0.3)
|
|
211
|
+
|
|
212
|
+
plt.tight_layout()
|
|
213
|
+
|
|
214
|
+
if save_path:
|
|
215
|
+
plt.savefig(save_path, dpi=150, bbox_inches='tight')
|
|
216
|
+
else:
|
|
217
|
+
plt.show()
|
|
218
|
+
|
|
219
|
+
def _plot_radar(self, figsize: Tuple[int, int], save_path: Optional[str]) -> None:
|
|
220
|
+
"""Create radar plot (not implemented yet - use bar for now)."""
|
|
221
|
+
print("Radar plot not yet implemented, using bar plot instead.")
|
|
222
|
+
self._plot_bar(figsize, save_path)
|
|
223
|
+
|
|
224
|
+
def _plot_parallel(self, figsize: Tuple[int, int], save_path: Optional[str]) -> None:
|
|
225
|
+
"""Create parallel coordinates plot (not implemented yet - use bar for now)."""
|
|
226
|
+
print("Parallel coordinates plot not yet implemented, using bar plot instead.")
|
|
227
|
+
self._plot_bar(figsize, save_path)
|
|
228
|
+
|
|
229
|
+
def __repr__(self) -> str:
|
|
230
|
+
return (f"Explanation(test_index={self.test_index}, "
|
|
231
|
+
f"predicted={self.get_predicted_class_name()}, "
|
|
232
|
+
f"correspondence={self.correspondence:.2%}, "
|
|
233
|
+
f"neighbors={len(self.neighbors)})")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Indexing strategies for efficient nearest neighbor search.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from typing import List, Tuple, Optional
|
|
7
|
+
from sklearn.neighbors import KDTree, BallTree
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IndexStrategy(ABC):
|
|
12
|
+
"""Abstract base class for indexing strategies."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def build(self, X: np.ndarray) -> None:
|
|
16
|
+
"""Build the index from data."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def query(self, point: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
21
|
+
"""
|
|
22
|
+
Query for k nearest neighbors.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
distances: Array of distances (k,)
|
|
26
|
+
indices: Array of indices (k,)
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BruteForceIndex(IndexStrategy):
|
|
32
|
+
"""Brute force search - computes all distances."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.X = None
|
|
36
|
+
|
|
37
|
+
def build(self, X: np.ndarray) -> None:
|
|
38
|
+
"""Store the training data."""
|
|
39
|
+
self.X = X
|
|
40
|
+
|
|
41
|
+
def query(self, point: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
42
|
+
"""Find k nearest neighbors by computing all distances."""
|
|
43
|
+
from .metrics import compute_all_distances
|
|
44
|
+
|
|
45
|
+
distances = compute_all_distances(point, self.X)
|
|
46
|
+
# Get indices of k smallest distances
|
|
47
|
+
indices = np.argpartition(distances, min(k, len(distances) - 1))[:k]
|
|
48
|
+
# Sort by distance
|
|
49
|
+
sorted_idx = np.argsort(distances[indices])
|
|
50
|
+
indices = indices[sorted_idx]
|
|
51
|
+
distances = distances[indices]
|
|
52
|
+
|
|
53
|
+
return distances, indices
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KDTreeIndex(IndexStrategy):
|
|
57
|
+
"""K-D Tree for fast nearest neighbor search (best for low dimensions)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, leaf_size: int = 30):
|
|
60
|
+
self.leaf_size = leaf_size
|
|
61
|
+
self.tree = None
|
|
62
|
+
|
|
63
|
+
def build(self, X: np.ndarray) -> None:
|
|
64
|
+
"""Build K-D tree."""
|
|
65
|
+
self.tree = KDTree(X, leaf_size=self.leaf_size, metric='euclidean')
|
|
66
|
+
|
|
67
|
+
def query(self, point: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
68
|
+
"""Query K-D tree for k nearest neighbors."""
|
|
69
|
+
distances, indices = self.tree.query([point], k=k)
|
|
70
|
+
return distances[0], indices[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BallTreeIndex(IndexStrategy):
|
|
74
|
+
"""Ball Tree for nearest neighbor search (better for high dimensions)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, leaf_size: int = 30):
|
|
77
|
+
self.leaf_size = leaf_size
|
|
78
|
+
self.tree = None
|
|
79
|
+
|
|
80
|
+
def build(self, X: np.ndarray) -> None:
|
|
81
|
+
"""Build Ball tree."""
|
|
82
|
+
self.tree = BallTree(X, leaf_size=self.leaf_size, metric='euclidean')
|
|
83
|
+
|
|
84
|
+
def query(self, point: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
85
|
+
"""Query Ball tree for k nearest neighbors."""
|
|
86
|
+
distances, indices = self.tree.query([point], k=k)
|
|
87
|
+
return distances[0], indices[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_index(method: str = 'kd_tree', **kwargs) -> IndexStrategy:
|
|
91
|
+
"""
|
|
92
|
+
Factory function to create an index.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
method: One of 'brute', 'kd_tree', 'ball_tree'
|
|
96
|
+
**kwargs: Additional arguments for the index
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
IndexStrategy instance
|
|
100
|
+
"""
|
|
101
|
+
if method == 'brute':
|
|
102
|
+
return BruteForceIndex()
|
|
103
|
+
elif method == 'kd_tree':
|
|
104
|
+
return KDTreeIndex(**kwargs)
|
|
105
|
+
elif method == 'ball_tree':
|
|
106
|
+
return BallTreeIndex(**kwargs)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(f"Unknown index method: {method}")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Distance metrics and correspondence calculation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.spatial import distance as scipy_distance
|
|
7
|
+
from typing import List, Tuple, Optional, Dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def euclidean_distance(point1: np.ndarray, point2: np.ndarray) -> float:
|
|
11
|
+
"""
|
|
12
|
+
Calculate Euclidean distance between two points.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
point1: First point as numpy array
|
|
16
|
+
point2: Second point as numpy array
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Euclidean distance as float
|
|
20
|
+
"""
|
|
21
|
+
return scipy_distance.euclidean(point1, point2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compute_correspondence(
|
|
25
|
+
neighbors: List[Tuple[int, float, int]],
|
|
26
|
+
predicted_class: int,
|
|
27
|
+
distance_weighted: bool = True,
|
|
28
|
+
class_weights: Optional[Dict[int, float]] = None
|
|
29
|
+
) -> Tuple[float, str]:
|
|
30
|
+
"""
|
|
31
|
+
Quantify agreement between prediction and retrieved neighbors.
|
|
32
|
+
|
|
33
|
+
Based on refined Method 2 formula from hardware trojan detection pipeline:
|
|
34
|
+
weight(class_c) = sum_{i in neighbors with class c} class_weight_c / (distance_i + 1)^3
|
|
35
|
+
|
|
36
|
+
Correspondence = weight(predicted_class) / sum(weight(all_classes))
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
neighbors: List of tuples (index, distance, label) for k nearest neighbors
|
|
40
|
+
predicted_class: The predicted class label
|
|
41
|
+
distance_weighted: Whether to weight by inverse cubed distance (default: True)
|
|
42
|
+
class_weights: Optional weights for each class, e.g., {0: 1.0, 1: 2.0}
|
|
43
|
+
for imbalanced datasets (default: all weights = 1.0)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
correspondence: float in [0, 1]
|
|
47
|
+
interpretation: "high" (>0.85), "medium" (0.70-0.85), "low" (<0.70)
|
|
48
|
+
"""
|
|
49
|
+
if not neighbors:
|
|
50
|
+
return 0.0, "undefined"
|
|
51
|
+
|
|
52
|
+
# Default class weights to 1.0 if not provided
|
|
53
|
+
if class_weights is None:
|
|
54
|
+
class_weights = {}
|
|
55
|
+
|
|
56
|
+
if distance_weighted:
|
|
57
|
+
# Weight by inverse cubed distance (refined formula from pipeline)
|
|
58
|
+
# weight = class_weight / (distance + 1)^3
|
|
59
|
+
class_weight_sums = {}
|
|
60
|
+
for _, dist, label in neighbors:
|
|
61
|
+
weight_multiplier = class_weights.get(label, 1.0)
|
|
62
|
+
weight = weight_multiplier / ((dist + 1.0) ** 3)
|
|
63
|
+
|
|
64
|
+
if label not in class_weight_sums:
|
|
65
|
+
class_weight_sums[label] = 0.0
|
|
66
|
+
class_weight_sums[label] += weight
|
|
67
|
+
|
|
68
|
+
total_weight = sum(class_weight_sums.values())
|
|
69
|
+
if total_weight == 0:
|
|
70
|
+
return 0.0, "undefined"
|
|
71
|
+
|
|
72
|
+
correspondence = class_weight_sums.get(predicted_class, 0.0) / total_weight
|
|
73
|
+
else:
|
|
74
|
+
# Simple voting with class weights
|
|
75
|
+
class_counts = {}
|
|
76
|
+
for _, _, label in neighbors:
|
|
77
|
+
weight = class_weights.get(label, 1.0)
|
|
78
|
+
if label not in class_counts:
|
|
79
|
+
class_counts[label] = 0.0
|
|
80
|
+
class_counts[label] += weight
|
|
81
|
+
|
|
82
|
+
total_count = sum(class_counts.values())
|
|
83
|
+
if total_count == 0:
|
|
84
|
+
return 0.0, "undefined"
|
|
85
|
+
|
|
86
|
+
correspondence = class_counts.get(predicted_class, 0.0) / total_count
|
|
87
|
+
|
|
88
|
+
# Interpret correspondence
|
|
89
|
+
if correspondence >= 0.85:
|
|
90
|
+
interpretation = "high"
|
|
91
|
+
elif correspondence >= 0.70:
|
|
92
|
+
interpretation = "medium"
|
|
93
|
+
else:
|
|
94
|
+
interpretation = "low"
|
|
95
|
+
|
|
96
|
+
return correspondence, interpretation
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_all_distances(point: np.ndarray, data: np.ndarray) -> np.ndarray:
|
|
100
|
+
"""
|
|
101
|
+
Compute Euclidean distances from a point to all points in a dataset.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
point: Query point as 1D numpy array
|
|
105
|
+
data: Dataset as 2D numpy array (n_samples, n_features)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Array of distances (n_samples,)
|
|
109
|
+
"""
|
|
110
|
+
# scipy_distance.cdist is faster than a loop
|
|
111
|
+
return scipy_distance.cdist([point], data, metric='euclidean')[0]
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: case-explainer
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: General-purpose case-based explainability for machine learning
|
|
5
|
+
Home-page: https://github.com/paulwhitten/case-explainer
|
|
6
|
+
Author: Paul Whitten
|
|
7
|
+
Author-email: Paul Whitten <pcw@case.edu>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/paulwhitten/case-explainer
|
|
10
|
+
Project-URL: Documentation, https://paulwhitten.github.io/case-explainer/
|
|
11
|
+
Project-URL: Repository, https://github.com/paulwhitten/case-explainer
|
|
12
|
+
Project-URL: Bug Tracker, https://github.com/paulwhitten/case-explainer/issues
|
|
13
|
+
Keywords: explainability,interpretability,machine-learning,case-based-reasoning,nearest-neighbors
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy>=1.20.0
|
|
28
|
+
Requires-Dist: scipy>=1.7.0
|
|
29
|
+
Requires-Dist: scikit-learn>=1.0.0
|
|
30
|
+
Requires-Dist: matplotlib>=3.3.0
|
|
31
|
+
Requires-Dist: pandas>=1.3.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=22.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: flake8>=4.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: mypy>=0.950; extra == "dev"
|
|
38
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
39
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
40
|
+
Dynamic: author
|
|
41
|
+
Dynamic: home-page
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
Dynamic: requires-python
|
|
44
|
+
|
|
45
|
+
# Case-Explainer: General-Purpose Case-Based Explainability
|
|
46
|
+
|
|
47
|
+
[](https://www.python.org/downloads/)
|
|
48
|
+
[](https://pypi.org/project/case-explainer/)
|
|
49
|
+
[](https://opensource.org/licenses/MIT)
|
|
50
|
+
[](https://paulwhitten.github.io/case-explainer/)
|
|
51
|
+
[](https://github.com/paulwhitten/case-explainer/actions/workflows/ci.yml)
|
|
52
|
+
|
|
53
|
+
Provides model-agnostic explanations through training set precedent and nearest neighbor correspondence.
|
|
54
|
+
|
|
55
|
+
**[Read the full documentation](https://paulwhitten.github.io/case-explainer/)**
|
|
56
|
+
|
|
57
|
+
## What is Case-Based Explainability?
|
|
58
|
+
|
|
59
|
+
While some explainability methods provide feature importance scores, case-based explainability answers: **"Why was this prediction made?"** by showing similar training examples.
|
|
60
|
+
|
|
61
|
+
Instead of: *"Feature X has importance 0.45"*
|
|
62
|
+
You get: *"This sample is classified as X because it resembles these 5 training examples"*
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- **Model-agnostic**: Works with any classifier (sklearn, XGBoost, neural networks, etc.)
|
|
67
|
+
- **Correspondence metric**: Quantifies agreement between prediction and neighbors
|
|
68
|
+
- **Multiple indexing strategies**: K-D Tree, Ball Tree, or brute force
|
|
69
|
+
- **Automatic scaling**: Optional feature standardization
|
|
70
|
+
- **Metadata tracking**: Attach provenance data to training samples
|
|
71
|
+
- **Sklearn-compatible API**: Familiar interface for ML practitioners
|
|
72
|
+
- **Batch explanations**: Explain multiple predictions efficiently
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install case-explainer
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or, to install the latest development version from source:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/paulwhitten/case-explainer.git
|
|
84
|
+
cd case-explainer
|
|
85
|
+
pip install -e .
|
|
86
|
+
|
|
87
|
+
# With development/test dependencies (pytest, pytest-cov, etc.)
|
|
88
|
+
pip install -e ".[dev]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Quick Start
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from case_explainer import CaseExplainer
|
|
95
|
+
from sklearn.datasets import load_iris
|
|
96
|
+
from sklearn.model_selection import train_test_split
|
|
97
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
98
|
+
|
|
99
|
+
# Load data
|
|
100
|
+
X, y = load_iris(return_X_y=True)
|
|
101
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
|
|
102
|
+
|
|
103
|
+
# Train classifier
|
|
104
|
+
clf = RandomForestClassifier()
|
|
105
|
+
clf.fit(X_train, y_train)
|
|
106
|
+
|
|
107
|
+
# Create explainer
|
|
108
|
+
explainer = CaseExplainer(
|
|
109
|
+
X_train=X_train,
|
|
110
|
+
y_train=y_train,
|
|
111
|
+
feature_names=['sepal_len', 'sepal_width', 'petal_len', 'petal_width'],
|
|
112
|
+
algorithm='kd_tree'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Explain a prediction
|
|
116
|
+
explanation = explainer.explain_instance(X_test[0], k=5, model=clf)
|
|
117
|
+
print(f"Correspondence: {explanation.correspondence:.2%}")
|
|
118
|
+
print(explanation.summary())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Core Concepts
|
|
122
|
+
|
|
123
|
+
### Correspondence Metric
|
|
124
|
+
|
|
125
|
+
Quantifies agreement between prediction and retrieved neighbors using inverse-cubed distance weighting:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
w(c) = sum[ 1 / (distance + 1)^3 ] for neighbors with class c
|
|
129
|
+
Correspondence = w(predicted_class) / sum( w(all_classes) )
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The `+1` offset in the denominator prevents division by zero when a test sample is identical to a training sample (distance = 0). In that case the weight is simply `1 / 1 = 1`.
|
|
133
|
+
|
|
134
|
+
**Example Interpretation Thresholds** (domain-dependent, not universal standards):
|
|
135
|
+
- **High (≥85%)**: Strong agreement with training precedent
|
|
136
|
+
- **Medium (70-85%)**: Moderate agreement
|
|
137
|
+
- **Low (<70%)**: Weak agreement, prediction may be uncertain
|
|
138
|
+
|
|
139
|
+
*Note: These thresholds are illustrative examples. Appropriate thresholds should be determined empirically for each specific domain and use case based on validation studies.*
|
|
140
|
+
|
|
141
|
+
### Indexing Strategies
|
|
142
|
+
|
|
143
|
+
- **`kd_tree`**: Fast for low-dimensional data (<20 features)
|
|
144
|
+
- **`ball_tree`**: Better for high-dimensional data
|
|
145
|
+
- **`brute`**: Exact search for small datasets (<10k samples)
|
|
146
|
+
|
|
147
|
+
## Examples
|
|
148
|
+
|
|
149
|
+
See `quickstart.py` for a complete working example:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
python quickstart.py
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Tutorial Notebooks
|
|
156
|
+
|
|
157
|
+
Interactive Jupyter notebooks for each validated domain:
|
|
158
|
+
|
|
159
|
+
- [Iris Classification](notebooks/01_iris_tutorial.ipynb) - Introductory multi-class example
|
|
160
|
+
- [Breast Cancer Diagnosis](notebooks/02_breast_cancer_tutorial.ipynb) - Medical diagnosis domain
|
|
161
|
+
- [Fraud Detection](notebooks/03_fraud_detection_tutorial.ipynb) - Financial security with extreme class imbalance
|
|
162
|
+
- [Hardware Trojan Detection](notebooks/04_hardware_trojan_tutorial.ipynb) - Large-scale security domain
|
|
163
|
+
|
|
164
|
+
### Benchmarking
|
|
165
|
+
|
|
166
|
+
Comprehensive performance benchmarks across multiple datasets:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python benchmark.py # Full benchmark including MNIST
|
|
170
|
+
python benchmark.py --no-mnist # Skip MNIST (faster)
|
|
171
|
+
python benchmark.py --help # See all options
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Results (single run on reference hardware):
|
|
175
|
+
- **Speed**: 14-37 ms per explanation depending on dataset size
|
|
176
|
+
- **Memory**: <1 MB to 131 MB (scales with data size and dimensionality)
|
|
177
|
+
- **Correspondence**: 87-100% neighbor agreement across validated domains
|
|
178
|
+
- **Scalability**: Tested up to 200k training samples
|
|
179
|
+
|
|
180
|
+
**Note on Correspondence**: This metric measures agreement between predictions and retrieved neighbors, not prediction accuracy or quality. High correspondence indicates consistency with training data patterns, not necessarily correct predictions.
|
|
181
|
+
|
|
182
|
+
### Documentation
|
|
183
|
+
|
|
184
|
+
**[View full API documentation online](https://paulwhitten.github.io/case-explainer/)**
|
|
185
|
+
|
|
186
|
+
Build and view documentation locally:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Build documentation
|
|
190
|
+
cd docs
|
|
191
|
+
make html
|
|
192
|
+
|
|
193
|
+
# View documentation locally
|
|
194
|
+
python3 -m http.server 8000 --directory docs/_build/html
|
|
195
|
+
# Then open http://localhost:8000 in your browser
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The documentation includes:
|
|
199
|
+
- Complete API reference for all classes and functions
|
|
200
|
+
- Usage examples and code snippets
|
|
201
|
+
- Theory and mathematical foundations
|
|
202
|
+
- Configuration guides and best practices
|
|
203
|
+
|
|
204
|
+
## Security & Privacy Considerations
|
|
205
|
+
|
|
206
|
+
**IMPORTANT:** Case-based explanations expose actual training samples as evidence. This can leak sensitive information:
|
|
207
|
+
|
|
208
|
+
- **Medical domains:** Patient records, diagnoses, treatments
|
|
209
|
+
- **Financial domains:** Account details, transaction patterns
|
|
210
|
+
- **Security domains:** Attack signatures, system vulnerabilities
|
|
211
|
+
- **Personal data:** User behavior, preferences, demographics
|
|
212
|
+
|
|
213
|
+
**Before using in production with sensitive data:**
|
|
214
|
+
1. Implement feature masking for sensitive columns
|
|
215
|
+
2. Consider differential privacy mechanisms
|
|
216
|
+
3. Apply anonymization to metadata
|
|
217
|
+
4. Set up access control and audit logging
|
|
218
|
+
5. Review legal/regulatory requirements (GDPR, HIPAA, etc.)
|
|
219
|
+
|
|
220
|
+
**Privacy protection features are planned for Phase 2.** For now, use only with non-sensitive data or in controlled research environments.
|
|
221
|
+
|
|
222
|
+
Unlike LIME/SHAP which only show feature importance, case-explainer exposes training sample features. Evaluate whether this trade-off is acceptable for your use case.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## API Overview
|
|
227
|
+
|
|
228
|
+
### CaseExplainer
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
explainer = CaseExplainer(
|
|
232
|
+
X_train, # Training features
|
|
233
|
+
y_train, # Training labels
|
|
234
|
+
feature_names=None, # Optional feature names
|
|
235
|
+
class_names=None, # Optional class names {0: 'cat', 1: 'dog'}
|
|
236
|
+
algorithm='kd_tree', # Indexing strategy
|
|
237
|
+
scale_data=True, # Standardize features
|
|
238
|
+
metadata=None # Optional provenance data
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Explain Single Instance
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
explanation = explainer.explain_instance(
|
|
246
|
+
test_sample, # Sample to explain
|
|
247
|
+
k=5, # Number of neighbors
|
|
248
|
+
model=clf, # Trained classifier
|
|
249
|
+
true_class=None, # Optional true label
|
|
250
|
+
distance_weighted=True # Use distance weighting
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Explain Batch
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
explanations = explainer.explain_batch(
|
|
258
|
+
X_test, # Test samples
|
|
259
|
+
k=5, # Number of neighbors
|
|
260
|
+
y_test=None, # Optional true labels
|
|
261
|
+
model=clf # Trained classifier
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Explanation Object
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
explanation.correspondence # Correspondence score [0, 1]
|
|
269
|
+
explanation.correspondence_interpretation # 'high', 'medium', 'low'
|
|
270
|
+
explanation.neighbors # List of Neighbor objects
|
|
271
|
+
explanation.predicted_class # Predicted class
|
|
272
|
+
explanation.is_correct() # True if prediction matches label
|
|
273
|
+
explanation.summary() # Text summary
|
|
274
|
+
explanation.to_dict() # Export as dictionary
|
|
275
|
+
explanation.plot() # Visualize (bar plot)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Validated Domains
|
|
279
|
+
|
|
280
|
+
**Hardware Trojan Detection** (56,959 samples, 5 features)
|
|
281
|
+
- 99.3% average correspondence across indexing methods
|
|
282
|
+
- High neighbor agreement on imbalanced security data
|
|
283
|
+
- 25.7 ms/sample explanation time (single run, reference hardware)
|
|
284
|
+
|
|
285
|
+
**Credit Card Fraud Detection** (284,807 samples, 30 features)
|
|
286
|
+
- 100% average correspondence (complete agreement with retrieved neighbors)
|
|
287
|
+
- Highly imbalanced dataset (268:1 normal:fraud ratio)
|
|
288
|
+
- 36.4 ms/sample explanation time (single run, reference hardware)
|
|
289
|
+
|
|
290
|
+
**Medical Diagnosis - Breast Cancer** (569 samples, 30 features)
|
|
291
|
+
- 93.3% average correspondence
|
|
292
|
+
- Correct predictions: 96.2% correspondence vs 47.3% for incorrect predictions
|
|
293
|
+
- 25.9 ms/sample explanation time (single run, reference hardware)
|
|
294
|
+
|
|
295
|
+
**Also Validated On:**
|
|
296
|
+
- Iris (92.7%), Wine (91.8%), Digits (94.9%), MNIST (87.5%)
|
|
297
|
+
- See `benchmark.py` for full results across 7 datasets
|
|
298
|
+
|
|
299
|
+
*Note: Correspondence measures neighbor agreement, not prediction quality. High correspondence with incorrect predictions indicates the model has learned incorrect patterns in the training data.*
|
|
300
|
+
|
|
301
|
+
## When to Use Case-Based Explainability
|
|
302
|
+
|
|
303
|
+
**Case-Explainer is well-suited for scenarios where:**
|
|
304
|
+
- Domain experts need to verify predictions against known training cases
|
|
305
|
+
- Precedent-based reasoning is valued (medical diagnosis, legal decisions, security analysis)
|
|
306
|
+
- Concrete examples are more intuitive than feature importance scores
|
|
307
|
+
- Training data has provenance or metadata worth surfacing to users
|
|
308
|
+
- Fast explanation generation is needed for real-time or interactive systems
|
|
309
|
+
|
|
310
|
+
**Alternative approaches (LIME, SHAP) may be preferable when:**
|
|
311
|
+
- Feature contributions are more relevant than training precedents
|
|
312
|
+
- Training data cannot be exposed due to privacy/security constraints
|
|
313
|
+
- Model debugging requires understanding feature-level behavior
|
|
314
|
+
|
|
315
|
+
### Comparison with LIME and SHAP
|
|
316
|
+
|
|
317
|
+
| Aspect | Case-Explainer | LIME | SHAP |
|
|
318
|
+
|--------|---------------|------|------|
|
|
319
|
+
| Explanation type | Training precedents (similar cases) | Local surrogate model (feature importance) | Shapley values (feature importance) |
|
|
320
|
+
| Output | k nearest neighbors + correspondence score | Per-feature importance for one prediction | Per-feature importance (local and global) |
|
|
321
|
+
| Privacy risk | High -- exposes actual training samples | Low -- uses synthetic perturbations | Low -- no sample exposure |
|
|
322
|
+
| Speed (pipeline, HW trojan) | ~13 ms/sample | ~25 ms/sample | ~1 ms/sample (TreeSHAP) |
|
|
323
|
+
| Model-agnostic | Yes | Yes | Yes (KernelSHAP); tree-specific variants are faster |
|
|
324
|
+
| Best for | Precedent-based reasoning, domain expert verification | Local feature contributions, model debugging | Global + local feature analysis, theoretical guarantees |
|
|
325
|
+
|
|
326
|
+
*Timing from the hardware trojan detection pipeline (XGBoost classifier, 5 features, ~57k samples). SHAP uses TreeSHAP which exploits tree structure for speed; KernelSHAP (model-agnostic) is substantially slower. LIME and Case-Explainer speeds are model-agnostic. Results will vary with dataset size, dimensionality, and hardware.*
|
|
327
|
+
|
|
328
|
+
## Limitations
|
|
329
|
+
|
|
330
|
+
**Privacy and Security**
|
|
331
|
+
- Exposes actual training samples, which may contain sensitive information
|
|
332
|
+
- Not suitable for sensitive data without additional privacy protection mechanisms
|
|
333
|
+
- Privacy-preserving features are planned for future releases
|
|
334
|
+
|
|
335
|
+
**Correspondence Metric**
|
|
336
|
+
- Measures neighbor agreement, not prediction correctness or quality
|
|
337
|
+
- High correspondence can occur with incorrect predictions if training data contains systematic errors
|
|
338
|
+
- Thresholds for "high/medium/low" must be validated per domain
|
|
339
|
+
|
|
340
|
+
**Performance Benchmarks**
|
|
341
|
+
- Timing and memory results are from single runs on reference hardware
|
|
342
|
+
- No statistical error bars or confidence intervals provided
|
|
343
|
+
- Results may vary significantly on different hardware and with different parameters
|
|
344
|
+
|
|
345
|
+
**Scalability**
|
|
346
|
+
- Memory usage scales linearly with training set size
|
|
347
|
+
- Very large datasets (>1M samples) may require approximate nearest neighbor methods (not yet implemented)
|
|
348
|
+
|
|
349
|
+
**Interpretability**
|
|
350
|
+
- Assumes users can meaningfully interpret feature values of retrieved neighbors
|
|
351
|
+
- Multi-feature patterns may be difficult to assess without domain expertise
|
|
352
|
+
- High-dimensional data may require dimensionality reduction for effective interpretation
|
|
353
|
+
|
|
354
|
+
## Development Status
|
|
355
|
+
|
|
356
|
+
### Core Functionality MVP
|
|
357
|
+
- [x] CaseExplainer class with sklearn-compatible API
|
|
358
|
+
- [x] Correspondence metric with distance weighting
|
|
359
|
+
- [x] Multiple indexing strategies (K-D tree, Ball tree, brute force)
|
|
360
|
+
- [x] Explanation object with summary and visualization
|
|
361
|
+
- [x] Metadata/provenance tracking
|
|
362
|
+
- [x] Batch explanation support
|
|
363
|
+
|
|
364
|
+
### Phase 1: Multi-Domain Validation
|
|
365
|
+
- [x] Hardware trojan detection (validated in JETTA paper)
|
|
366
|
+
- [x] Medical diagnosis (UCI Breast Cancer)
|
|
367
|
+
- [x] Fraud detection (Credit Card Fraud)
|
|
368
|
+
- [x] Benchmarking (time, memory, correspondence)
|
|
369
|
+
|
|
370
|
+
### Phase 2: Documentation - IN PROGRESS
|
|
371
|
+
- [x] API reference
|
|
372
|
+
- [x] Tutorial notebooks (4 domains)
|
|
373
|
+
- [x] Comparison guide (vs LIME/SHAP)
|
|
374
|
+
- [x] Code coverage >90%
|
|
375
|
+
|
|
376
|
+
### Phase 3: Testing & Quality
|
|
377
|
+
- [x] Unit test suite (pytest, >90% coverage)
|
|
378
|
+
- [x] Multi-Python version compatibility (3.8–3.12)
|
|
379
|
+
- [x] Integration tests across validated domains
|
|
380
|
+
- [ ] Privacy-preserving features (feature masking, differential privacy)
|
|
381
|
+
- [ ] Approximate nearest neighbors (Annoy, FAISS) for large-scale data
|
|
382
|
+
|
|
383
|
+
### Phase 4: Release & Distribution
|
|
384
|
+
- [x] PyPI package (`pip install case-explainer`)
|
|
385
|
+
- [x] GitHub Pages documentation (https://paulwhitten.github.io/case-explainer/)
|
|
386
|
+
- [x] CI/CD pipeline (GitHub Actions: test matrix, publish to PyPI)
|
|
387
|
+
- [ ] Zenodo DOI
|
|
388
|
+
|
|
389
|
+
## Citation
|
|
390
|
+
|
|
391
|
+
If you use this module in academic work, please cite:
|
|
392
|
+
|
|
393
|
+
```bibtex
|
|
394
|
+
@software{case_explainer2025,
|
|
395
|
+
author = {Whitten, Paul and Wolff, Francis and Papachristou, Chris},
|
|
396
|
+
title = {Case-Explainer: General-Purpose Case-Based Explainability},
|
|
397
|
+
year = {2025},
|
|
398
|
+
url = {https://github.com/paulwhitten/case-explainer}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## License
|
|
403
|
+
|
|
404
|
+
MIT License - see LICENSE file for details.
|
|
405
|
+
|
|
406
|
+
## Contributing
|
|
407
|
+
|
|
408
|
+
Contributions welcome! Core functionality and release infrastructure are complete.
|
|
409
|
+
|
|
410
|
+
**Priority areas:**
|
|
411
|
+
- Additional distance metrics (Manhattan, Cosine)
|
|
412
|
+
- Approximate nearest neighbors (Annoy, FAISS) for large-scale data
|
|
413
|
+
- Radar and parallel coordinate visualizations
|
|
414
|
+
- More comprehensive unit tests
|
|
415
|
+
|
|
416
|
+
## Contact
|
|
417
|
+
|
|
418
|
+
Questions? Issues? Open a GitHub issue or contact pcw@case.edu.
|
|
419
|
+
|
|
420
|
+
## Acknowledgments
|
|
421
|
+
|
|
422
|
+
- Inspired by Caruana et al. (1999) "Case-based explanation of non-case-based learning"
|
|
423
|
+
- Validated on hardware trojan detection research
|
|
424
|
+
- Built with scikit-learn, scipy, and matplotlib
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
case_explainer/__init__.py,sha256=Um44dCy3i570jUhtpj5pk8TF8-diFJ1R2lpKCu0CE3U,433
|
|
2
|
+
case_explainer/explainer.py,sha256=E1wdNo8c46dlaQ8hQMcGMRzjt5qYwCYOmpKkgpLjoKc,12680
|
|
3
|
+
case_explainer/explanation.py,sha256=cM_x9wfKa1cda7mBVz2h2DmU8Wuk7XUC3bQzsxWWT8Q,9115
|
|
4
|
+
case_explainer/indexing.py,sha256=xK3onjsEH8mo8Vzz0EhhzUdqzOUyrp-bhw08gfsv-T8,3375
|
|
5
|
+
case_explainer/metrics.py,sha256=BVK1ymb0dhpLex2J-Nn77gG-ybZY2VOBB6v4zei_nDA,3747
|
|
6
|
+
case_explainer-0.1.1.dist-info/licenses/LICENSE,sha256=NgFFFRrWHUd7Z_rl5QnCQqZNRc3B5WhRa1sy87ubrrE,1104
|
|
7
|
+
case_explainer-0.1.1.dist-info/METADATA,sha256=JEooQ_XZOVEdx3ySDdfwadHKxnxrorovGCLAAkUhTy8,16887
|
|
8
|
+
case_explainer-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
case_explainer-0.1.1.dist-info/top_level.txt,sha256=682HZHMTXvyhYJa1vPUoTDcJRcrm7KDsL3nzURW91MA,15
|
|
10
|
+
case_explainer-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Paul Whitten, Francis Wolff, Chris Papachristou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
case_explainer
|