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.
@@ -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
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
48
+ [![PyPI version](https://img.shields.io/pypi/v/case-explainer.svg)](https://pypi.org/project/case-explainer/)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
50
+ [![Documentation](https://img.shields.io/badge/docs-online-blue.svg)](https://paulwhitten.github.io/case-explainer/)
51
+ [![CI](https://github.com/paulwhitten/case-explainer/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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