modeldiff-ml 0.1.0__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.
- modeldiff/__init__.py +14 -0
- modeldiff/adapters/__init__.py +5 -0
- modeldiff/adapters/sklearn.py +31 -0
- modeldiff/analysis/__init__.py +5 -0
- modeldiff/analysis/disagreement.py +40 -0
- modeldiff/analysis/distributions.py +26 -0
- modeldiff/analysis/regions.py +210 -0
- modeldiff/api.py +249 -0
- modeldiff/cli.py +74 -0
- modeldiff/config.py +57 -0
- modeldiff/metrics/__init__.py +15 -0
- modeldiff/metrics/base.py +28 -0
- modeldiff/metrics/classification.py +53 -0
- modeldiff/metrics/regression.py +27 -0
- modeldiff/reporting/__init__.py +7 -0
- modeldiff/reporting/console.py +69 -0
- modeldiff/reporting/html.py +88 -0
- modeldiff/reporting/json.py +13 -0
- modeldiff/result.py +143 -0
- modeldiff/statistics/__init__.py +7 -0
- modeldiff/statistics/bootstrap.py +32 -0
- modeldiff/statistics/multiple_testing.py +21 -0
- modeldiff/statistics/permutation.py +42 -0
- modeldiff/utils.py +84 -0
- modeldiff_ml-0.1.0.dist-info/METADATA +251 -0
- modeldiff_ml-0.1.0.dist-info/RECORD +29 -0
- modeldiff_ml-0.1.0.dist-info/WHEEL +4 -0
- modeldiff_ml-0.1.0.dist-info/entry_points.txt +2 -0
- modeldiff_ml-0.1.0.dist-info/licenses/LICENSE +21 -0
modeldiff/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ModelDiff-ML: behavioral regression testing for machine-learning models."""
|
|
2
|
+
|
|
3
|
+
from .api import compare, compare_predictions
|
|
4
|
+
from .config import ModelDiffConfig
|
|
5
|
+
from .result import ModelDiffReport, RegionResult
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ModelDiffConfig",
|
|
9
|
+
"ModelDiffReport",
|
|
10
|
+
"RegionResult",
|
|
11
|
+
"compare",
|
|
12
|
+
"compare_predictions",
|
|
13
|
+
]
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Small model protocol adapter compatible with sklearn-style estimators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def predict_model(model: Any, X: pd.DataFrame, task: str) -> np.ndarray:
|
|
12
|
+
"""Obtain one-dimensional predictions without transforming model scores."""
|
|
13
|
+
if task == "regression":
|
|
14
|
+
if not hasattr(model, "predict"):
|
|
15
|
+
raise TypeError("regression models must provide predict(X)")
|
|
16
|
+
predictions = np.asarray(model.predict(X))
|
|
17
|
+
else:
|
|
18
|
+
if not hasattr(model, "predict_proba"):
|
|
19
|
+
if hasattr(model, "decision_function"):
|
|
20
|
+
raise TypeError(
|
|
21
|
+
"probability-based classification metrics require predict_proba(X); "
|
|
22
|
+
"decision_function scores are not silently transformed"
|
|
23
|
+
)
|
|
24
|
+
raise TypeError("binary classification models must provide predict_proba(X)")
|
|
25
|
+
probabilities = np.asarray(model.predict_proba(X))
|
|
26
|
+
if probabilities.ndim != 2 or probabilities.shape[1] != 2:
|
|
27
|
+
raise ValueError("predict_proba(X) must return shape (n_samples, 2)")
|
|
28
|
+
predictions = probabilities[:, 1]
|
|
29
|
+
if predictions.ndim != 1:
|
|
30
|
+
raise ValueError("model predictions must be one-dimensional")
|
|
31
|
+
return predictions.astype(float)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Prediction-level behavioral diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def classification_disagreement(
|
|
9
|
+
baseline: np.ndarray, candidate: np.ndarray, threshold: float = 0.5
|
|
10
|
+
) -> dict[str, float]:
|
|
11
|
+
"""Summarize probability shifts and hard-label disagreement."""
|
|
12
|
+
shift = np.abs(candidate - baseline)
|
|
13
|
+
baseline_confidence = np.abs(baseline - 0.5) * 2
|
|
14
|
+
candidate_confidence = np.abs(candidate - 0.5) * 2
|
|
15
|
+
return {
|
|
16
|
+
"mean_absolute_probability_shift": float(np.mean(shift)),
|
|
17
|
+
"median_absolute_probability_shift": float(np.median(shift)),
|
|
18
|
+
"p95_absolute_probability_shift": float(np.quantile(shift, 0.95)),
|
|
19
|
+
"hard_classification_disagreement_rate": float(
|
|
20
|
+
np.mean((baseline >= threshold) != (candidate >= threshold))
|
|
21
|
+
),
|
|
22
|
+
"mean_candidate_confidence_change": float(
|
|
23
|
+
np.mean(candidate_confidence - baseline_confidence)
|
|
24
|
+
),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def regression_disagreement(baseline: np.ndarray, candidate: np.ndarray) -> dict[str, float]:
|
|
29
|
+
"""Summarize regression prediction shifts."""
|
|
30
|
+
difference = np.abs(candidate - baseline)
|
|
31
|
+
if np.std(baseline) == 0 or np.std(candidate) == 0:
|
|
32
|
+
correlation = float("nan")
|
|
33
|
+
else:
|
|
34
|
+
correlation = float(np.corrcoef(baseline, candidate)[0, 1])
|
|
35
|
+
return {
|
|
36
|
+
"mean_absolute_prediction_difference": float(np.mean(difference)),
|
|
37
|
+
"median_absolute_prediction_difference": float(np.median(difference)),
|
|
38
|
+
"p95_absolute_prediction_difference": float(np.quantile(difference, 0.95)),
|
|
39
|
+
"prediction_correlation": correlation,
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Aggregate metric comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from modeldiff.metrics.classification import classification_metrics
|
|
8
|
+
from modeldiff.metrics.regression import regression_metrics
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def aggregate_metrics(
|
|
12
|
+
task: str,
|
|
13
|
+
y: np.ndarray,
|
|
14
|
+
baseline: np.ndarray,
|
|
15
|
+
candidate: np.ndarray,
|
|
16
|
+
threshold: float,
|
|
17
|
+
epsilon: float,
|
|
18
|
+
) -> dict[str, dict[str, float]]:
|
|
19
|
+
"""Compute full aggregate metric sets for both models."""
|
|
20
|
+
if task == "binary_classification":
|
|
21
|
+
baseline_metrics = classification_metrics(y, baseline, threshold, epsilon)
|
|
22
|
+
candidate_metrics = classification_metrics(y, candidate, threshold, epsilon)
|
|
23
|
+
else:
|
|
24
|
+
baseline_metrics = regression_metrics(y, baseline)
|
|
25
|
+
candidate_metrics = regression_metrics(y, candidate)
|
|
26
|
+
return {"baseline": baseline_metrics, "candidate": candidate_metrics}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Interpretable condition generation and beam-search region discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from modeldiff.config import ModelDiffConfig
|
|
12
|
+
from modeldiff.utils import python_value
|
|
13
|
+
|
|
14
|
+
Direction = Literal["regression", "improvement", "both"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Condition:
|
|
19
|
+
"""A single human-readable feature predicate."""
|
|
20
|
+
|
|
21
|
+
feature: str
|
|
22
|
+
operator: str
|
|
23
|
+
value: Any
|
|
24
|
+
|
|
25
|
+
def apply(self, X: pd.DataFrame) -> np.ndarray:
|
|
26
|
+
"""Evaluate the condition, treating missing values as non-members."""
|
|
27
|
+
series = X[self.feature]
|
|
28
|
+
present = series.notna().to_numpy()
|
|
29
|
+
if self.operator == "<=":
|
|
30
|
+
result = series.le(self.value).to_numpy()
|
|
31
|
+
elif self.operator == ">":
|
|
32
|
+
result = series.gt(self.value).to_numpy()
|
|
33
|
+
elif self.operator == "==":
|
|
34
|
+
result = series.eq(self.value).to_numpy()
|
|
35
|
+
elif self.operator == "!=":
|
|
36
|
+
result = series.ne(self.value).to_numpy()
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(f"unsupported condition operator: {self.operator}")
|
|
39
|
+
return np.asarray(result, dtype=bool) & present
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
value = repr(self.value) if isinstance(self.value, str) else f"{self.value:.6g}"
|
|
43
|
+
return f"{self.feature} {self.operator} {value}"
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
"""Return a JSON-compatible representation."""
|
|
47
|
+
return {
|
|
48
|
+
"feature": self.feature,
|
|
49
|
+
"operator": self.operator,
|
|
50
|
+
"value": python_value(self.value),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Region:
|
|
56
|
+
"""A conjunction of conditions found on discovery data."""
|
|
57
|
+
|
|
58
|
+
conditions: tuple[Condition, ...]
|
|
59
|
+
discovery_support: int
|
|
60
|
+
discovery_fraction: float
|
|
61
|
+
discovery_delta: float
|
|
62
|
+
score: float
|
|
63
|
+
|
|
64
|
+
def apply(self, X: pd.DataFrame) -> np.ndarray:
|
|
65
|
+
"""Evaluate all conditions against a feature frame."""
|
|
66
|
+
mask = np.ones(len(X), dtype=bool)
|
|
67
|
+
for condition in self.conditions:
|
|
68
|
+
mask &= condition.apply(X)
|
|
69
|
+
return mask
|
|
70
|
+
|
|
71
|
+
def __str__(self) -> str:
|
|
72
|
+
return " AND ".join(str(condition) for condition in self.conditions)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def generate_conditions(
|
|
76
|
+
X: pd.DataFrame, config: ModelDiffConfig, minimum_support: int
|
|
77
|
+
) -> list[Condition]:
|
|
78
|
+
"""Generate bounded numeric and categorical candidate predicates."""
|
|
79
|
+
conditions: list[Condition] = []
|
|
80
|
+
n = len(X)
|
|
81
|
+
maximum_support = int(np.floor(config.max_region_fraction * n))
|
|
82
|
+
for feature in X.columns:
|
|
83
|
+
series = X[feature]
|
|
84
|
+
if pd.api.types.is_numeric_dtype(series) and not pd.api.types.is_bool_dtype(series):
|
|
85
|
+
values = series.dropna().to_numpy(dtype=float)
|
|
86
|
+
if values.size == 0:
|
|
87
|
+
continue
|
|
88
|
+
thresholds = np.unique(np.quantile(values, config.quantiles))
|
|
89
|
+
for threshold in thresholds:
|
|
90
|
+
for operator in ("<=", ">"):
|
|
91
|
+
condition = Condition(feature, operator, float(threshold))
|
|
92
|
+
support = int(condition.apply(X).sum())
|
|
93
|
+
if minimum_support <= support <= maximum_support:
|
|
94
|
+
conditions.append(condition)
|
|
95
|
+
else:
|
|
96
|
+
counts = series.dropna().value_counts()
|
|
97
|
+
for category in counts.head(config.max_categories).index:
|
|
98
|
+
for operator in ("==", "!="):
|
|
99
|
+
condition = Condition(feature, operator, python_value(category))
|
|
100
|
+
support = int(condition.apply(X).sum())
|
|
101
|
+
if minimum_support <= support <= maximum_support:
|
|
102
|
+
conditions.append(condition)
|
|
103
|
+
return conditions
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _direction_score(mean_delta: float, support_fraction: float, direction: Direction) -> float:
|
|
107
|
+
if direction == "regression":
|
|
108
|
+
effect = mean_delta
|
|
109
|
+
elif direction == "improvement":
|
|
110
|
+
effect = -mean_delta
|
|
111
|
+
else:
|
|
112
|
+
effect = abs(mean_delta)
|
|
113
|
+
return max(effect, 0.0) * float(np.sqrt(support_fraction))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _region_key(conditions: tuple[Condition, ...]) -> tuple[str, ...]:
|
|
117
|
+
return tuple(sorted(str(condition) for condition in conditions))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def discover_regions(
|
|
121
|
+
X: pd.DataFrame,
|
|
122
|
+
delta: np.ndarray,
|
|
123
|
+
config: ModelDiffConfig,
|
|
124
|
+
direction: Direction = "both",
|
|
125
|
+
) -> list[Region]:
|
|
126
|
+
"""Find high-effect regions using deterministic breadth-limited beam search."""
|
|
127
|
+
n = len(X)
|
|
128
|
+
minimum_support = max(config.min_region_size or 0, int(np.ceil(config.min_region_fraction * n)))
|
|
129
|
+
candidates = generate_conditions(X, config, minimum_support)
|
|
130
|
+
candidate_masks = {condition: condition.apply(X) for condition in candidates}
|
|
131
|
+
beam: list[tuple[Region, np.ndarray]] = []
|
|
132
|
+
all_regions: list[tuple[Region, np.ndarray]] = []
|
|
133
|
+
seen: set[tuple[str, ...]] = set()
|
|
134
|
+
|
|
135
|
+
for depth in range(1, config.max_depth + 1):
|
|
136
|
+
expanded: list[tuple[Region, np.ndarray]] = []
|
|
137
|
+
parents: list[tuple[tuple[Condition, ...], np.ndarray]]
|
|
138
|
+
if depth == 1:
|
|
139
|
+
parents = [((), np.ones(n, dtype=bool))]
|
|
140
|
+
else:
|
|
141
|
+
parents = [(region.conditions, mask) for region, mask in beam]
|
|
142
|
+
for parent_conditions, parent_mask in parents:
|
|
143
|
+
used_features = {condition.feature for condition in parent_conditions}
|
|
144
|
+
for condition in candidates:
|
|
145
|
+
# One predicate per feature is simple, readable, and avoids contradictions.
|
|
146
|
+
if condition.feature in used_features:
|
|
147
|
+
continue
|
|
148
|
+
conditions = (*parent_conditions, condition)
|
|
149
|
+
key = _region_key(conditions)
|
|
150
|
+
if key in seen:
|
|
151
|
+
continue
|
|
152
|
+
seen.add(key)
|
|
153
|
+
mask = parent_mask & candidate_masks[condition]
|
|
154
|
+
support = int(mask.sum())
|
|
155
|
+
fraction = support / n
|
|
156
|
+
if support < minimum_support or fraction > config.max_region_fraction:
|
|
157
|
+
continue
|
|
158
|
+
mean_delta = float(np.mean(delta[mask]))
|
|
159
|
+
score = _direction_score(mean_delta, fraction, direction)
|
|
160
|
+
if score <= 0:
|
|
161
|
+
continue
|
|
162
|
+
expanded.append(
|
|
163
|
+
(
|
|
164
|
+
Region(conditions, support, fraction, mean_delta, score),
|
|
165
|
+
mask,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
expanded.sort(
|
|
169
|
+
key=lambda item: (
|
|
170
|
+
-item[0].score,
|
|
171
|
+
-item[0].discovery_support,
|
|
172
|
+
len(item[0].conditions),
|
|
173
|
+
str(item[0]),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
beam = expanded[: config.beam_width]
|
|
177
|
+
all_regions.extend(beam)
|
|
178
|
+
if not beam:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
return deduplicate_regions(all_regions, config.jaccard_threshold, config.max_regions)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def jaccard(mask_a: np.ndarray, mask_b: np.ndarray) -> float:
|
|
185
|
+
"""Return Jaccard similarity between two boolean membership masks."""
|
|
186
|
+
union = int(np.sum(mask_a | mask_b))
|
|
187
|
+
return float(np.sum(mask_a & mask_b) / union) if union else 1.0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def deduplicate_regions(
|
|
191
|
+
regions_with_masks: list[tuple[Region, np.ndarray]], threshold: float, maximum: int
|
|
192
|
+
) -> list[Region]:
|
|
193
|
+
"""Keep the strongest region among highly overlapping candidates."""
|
|
194
|
+
ranked = sorted(
|
|
195
|
+
regions_with_masks,
|
|
196
|
+
key=lambda item: (
|
|
197
|
+
-item[0].score,
|
|
198
|
+
-item[0].discovery_support,
|
|
199
|
+
len(item[0].conditions),
|
|
200
|
+
str(item[0]),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
selected: list[tuple[Region, np.ndarray]] = []
|
|
204
|
+
for region, mask in ranked:
|
|
205
|
+
if any(jaccard(mask, existing_mask) > threshold for _, existing_mask in selected):
|
|
206
|
+
continue
|
|
207
|
+
selected.append((region, mask))
|
|
208
|
+
if len(selected) >= maximum:
|
|
209
|
+
break
|
|
210
|
+
return [region for region, _ in selected]
|
modeldiff/api.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Public comparison pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from modeldiff.adapters import predict_model
|
|
11
|
+
from modeldiff.analysis.disagreement import (
|
|
12
|
+
classification_disagreement,
|
|
13
|
+
regression_disagreement,
|
|
14
|
+
)
|
|
15
|
+
from modeldiff.analysis.distributions import aggregate_metrics
|
|
16
|
+
from modeldiff.analysis.regions import Region, discover_regions
|
|
17
|
+
from modeldiff.config import ModelDiffConfig
|
|
18
|
+
from modeldiff.metrics.base import get_loss_function, primary_metric_value
|
|
19
|
+
from modeldiff.result import ModelDiffReport, RegionResult
|
|
20
|
+
from modeldiff.statistics import benjamini_hochberg, bootstrap_mean_ci, sign_flip_test
|
|
21
|
+
from modeldiff.utils import normalize_X, validate_inputs
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _observation_losses(
|
|
25
|
+
task: str,
|
|
26
|
+
metric: str,
|
|
27
|
+
y: np.ndarray,
|
|
28
|
+
predictions: np.ndarray,
|
|
29
|
+
config: ModelDiffConfig,
|
|
30
|
+
) -> np.ndarray:
|
|
31
|
+
function = get_loss_function(task, metric)
|
|
32
|
+
if task == "binary_classification" and metric == "log_loss":
|
|
33
|
+
return function(y, predictions, epsilon=config.epsilon)
|
|
34
|
+
if task == "binary_classification" and metric == "accuracy":
|
|
35
|
+
return function(y, predictions, threshold=config.threshold)
|
|
36
|
+
return function(y, predictions)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_regions(
|
|
40
|
+
regions: list[Region],
|
|
41
|
+
direction: str,
|
|
42
|
+
X_validation: Any,
|
|
43
|
+
baseline_loss: np.ndarray,
|
|
44
|
+
candidate_loss: np.ndarray,
|
|
45
|
+
config: ModelDiffConfig,
|
|
46
|
+
rng: np.random.Generator,
|
|
47
|
+
warnings: list[str],
|
|
48
|
+
) -> list[RegionResult]:
|
|
49
|
+
results: list[RegionResult] = []
|
|
50
|
+
delta = candidate_loss - baseline_loss
|
|
51
|
+
for region in regions:
|
|
52
|
+
mask = region.apply(X_validation)
|
|
53
|
+
support = int(mask.sum())
|
|
54
|
+
if support == 0:
|
|
55
|
+
warnings.append(f"Region disappeared in validation: {region}")
|
|
56
|
+
continue
|
|
57
|
+
if support < 5:
|
|
58
|
+
warnings.append(f"Region has fewer than five validation observations: {region}")
|
|
59
|
+
region_delta = delta[mask]
|
|
60
|
+
alternative: Literal["greater", "less"] = "greater" if direction == "regression" else "less"
|
|
61
|
+
ci = bootstrap_mean_ci(
|
|
62
|
+
region_delta,
|
|
63
|
+
n_bootstrap=config.n_bootstrap,
|
|
64
|
+
confidence_level=config.confidence_level,
|
|
65
|
+
random_state=rng,
|
|
66
|
+
)
|
|
67
|
+
p_value = sign_flip_test(
|
|
68
|
+
region_delta,
|
|
69
|
+
n_permutations=config.n_permutations,
|
|
70
|
+
alternative=alternative,
|
|
71
|
+
random_state=rng,
|
|
72
|
+
)
|
|
73
|
+
results.append(
|
|
74
|
+
RegionResult(
|
|
75
|
+
conditions=region.conditions,
|
|
76
|
+
direction=direction,
|
|
77
|
+
discovery_support=region.discovery_support,
|
|
78
|
+
discovery_fraction=region.discovery_fraction,
|
|
79
|
+
validation_support=support,
|
|
80
|
+
validation_fraction=support / len(X_validation),
|
|
81
|
+
discovery_delta=region.discovery_delta,
|
|
82
|
+
validation_delta=float(np.mean(region_delta)),
|
|
83
|
+
confidence_interval=ci,
|
|
84
|
+
p_value=p_value,
|
|
85
|
+
q_value=1.0,
|
|
86
|
+
score=region.score,
|
|
87
|
+
baseline_mean_loss=float(np.mean(baseline_loss[mask])),
|
|
88
|
+
candidate_mean_loss=float(np.mean(candidate_loss[mask])),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compare_predictions(
|
|
95
|
+
baseline_predictions: Any,
|
|
96
|
+
candidate_predictions: Any,
|
|
97
|
+
X: Any,
|
|
98
|
+
y: Any,
|
|
99
|
+
task: str,
|
|
100
|
+
metric: str,
|
|
101
|
+
*,
|
|
102
|
+
config: ModelDiffConfig | None = None,
|
|
103
|
+
random_state: int | None = None,
|
|
104
|
+
) -> ModelDiffReport:
|
|
105
|
+
"""Compare precomputed model predictions and discover validated behavior changes.
|
|
106
|
+
|
|
107
|
+
Positive loss deltas mean the candidate is worse. Regions are discovered on
|
|
108
|
+
one random partition and statistically evaluated on the independent holdout.
|
|
109
|
+
"""
|
|
110
|
+
effective_config = config or ModelDiffConfig()
|
|
111
|
+
if random_state is not None:
|
|
112
|
+
effective_config = replace(effective_config, random_state=random_state)
|
|
113
|
+
frame, target, baseline, candidate = validate_inputs(
|
|
114
|
+
X, y, baseline_predictions, candidate_predictions, task, metric
|
|
115
|
+
)
|
|
116
|
+
baseline_loss = _observation_losses(task, metric, target, baseline, effective_config)
|
|
117
|
+
candidate_loss = _observation_losses(task, metric, target, candidate, effective_config)
|
|
118
|
+
delta = candidate_loss - baseline_loss
|
|
119
|
+
|
|
120
|
+
baseline_primary = primary_metric_value(baseline_loss, metric)
|
|
121
|
+
candidate_primary = primary_metric_value(candidate_loss, metric)
|
|
122
|
+
absolute_delta = candidate_primary - baseline_primary
|
|
123
|
+
relative_delta = absolute_delta / baseline_primary if baseline_primary != 0 else None
|
|
124
|
+
overall = {
|
|
125
|
+
"baseline": baseline_primary,
|
|
126
|
+
"candidate": candidate_primary,
|
|
127
|
+
"absolute_delta": absolute_delta,
|
|
128
|
+
"relative_delta": relative_delta,
|
|
129
|
+
}
|
|
130
|
+
aggregates = aggregate_metrics(
|
|
131
|
+
task, target, baseline, candidate, effective_config.threshold, effective_config.epsilon
|
|
132
|
+
)
|
|
133
|
+
if task == "binary_classification":
|
|
134
|
+
diagnostics = classification_disagreement(baseline, candidate, effective_config.threshold)
|
|
135
|
+
else:
|
|
136
|
+
diagnostics = regression_disagreement(baseline, candidate)
|
|
137
|
+
|
|
138
|
+
rng = np.random.default_rng(effective_config.random_state)
|
|
139
|
+
order = rng.permutation(len(frame))
|
|
140
|
+
discovery_count = int(np.floor(effective_config.discovery_fraction * len(frame)))
|
|
141
|
+
discovery_indices = order[:discovery_count]
|
|
142
|
+
validation_indices = order[discovery_count:]
|
|
143
|
+
X_discovery = frame.iloc[discovery_indices].reset_index(drop=True)
|
|
144
|
+
X_validation = frame.iloc[validation_indices].reset_index(drop=True)
|
|
145
|
+
discovery_delta = delta[discovery_indices]
|
|
146
|
+
|
|
147
|
+
warnings: list[str] = []
|
|
148
|
+
if len(validation_indices) < 30:
|
|
149
|
+
warnings.append("Validation set has fewer than 30 observations; inference may be unstable.")
|
|
150
|
+
if np.array_equal(baseline, candidate):
|
|
151
|
+
warnings.append("Candidate predictions are identical to baseline predictions.")
|
|
152
|
+
if np.std(baseline) == 0 or np.std(candidate) == 0:
|
|
153
|
+
warnings.append("At least one model has constant predictions.")
|
|
154
|
+
for feature in frame.columns:
|
|
155
|
+
if frame[feature].isna().mean() > 0.5:
|
|
156
|
+
warnings.append(f"Feature {feature!r} contains more than 50% missing values.")
|
|
157
|
+
|
|
158
|
+
discovered_regressions = discover_regions(
|
|
159
|
+
X_discovery, discovery_delta, effective_config, "regression"
|
|
160
|
+
)
|
|
161
|
+
discovered_improvements = discover_regions(
|
|
162
|
+
X_discovery, discovery_delta, effective_config, "improvement"
|
|
163
|
+
)
|
|
164
|
+
validated = _validate_regions(
|
|
165
|
+
discovered_regressions,
|
|
166
|
+
"regression",
|
|
167
|
+
X_validation,
|
|
168
|
+
baseline_loss[validation_indices],
|
|
169
|
+
candidate_loss[validation_indices],
|
|
170
|
+
effective_config,
|
|
171
|
+
rng,
|
|
172
|
+
warnings,
|
|
173
|
+
)
|
|
174
|
+
validated += _validate_regions(
|
|
175
|
+
discovered_improvements,
|
|
176
|
+
"improvement",
|
|
177
|
+
X_validation,
|
|
178
|
+
baseline_loss[validation_indices],
|
|
179
|
+
candidate_loss[validation_indices],
|
|
180
|
+
effective_config,
|
|
181
|
+
rng,
|
|
182
|
+
warnings,
|
|
183
|
+
)
|
|
184
|
+
q_values = benjamini_hochberg([region.p_value for region in validated])
|
|
185
|
+
validated = [
|
|
186
|
+
replace(
|
|
187
|
+
region,
|
|
188
|
+
q_value=float(q_value),
|
|
189
|
+
significant=float(q_value) <= effective_config.significance_level,
|
|
190
|
+
)
|
|
191
|
+
for region, q_value in zip(validated, q_values, strict=True)
|
|
192
|
+
]
|
|
193
|
+
regressions = sorted(
|
|
194
|
+
[
|
|
195
|
+
region
|
|
196
|
+
for region in validated
|
|
197
|
+
if region.direction == "regression" and region.validation_delta > 0
|
|
198
|
+
],
|
|
199
|
+
key=lambda region: (not region.significant, -region.validation_delta, region.q_value),
|
|
200
|
+
)
|
|
201
|
+
improvements = sorted(
|
|
202
|
+
[
|
|
203
|
+
region
|
|
204
|
+
for region in validated
|
|
205
|
+
if region.direction == "improvement" and region.validation_delta < 0
|
|
206
|
+
],
|
|
207
|
+
key=lambda region: (not region.significant, region.validation_delta, region.q_value),
|
|
208
|
+
)
|
|
209
|
+
return ModelDiffReport(
|
|
210
|
+
task=task,
|
|
211
|
+
metric=metric,
|
|
212
|
+
sample_count=len(frame),
|
|
213
|
+
discovery_count=len(discovery_indices),
|
|
214
|
+
validation_count=len(validation_indices),
|
|
215
|
+
overall=overall,
|
|
216
|
+
aggregate_metrics=aggregates,
|
|
217
|
+
disagreement=diagnostics,
|
|
218
|
+
regression_regions=regressions,
|
|
219
|
+
improvement_regions=improvements,
|
|
220
|
+
warnings=warnings,
|
|
221
|
+
configuration=effective_config,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def compare(
|
|
226
|
+
baseline: Any,
|
|
227
|
+
candidate: Any,
|
|
228
|
+
X: Any,
|
|
229
|
+
y: Any,
|
|
230
|
+
task: str,
|
|
231
|
+
metric: str,
|
|
232
|
+
*,
|
|
233
|
+
config: ModelDiffConfig | None = None,
|
|
234
|
+
random_state: int | None = None,
|
|
235
|
+
) -> ModelDiffReport:
|
|
236
|
+
"""Compare two fitted sklearn-style models on a shared evaluation dataset."""
|
|
237
|
+
frame = normalize_X(X)
|
|
238
|
+
baseline_predictions = predict_model(baseline, frame, task)
|
|
239
|
+
candidate_predictions = predict_model(candidate, frame, task)
|
|
240
|
+
return compare_predictions(
|
|
241
|
+
baseline_predictions,
|
|
242
|
+
candidate_predictions,
|
|
243
|
+
frame,
|
|
244
|
+
y,
|
|
245
|
+
task,
|
|
246
|
+
metric,
|
|
247
|
+
config=config,
|
|
248
|
+
random_state=random_state,
|
|
249
|
+
)
|
modeldiff/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Command-line interface for precomputed-prediction comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from modeldiff import ModelDiffConfig, compare_predictions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Build the command-line argument parser."""
|
|
17
|
+
parser = argparse.ArgumentParser(prog="modeldiff-ml", description=__doc__)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
compare_parser = subparsers.add_parser("compare", help="compare predictions in a CSV file")
|
|
20
|
+
compare_parser.add_argument("--data", required=True, type=Path)
|
|
21
|
+
compare_parser.add_argument("--target", required=True)
|
|
22
|
+
compare_parser.add_argument("--baseline-pred", required=True)
|
|
23
|
+
compare_parser.add_argument("--candidate-pred", required=True)
|
|
24
|
+
compare_parser.add_argument(
|
|
25
|
+
"--task", required=True, choices=["binary_classification", "regression"]
|
|
26
|
+
)
|
|
27
|
+
compare_parser.add_argument("--metric", required=True)
|
|
28
|
+
compare_parser.add_argument("--exclude", action="append", default=[])
|
|
29
|
+
compare_parser.add_argument("--html", type=Path)
|
|
30
|
+
compare_parser.add_argument("--json", type=Path)
|
|
31
|
+
compare_parser.add_argument("--random-state", type=int, default=42)
|
|
32
|
+
compare_parser.add_argument("--fail-on-significant-regression", action="store_true")
|
|
33
|
+
compare_parser.add_argument("--max-overall-regression", type=float)
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
38
|
+
"""Run the ModelDiff-ML CLI and return a CI-friendly exit status."""
|
|
39
|
+
args = build_parser().parse_args(argv)
|
|
40
|
+
data = pd.read_csv(args.data)
|
|
41
|
+
required = {args.target, args.baseline_pred, args.candidate_pred}
|
|
42
|
+
missing = required - set(data.columns)
|
|
43
|
+
if missing:
|
|
44
|
+
raise SystemExit(f"missing columns: {', '.join(sorted(missing))}")
|
|
45
|
+
excluded = required | set(args.exclude)
|
|
46
|
+
features = data.drop(columns=list(excluded), errors="ignore")
|
|
47
|
+
if features.shape[1] == 0:
|
|
48
|
+
raise SystemExit("no feature columns remain after exclusions")
|
|
49
|
+
config = ModelDiffConfig(random_state=args.random_state)
|
|
50
|
+
report = compare_predictions(
|
|
51
|
+
data[args.baseline_pred],
|
|
52
|
+
data[args.candidate_pred],
|
|
53
|
+
features,
|
|
54
|
+
data[args.target],
|
|
55
|
+
args.task,
|
|
56
|
+
args.metric,
|
|
57
|
+
config=config,
|
|
58
|
+
)
|
|
59
|
+
print(report.summary())
|
|
60
|
+
if args.html:
|
|
61
|
+
report.to_html(args.html)
|
|
62
|
+
if args.json:
|
|
63
|
+
report.to_json(args.json)
|
|
64
|
+
failed = args.fail_on_significant_regression and bool(report.regressions(significant_only=True))
|
|
65
|
+
if (
|
|
66
|
+
args.max_overall_regression is not None
|
|
67
|
+
and report.overall["absolute_delta"] > args.max_overall_regression
|
|
68
|
+
):
|
|
69
|
+
failed = True
|
|
70
|
+
return 1 if failed else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__": # pragma: no cover
|
|
74
|
+
sys.exit(main())
|