ml-reliability-toolkit 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.
@@ -0,0 +1,202 @@
1
+ import math
2
+ from math import sqrt
3
+ from typing import Any
4
+
5
+ from ml_reliability.core.result import CheckResult
6
+
7
+
8
+ def check_regression_metrics(
9
+ model: Any,
10
+ X_test: Any,
11
+ y_test: Any,
12
+ max_acceptable_mae: float | None = None,
13
+ max_scoring_mae: float | None = None,
14
+ ) -> CheckResult:
15
+ """Calculate regression MAE and RMSE and optionally assess quality."""
16
+
17
+ check_id = "model.regression_metrics"
18
+ title = "Regression Metrics"
19
+ category = "performance"
20
+
21
+ def configuration_error(message: str) -> CheckResult:
22
+ return CheckResult(
23
+ check_id=check_id,
24
+ title=title,
25
+ category=category,
26
+ passed=False,
27
+ severity="critical",
28
+ score=0.0,
29
+ message=message,
30
+ recommendation="Correct the regression metric configuration.",
31
+ )
32
+
33
+ for name, value in (
34
+ ("max_acceptable_mae", max_acceptable_mae),
35
+ ("max_scoring_mae", max_scoring_mae),
36
+ ):
37
+ if value is None:
38
+ continue
39
+
40
+ if (
41
+ isinstance(value, bool)
42
+ or not isinstance(value, (int, float))
43
+ or not math.isfinite(value)
44
+ or value < 0
45
+ or (name == "max_scoring_mae" and value == 0)
46
+ ):
47
+ requirement = (
48
+ "a finite, strictly positive number"
49
+ if name == "max_scoring_mae"
50
+ else "a finite, non-negative number"
51
+ )
52
+ return configuration_error(f"{name} must be {requirement}.")
53
+
54
+ if model is None:
55
+ return CheckResult(
56
+ check_id=check_id,
57
+ title=title,
58
+ category=category,
59
+ passed=False,
60
+ severity="critical",
61
+ score=0.0,
62
+ message="A trained model is required.",
63
+ recommendation="Provide a trained model with a predict() method.",
64
+ )
65
+
66
+ if X_test is None or y_test is None:
67
+ return CheckResult(
68
+ check_id=check_id,
69
+ title=title,
70
+ category=category,
71
+ passed=False,
72
+ severity="critical",
73
+ score=0.0,
74
+ message="X_test and y_test are required.",
75
+ recommendation="Provide both X_test and y_test.",
76
+ )
77
+
78
+ if not callable(getattr(model, "predict", None)):
79
+ return CheckResult(
80
+ check_id=check_id,
81
+ title=title,
82
+ category=category,
83
+ passed=False,
84
+ severity="critical",
85
+ score=0.0,
86
+ message="The model must provide a callable predict() method.",
87
+ recommendation="Provide a trained model implementing predict().",
88
+ )
89
+
90
+ try:
91
+ predictions = list(model.predict(X_test))
92
+ actual = list(y_test)
93
+
94
+ for name, values in (
95
+ ("predictions", predictions),
96
+ ("targets", actual),
97
+ ):
98
+ for index, value in enumerate(values):
99
+ if (
100
+ isinstance(value, bool)
101
+ or not isinstance(value, (int, float))
102
+ or not math.isfinite(value)
103
+ ):
104
+ raise ValueError(
105
+ f"Non-finite or invalid numeric value in "
106
+ f"{name} at index {index}: {value!r}."
107
+ )
108
+
109
+ if not actual:
110
+ raise ValueError("y_test must contain at least one sample.")
111
+
112
+ if len(actual) != len(predictions):
113
+ raise ValueError("Predictions and y_test must have the same length.")
114
+
115
+ absolute_errors = [
116
+ abs(prediction - target) for prediction, target in zip(predictions, actual)
117
+ ]
118
+ mae = sum(absolute_errors) / len(actual)
119
+
120
+ squared_errors = [
121
+ (prediction - target) ** 2
122
+ for prediction, target in zip(predictions, actual)
123
+ ]
124
+ rmse = sqrt(sum(squared_errors) / len(actual))
125
+
126
+ if not math.isfinite(mae) or not math.isfinite(rmse):
127
+ raise ValueError(
128
+ "Calculated MAE and RMSE must be finite; "
129
+ f"received mae={mae!r}, rmse={rmse!r}."
130
+ )
131
+
132
+ passed = True if max_acceptable_mae is None else mae <= max_acceptable_mae
133
+
134
+ if max_scoring_mae is not None:
135
+ score = max(
136
+ 0.0,
137
+ min(100.0, (1.0 - mae / max_scoring_mae) * 100.0),
138
+ )
139
+ elif max_acceptable_mae is not None:
140
+ score = 100.0 if passed else 0.0
141
+ else:
142
+ score = None
143
+
144
+ severity = "info" if max_acceptable_mae is None or passed else "warning"
145
+
146
+ message = (
147
+ f"Regression metrics calculated successfully: "
148
+ f"mae={mae:.4f}, rmse={rmse:.4f}."
149
+ )
150
+
151
+ if max_acceptable_mae is None:
152
+ message += (
153
+ " No MAE tolerance configured; "
154
+ "model quality was not assessed against a pass/fail threshold."
155
+ )
156
+ else:
157
+ message += (
158
+ f" MAE tolerance={max_acceptable_mae:.4f}; "
159
+ f"tolerance {'met' if passed else 'exceeded'}."
160
+ )
161
+
162
+ if max_scoring_mae is not None:
163
+ message += f" Scoring scale={max_scoring_mae:.4f}; score={score:.2f}/100."
164
+
165
+ recommendation = (
166
+ ""
167
+ if passed
168
+ else (
169
+ "Investigate prediction errors or revise the configured MAE tolerance."
170
+ )
171
+ )
172
+
173
+ return CheckResult(
174
+ check_id=check_id,
175
+ title=title,
176
+ category=category,
177
+ passed=passed,
178
+ severity=severity,
179
+ score=score,
180
+ message=message,
181
+ evidence={
182
+ "metric": "mae",
183
+ "mae": round(float(mae), 4),
184
+ "rmse": round(float(rmse), 4),
185
+ "test_samples": len(actual),
186
+ "max_acceptable_mae": max_acceptable_mae,
187
+ "max_scoring_mae": max_scoring_mae,
188
+ },
189
+ recommendation=recommendation,
190
+ )
191
+
192
+ except Exception as exc: # noqa: BLE001 - Convert evaluation failures to check results.
193
+ return CheckResult(
194
+ check_id=check_id,
195
+ title=title,
196
+ category=category,
197
+ passed=False,
198
+ severity="critical",
199
+ score=0.0,
200
+ message=f"Regression metric calculation failed: {exc}",
201
+ recommendation=("Verify that predictions and test targets are valid."),
202
+ )
@@ -0,0 +1,94 @@
1
+ from typing import Any
2
+
3
+ from ml_reliability.core.result import CheckResult
4
+
5
+
6
+ def check_schema(X: Any) -> CheckResult:
7
+ """
8
+ Validate the basic structure of a tabular dataset.
9
+ """
10
+
11
+ if X is None:
12
+ return CheckResult(
13
+ check_id="data.schema",
14
+ title="Dataset Schema",
15
+ category="data_quality",
16
+ passed=False,
17
+ severity="critical",
18
+ score=0.0,
19
+ message="Input dataset is missing.",
20
+ recommendation="Provide a valid pandas DataFrame.",
21
+ )
22
+
23
+ if not hasattr(X, "shape") or not hasattr(X, "columns") or not hasattr(X, "dtypes"):
24
+ return CheckResult(
25
+ check_id="data.schema",
26
+ title="Dataset Schema",
27
+ category="data_quality",
28
+ passed=False,
29
+ severity="critical",
30
+ score=0.0,
31
+ message="Input must be a pandas DataFrame.",
32
+ recommendation="Provide the dataset as a pandas DataFrame.",
33
+ )
34
+
35
+ rows = int(X.shape[0])
36
+ columns = int(X.shape[1])
37
+
38
+ if rows == 0:
39
+ return CheckResult(
40
+ check_id="data.schema",
41
+ title="Dataset Schema",
42
+ category="data_quality",
43
+ passed=False,
44
+ severity="critical",
45
+ score=0.0,
46
+ message="Dataset contains zero rows.",
47
+ evidence={
48
+ "rows": rows,
49
+ "columns": columns,
50
+ "column_names": [str(column) for column in X.columns],
51
+ "dtypes": {
52
+ str(column): str(dtype) for column, dtype in X.dtypes.items()
53
+ },
54
+ },
55
+ recommendation="Provide a dataset containing at least one row.",
56
+ )
57
+
58
+ if columns == 0:
59
+ return CheckResult(
60
+ check_id="data.schema",
61
+ title="Dataset Schema",
62
+ category="data_quality",
63
+ passed=False,
64
+ severity="critical",
65
+ score=0.0,
66
+ message="Dataset contains zero columns.",
67
+ evidence={
68
+ "rows": rows,
69
+ "columns": columns,
70
+ "column_names": [],
71
+ "dtypes": {},
72
+ },
73
+ recommendation="Provide a dataset containing at least one column.",
74
+ )
75
+
76
+ column_names = [str(column) for column in X.columns]
77
+
78
+ dtypes = {str(column): str(dtype) for column, dtype in X.dtypes.items()}
79
+
80
+ return CheckResult(
81
+ check_id="data.schema",
82
+ title="Dataset Schema",
83
+ category="data_quality",
84
+ passed=True,
85
+ severity="info",
86
+ score=100.0,
87
+ message="Dataset schema is valid.",
88
+ evidence={
89
+ "rows": rows,
90
+ "columns": columns,
91
+ "column_names": column_names,
92
+ "dtypes": dtypes,
93
+ },
94
+ )
@@ -0,0 +1,155 @@
1
+ from typing import Any
2
+
3
+ from ml_reliability.core.result import CheckResult
4
+
5
+
6
+ def check_slice_analysis(
7
+ model: Any,
8
+ X_test: Any,
9
+ y_test: Any,
10
+ slices: Any,
11
+ task: str,
12
+ ) -> CheckResult:
13
+ """
14
+ Calculate classification accuracy separately for each slice.
15
+
16
+ The current implementation supports classification only.
17
+ """
18
+
19
+ if model is None:
20
+ return CheckResult(
21
+ check_id="model.slice_analysis",
22
+ title="Slice Analysis",
23
+ category="performance",
24
+ passed=False,
25
+ severity="critical",
26
+ score=0.0,
27
+ message="A trained model is required.",
28
+ recommendation="Provide a trained model with a predict() method.",
29
+ )
30
+
31
+ if X_test is None or y_test is None or slices is None:
32
+ return CheckResult(
33
+ check_id="model.slice_analysis",
34
+ title="Slice Analysis",
35
+ category="performance",
36
+ passed=False,
37
+ severity="critical",
38
+ score=0.0,
39
+ message="X_test, y_test, and slices are required.",
40
+ recommendation="Provide test data, targets, and slice labels.",
41
+ )
42
+
43
+ if task != "classification":
44
+ return CheckResult(
45
+ check_id="model.slice_analysis",
46
+ title="Slice Analysis",
47
+ category="performance",
48
+ passed=False,
49
+ severity="critical",
50
+ score=0.0,
51
+ message="Slice analysis currently supports classification only.",
52
+ recommendation="Use task='classification'.",
53
+ )
54
+
55
+ if not hasattr(model, "predict") or not callable(model.predict):
56
+ return CheckResult(
57
+ check_id="model.slice_analysis",
58
+ title="Slice Analysis",
59
+ category="performance",
60
+ passed=False,
61
+ severity="critical",
62
+ score=0.0,
63
+ message="The model must provide a callable predict() method.",
64
+ recommendation="Provide a trained model implementing predict().",
65
+ )
66
+
67
+ try:
68
+ predictions = list(model.predict(X_test))
69
+ actual = list(y_test)
70
+ slice_labels = list(slices)
71
+
72
+ if not actual:
73
+ raise ValueError("y_test must contain at least one sample.")
74
+
75
+ if len(actual) != len(predictions):
76
+ raise ValueError("Predictions and y_test must have the same length.")
77
+
78
+ if len(actual) != len(slice_labels):
79
+ raise ValueError("Slices and y_test must have the same length.")
80
+
81
+ grouped_results = {}
82
+
83
+ for prediction, target, slice_label in zip(
84
+ predictions,
85
+ actual,
86
+ slice_labels,
87
+ ):
88
+ if slice_label not in grouped_results:
89
+ grouped_results[slice_label] = {
90
+ "correct": 0,
91
+ "total": 0,
92
+ }
93
+
94
+ grouped_results[slice_label]["total"] += 1
95
+
96
+ if prediction == target:
97
+ grouped_results[slice_label]["correct"] += 1
98
+
99
+ slice_metrics = {}
100
+
101
+ for slice_label, values in grouped_results.items():
102
+ accuracy = values["correct"] / values["total"]
103
+
104
+ slice_metrics[str(slice_label)] = {
105
+ "accuracy": round(float(accuracy), 4),
106
+ "samples": values["total"],
107
+ }
108
+
109
+ accuracies = [metrics["accuracy"] for metrics in slice_metrics.values()]
110
+
111
+ slice_accuracy_gap = max(accuracies) - min(accuracies)
112
+
113
+ score = round(
114
+ max(0.0, min(100.0, (1.0 - slice_accuracy_gap) * 100.0)),
115
+ 2,
116
+ )
117
+
118
+ return CheckResult(
119
+ check_id="model.slice_analysis",
120
+ title="Slice Analysis",
121
+ category="performance",
122
+ passed=True,
123
+ severity="info",
124
+ score=score,
125
+ message=(
126
+ "Classification accuracy calculated for each slice. "
127
+ f"Accuracy gap: {slice_accuracy_gap:.4f}. "
128
+ f"Slice consistency score: {score:.2f}/100."
129
+ ),
130
+ evidence={
131
+ "task": "classification",
132
+ "metric": "accuracy",
133
+ "slices": slice_metrics,
134
+ "slice_accuracy_gap": round(
135
+ float(slice_accuracy_gap),
136
+ 4,
137
+ ),
138
+ "test_samples": len(actual),
139
+ },
140
+ recommendation="",
141
+ )
142
+
143
+ except Exception as exc: # noqa: BLE001 - Convert evaluation failures to check results.
144
+ return CheckResult(
145
+ check_id="model.slice_analysis",
146
+ title="Slice Analysis",
147
+ category="performance",
148
+ passed=False,
149
+ severity="critical",
150
+ score=0.0,
151
+ message=f"Slice analysis failed: {exc}",
152
+ recommendation=(
153
+ "Verify that predictions, targets, and slice labels are valid."
154
+ ),
155
+ )
@@ -0,0 +1,41 @@
1
+ from .base import (
2
+ StressTestOutcome,
3
+ calculate_degradation,
4
+ validate_rate,
5
+ )
6
+ from .categorical import (
7
+ evaluate_categorical_corruption_stress,
8
+ inject_categorical_corruption,
9
+ )
10
+ from .distribution import (
11
+ evaluate_distribution_perturbation_stress,
12
+ perturb_feature_distribution,
13
+ )
14
+ from .feature_dropout import (
15
+ apply_feature_dropout,
16
+ evaluate_feature_dropout_stress,
17
+ )
18
+ from .missing_values import (
19
+ evaluate_missing_value_stress,
20
+ inject_missing_values,
21
+ )
22
+ from .noise import (
23
+ evaluate_noise_stress,
24
+ inject_feature_noise,
25
+ )
26
+
27
+ __all__ = [
28
+ "StressTestOutcome",
29
+ "apply_feature_dropout",
30
+ "calculate_degradation",
31
+ "evaluate_categorical_corruption_stress",
32
+ "evaluate_distribution_perturbation_stress",
33
+ "evaluate_feature_dropout_stress",
34
+ "evaluate_missing_value_stress",
35
+ "evaluate_noise_stress",
36
+ "inject_categorical_corruption",
37
+ "inject_feature_noise",
38
+ "inject_missing_values",
39
+ "perturb_feature_distribution",
40
+ "validate_rate",
41
+ ]
@@ -0,0 +1,74 @@
1
+ import math
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+
7
+
8
+ def _is_finite_number(value: Any) -> bool:
9
+ """Return whether a supported numeric value is finite."""
10
+ try:
11
+ return math.isfinite(value)
12
+ except OverflowError:
13
+ return False
14
+
15
+
16
+ @dataclass
17
+ class StressTestOutcome:
18
+ """Standard result produced by a stress test.
19
+
20
+ The ``passed`` field indicates whether the stress evaluation
21
+ completed successfully. It does not indicate that the model
22
+ was robust to the applied stress.
23
+
24
+ Metric validation is handled by the calculation and reporting
25
+ layers so failed evaluations can preserve diagnostic results.
26
+ """
27
+
28
+ stress_type: str
29
+ baseline_metric: float
30
+ stressed_metric: float
31
+ degradation: float
32
+ passed: bool
33
+ evidence: dict[str, Any] = field(default_factory=dict)
34
+
35
+
36
+ def calculate_degradation(
37
+ baseline_metric: float,
38
+ stressed_metric: float,
39
+ ) -> float:
40
+ """Calculate degradation from two finite numeric metrics."""
41
+
42
+ metrics = (baseline_metric, stressed_metric)
43
+
44
+ if any(
45
+ isinstance(metric, (bool, np.bool_))
46
+ or not isinstance(
47
+ metric,
48
+ (int, float, np.integer, np.floating),
49
+ )
50
+ or not _is_finite_number(metric)
51
+ for metric in metrics
52
+ ):
53
+ raise ValueError("baseline_metric and stressed_metric must be finite numbers")
54
+
55
+ degradation = float(baseline_metric) - float(stressed_metric)
56
+
57
+ if not math.isfinite(degradation):
58
+ raise ValueError("degradation must be finite")
59
+
60
+ return round(degradation, 10)
61
+
62
+
63
+ def validate_rate(rate: float) -> float:
64
+ """Validate that a stress rate is finite and between 0 and 1."""
65
+
66
+ if (
67
+ isinstance(rate, (bool, np.bool_))
68
+ or not isinstance(rate, (int, float, np.integer, np.floating))
69
+ or not _is_finite_number(rate)
70
+ or not 0.0 <= rate <= 1.0
71
+ ):
72
+ raise ValueError("rate must be between 0.0 and 1.0")
73
+
74
+ return rate