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 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,5 @@
1
+ """Model adapters."""
2
+
3
+ from .sklearn import predict_model
4
+
5
+ __all__ = ["predict_model"]
@@ -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,5 @@
1
+ """Analysis helpers."""
2
+
3
+ from .disagreement import classification_disagreement, regression_disagreement
4
+
5
+ __all__ = ["classification_disagreement", "regression_disagreement"]
@@ -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())