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.
- ml_reliability/__init__.py +3 -0
- ml_reliability/checks/__init__.py +0 -0
- ml_reliability/checks/calibration.py +152 -0
- ml_reliability/checks/cardinality.py +155 -0
- ml_reliability/checks/classification_metrics.py +151 -0
- ml_reliability/checks/distribution_shift.py +172 -0
- ml_reliability/checks/duplicates.py +104 -0
- ml_reliability/checks/generalization_gap.py +217 -0
- ml_reliability/checks/missing_values.py +103 -0
- ml_reliability/checks/model_performance.py +187 -0
- ml_reliability/checks/regression_metrics.py +202 -0
- ml_reliability/checks/schema.py +94 -0
- ml_reliability/checks/slice_analysis.py +155 -0
- ml_reliability/checks/stress/__init__.py +41 -0
- ml_reliability/checks/stress/base.py +74 -0
- ml_reliability/checks/stress/categorical.py +181 -0
- ml_reliability/checks/stress/distribution.py +154 -0
- ml_reliability/checks/stress/feature_dropout.py +122 -0
- ml_reliability/checks/stress/missing_values.py +142 -0
- ml_reliability/checks/stress/noise.py +161 -0
- ml_reliability/checks/target_distribution.py +189 -0
- ml_reliability/core/__init__.py +0 -0
- ml_reliability/core/result.py +15 -0
- ml_reliability/py.typed +0 -0
- ml_reliability/reporting.py +642 -0
- ml_reliability/suite.py +611 -0
- ml_reliability_toolkit-0.1.0.dist-info/METADATA +359 -0
- ml_reliability_toolkit-0.1.0.dist-info/RECORD +30 -0
- ml_reliability_toolkit-0.1.0.dist-info/WHEEL +4 -0
- ml_reliability_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ml_reliability.checks.model_performance import check_model_performance
|
|
7
|
+
|
|
8
|
+
from .base import (
|
|
9
|
+
StressTestOutcome,
|
|
10
|
+
calculate_degradation,
|
|
11
|
+
validate_rate,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inject_feature_noise(
|
|
16
|
+
X: pd.DataFrame,
|
|
17
|
+
noise_level: float,
|
|
18
|
+
random_state: int = 42,
|
|
19
|
+
) -> pd.DataFrame:
|
|
20
|
+
"""Return a copy of X with controlled Gaussian noise added to numeric features."""
|
|
21
|
+
|
|
22
|
+
validate_rate(noise_level)
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
isinstance(random_state, (bool, np.bool_))
|
|
26
|
+
or not isinstance(random_state, (int, np.integer))
|
|
27
|
+
or random_state < 0
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("random_state must be a non-negative integer")
|
|
30
|
+
|
|
31
|
+
stressed_X = X.copy()
|
|
32
|
+
|
|
33
|
+
if stressed_X.columns.has_duplicates:
|
|
34
|
+
raise ValueError("duplicate column names are ambiguous for feature noise")
|
|
35
|
+
|
|
36
|
+
numeric_columns = stressed_X.select_dtypes(include=np.number).columns
|
|
37
|
+
|
|
38
|
+
if len(numeric_columns) > 0:
|
|
39
|
+
numeric_values = stressed_X[numeric_columns].to_numpy(dtype=float)
|
|
40
|
+
|
|
41
|
+
invalid_values = np.isinf(numeric_values)
|
|
42
|
+
|
|
43
|
+
if invalid_values.any():
|
|
44
|
+
raise ValueError("feature noise produced non-finite values")
|
|
45
|
+
|
|
46
|
+
if stressed_X.empty or noise_level == 0.0:
|
|
47
|
+
return stressed_X
|
|
48
|
+
|
|
49
|
+
numeric_columns = stressed_X.select_dtypes(include=np.number).columns
|
|
50
|
+
|
|
51
|
+
if len(numeric_columns) == 0:
|
|
52
|
+
return stressed_X
|
|
53
|
+
|
|
54
|
+
rng = np.random.default_rng(random_state)
|
|
55
|
+
|
|
56
|
+
for column in numeric_columns:
|
|
57
|
+
if stressed_X[column].isna().all():
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
61
|
+
feature_std = stressed_X[column].std()
|
|
62
|
+
|
|
63
|
+
if not np.isfinite(feature_std):
|
|
64
|
+
raise ValueError("feature noise produced non-finite values")
|
|
65
|
+
|
|
66
|
+
noise_scale = feature_std * noise_level
|
|
67
|
+
|
|
68
|
+
if not np.isfinite(noise_scale):
|
|
69
|
+
raise ValueError("feature noise produced non-finite values")
|
|
70
|
+
|
|
71
|
+
noise = rng.normal(
|
|
72
|
+
loc=0.0,
|
|
73
|
+
scale=noise_scale,
|
|
74
|
+
size=len(stressed_X),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
stressed_X[column] = stressed_X[column] + noise
|
|
78
|
+
|
|
79
|
+
stressed_values = stressed_X[column].to_numpy(dtype=float)
|
|
80
|
+
|
|
81
|
+
if np.isinf(stressed_values).any():
|
|
82
|
+
raise ValueError("feature noise produced non-finite values")
|
|
83
|
+
|
|
84
|
+
return stressed_X
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def evaluate_noise_stress(
|
|
88
|
+
model: Any,
|
|
89
|
+
X_test: pd.DataFrame,
|
|
90
|
+
y_test: Any,
|
|
91
|
+
task: str,
|
|
92
|
+
noise_level: float,
|
|
93
|
+
random_state: int = 42,
|
|
94
|
+
) -> StressTestOutcome:
|
|
95
|
+
"""Evaluate model performance after adding noise to numeric features."""
|
|
96
|
+
|
|
97
|
+
stressed_X = inject_feature_noise(
|
|
98
|
+
X_test,
|
|
99
|
+
noise_level=noise_level,
|
|
100
|
+
random_state=random_state,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
baseline_result = check_model_performance(
|
|
104
|
+
model=model,
|
|
105
|
+
X_test=X_test,
|
|
106
|
+
y_test=y_test,
|
|
107
|
+
task=task,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not baseline_result.passed:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"Baseline model performance could not be calculated: "
|
|
113
|
+
f"{baseline_result.message}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
stressed_result = check_model_performance(
|
|
117
|
+
model=model,
|
|
118
|
+
X_test=stressed_X,
|
|
119
|
+
y_test=y_test,
|
|
120
|
+
task=task,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
baseline_metric = float(baseline_result.evidence["metric_value"])
|
|
124
|
+
|
|
125
|
+
if not stressed_result.passed:
|
|
126
|
+
return StressTestOutcome(
|
|
127
|
+
stress_type="feature_noise",
|
|
128
|
+
baseline_metric=baseline_metric,
|
|
129
|
+
stressed_metric=float("nan"),
|
|
130
|
+
degradation=float("inf"),
|
|
131
|
+
passed=False,
|
|
132
|
+
evidence={
|
|
133
|
+
"noise_level": noise_level,
|
|
134
|
+
"random_state": random_state,
|
|
135
|
+
"baseline_metric": baseline_metric,
|
|
136
|
+
"stress_prediction_failed": True,
|
|
137
|
+
"error": stressed_result.message,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
stressed_metric = float(stressed_result.evidence["metric_value"])
|
|
142
|
+
|
|
143
|
+
degradation = calculate_degradation(
|
|
144
|
+
baseline_metric,
|
|
145
|
+
stressed_metric,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return StressTestOutcome(
|
|
149
|
+
stress_type="feature_noise",
|
|
150
|
+
baseline_metric=baseline_metric,
|
|
151
|
+
stressed_metric=stressed_metric,
|
|
152
|
+
degradation=degradation,
|
|
153
|
+
passed=True,
|
|
154
|
+
evidence={
|
|
155
|
+
"noise_level": noise_level,
|
|
156
|
+
"random_state": random_state,
|
|
157
|
+
"baseline_metric": baseline_metric,
|
|
158
|
+
"stressed_metric": stressed_metric,
|
|
159
|
+
"degradation": degradation,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from ml_reliability.core.result import CheckResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _infer_task_type(y: Any) -> str:
|
|
9
|
+
"""Infer whether the target is classification or regression."""
|
|
10
|
+
|
|
11
|
+
series = pd.Series(y)
|
|
12
|
+
|
|
13
|
+
if not pd.api.types.is_numeric_dtype(series):
|
|
14
|
+
return "classification"
|
|
15
|
+
|
|
16
|
+
unique_values = series.nunique(dropna=True)
|
|
17
|
+
|
|
18
|
+
if unique_values <= 10:
|
|
19
|
+
return "classification"
|
|
20
|
+
|
|
21
|
+
return "regression"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_target_distribution(
|
|
25
|
+
y: Any,
|
|
26
|
+
task_type: str | None = None,
|
|
27
|
+
) -> CheckResult:
|
|
28
|
+
"""
|
|
29
|
+
Check the distribution of a machine-learning target.
|
|
30
|
+
|
|
31
|
+
Supports classification and regression targets.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
if y is None:
|
|
35
|
+
return CheckResult(
|
|
36
|
+
check_id="target.distribution",
|
|
37
|
+
title="Target Distribution",
|
|
38
|
+
category="target_quality",
|
|
39
|
+
passed=False,
|
|
40
|
+
severity="critical",
|
|
41
|
+
score=0.0,
|
|
42
|
+
message="Target data is missing.",
|
|
43
|
+
recommendation="Provide y_train before running target distribution checks.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
series = pd.Series(y)
|
|
48
|
+
except Exception: # noqa: BLE001 - Handle invalid target conversion.
|
|
49
|
+
return CheckResult(
|
|
50
|
+
check_id="target.distribution",
|
|
51
|
+
title="Target Distribution",
|
|
52
|
+
category="target_quality",
|
|
53
|
+
passed=False,
|
|
54
|
+
severity="critical",
|
|
55
|
+
score=0.0,
|
|
56
|
+
message="Target could not be converted into a valid series.",
|
|
57
|
+
recommendation="Provide a valid one-dimensional target.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if series.empty:
|
|
61
|
+
return CheckResult(
|
|
62
|
+
check_id="target.distribution",
|
|
63
|
+
title="Target Distribution",
|
|
64
|
+
category="target_quality",
|
|
65
|
+
passed=False,
|
|
66
|
+
severity="critical",
|
|
67
|
+
score=0.0,
|
|
68
|
+
message="Target contains zero samples.",
|
|
69
|
+
evidence={"total_samples": 0},
|
|
70
|
+
recommendation="Provide a target containing at least one sample.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if series.isna().any():
|
|
74
|
+
missing_count = int(series.isna().sum())
|
|
75
|
+
|
|
76
|
+
return CheckResult(
|
|
77
|
+
check_id="target.distribution",
|
|
78
|
+
title="Target Distribution",
|
|
79
|
+
category="target_quality",
|
|
80
|
+
passed=False,
|
|
81
|
+
severity="critical",
|
|
82
|
+
score=0.0,
|
|
83
|
+
message=f"Target contains {missing_count} missing values.",
|
|
84
|
+
evidence={
|
|
85
|
+
"total_samples": len(series),
|
|
86
|
+
"missing_values": missing_count,
|
|
87
|
+
},
|
|
88
|
+
recommendation="Remove or appropriately handle missing target values.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if task_type is None:
|
|
92
|
+
task_type = _infer_task_type(series)
|
|
93
|
+
|
|
94
|
+
if task_type not in {"classification", "regression"}:
|
|
95
|
+
return CheckResult(
|
|
96
|
+
check_id="target.distribution",
|
|
97
|
+
title="Target Distribution",
|
|
98
|
+
category="target_quality",
|
|
99
|
+
passed=False,
|
|
100
|
+
severity="critical",
|
|
101
|
+
score=0.0,
|
|
102
|
+
message=f"Unsupported task type: {task_type}.",
|
|
103
|
+
recommendation="Use 'classification' or 'regression'.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if task_type == "classification":
|
|
107
|
+
counts = series.value_counts()
|
|
108
|
+
percentages = (counts / len(series)) * 100
|
|
109
|
+
|
|
110
|
+
class_counts = {str(label): int(count) for label, count in counts.items()}
|
|
111
|
+
|
|
112
|
+
class_percentages = {
|
|
113
|
+
str(label): round(float(percentage), 2)
|
|
114
|
+
for label, percentage in percentages.items()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
minority_percentage = float(percentages.min())
|
|
118
|
+
|
|
119
|
+
if minority_percentage >= 20:
|
|
120
|
+
severity = "info"
|
|
121
|
+
score = 100.0
|
|
122
|
+
passed = True
|
|
123
|
+
elif minority_percentage >= 5:
|
|
124
|
+
severity = "warning"
|
|
125
|
+
score = 70.0
|
|
126
|
+
passed = True
|
|
127
|
+
else:
|
|
128
|
+
severity = "critical"
|
|
129
|
+
score = 30.0
|
|
130
|
+
passed = False
|
|
131
|
+
|
|
132
|
+
return CheckResult(
|
|
133
|
+
check_id="target.distribution",
|
|
134
|
+
title="Target Distribution",
|
|
135
|
+
category="target_quality",
|
|
136
|
+
passed=passed,
|
|
137
|
+
severity=severity,
|
|
138
|
+
score=score,
|
|
139
|
+
message=(
|
|
140
|
+
f"Classification target contains {len(counts)} classes. "
|
|
141
|
+
f"Minority class represents {minority_percentage:.2f}% of samples."
|
|
142
|
+
),
|
|
143
|
+
evidence={
|
|
144
|
+
"task_type": "classification",
|
|
145
|
+
"total_samples": len(series),
|
|
146
|
+
"class_counts": class_counts,
|
|
147
|
+
"class_percentages": class_percentages,
|
|
148
|
+
"minority_percentage": round(minority_percentage, 2),
|
|
149
|
+
},
|
|
150
|
+
recommendation=(
|
|
151
|
+
"Consider class-balancing techniques or appropriate evaluation "
|
|
152
|
+
"metrics if the target distribution is highly imbalanced."
|
|
153
|
+
if not passed
|
|
154
|
+
else ""
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Regression
|
|
159
|
+
numeric_series = pd.to_numeric(series, errors="coerce")
|
|
160
|
+
|
|
161
|
+
if numeric_series.isna().any():
|
|
162
|
+
return CheckResult(
|
|
163
|
+
check_id="target.distribution",
|
|
164
|
+
title="Target Distribution",
|
|
165
|
+
category="target_quality",
|
|
166
|
+
passed=False,
|
|
167
|
+
severity="critical",
|
|
168
|
+
score=0.0,
|
|
169
|
+
message="Regression target contains non-numeric values.",
|
|
170
|
+
recommendation="Provide a numeric target for regression.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return CheckResult(
|
|
174
|
+
check_id="target.distribution",
|
|
175
|
+
title="Target Distribution",
|
|
176
|
+
category="target_quality",
|
|
177
|
+
passed=True,
|
|
178
|
+
severity="info",
|
|
179
|
+
score=100.0,
|
|
180
|
+
message="Regression target distribution was successfully analyzed.",
|
|
181
|
+
evidence={
|
|
182
|
+
"task_type": "regression",
|
|
183
|
+
"total_samples": len(numeric_series),
|
|
184
|
+
"mean": float(numeric_series.mean()),
|
|
185
|
+
"std": float(numeric_series.std()),
|
|
186
|
+
"min": float(numeric_series.min()),
|
|
187
|
+
"max": float(numeric_series.max()),
|
|
188
|
+
},
|
|
189
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class CheckResult:
|
|
7
|
+
check_id: str
|
|
8
|
+
title: str
|
|
9
|
+
category: str
|
|
10
|
+
passed: bool
|
|
11
|
+
severity: str
|
|
12
|
+
score: float | None
|
|
13
|
+
message: str
|
|
14
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
recommendation: str = ""
|
ml_reliability/py.typed
ADDED
|
File without changes
|