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,181 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ml_reliability.checks.model_performance import check_model_performance
|
|
8
|
+
|
|
9
|
+
from .base import (
|
|
10
|
+
StressTestOutcome,
|
|
11
|
+
calculate_degradation,
|
|
12
|
+
validate_rate,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def inject_categorical_corruption(
|
|
17
|
+
X: pd.DataFrame,
|
|
18
|
+
columns: Sequence[str],
|
|
19
|
+
rate: float,
|
|
20
|
+
random_state: int = 42,
|
|
21
|
+
) -> pd.DataFrame:
|
|
22
|
+
"""Return a copy of X with categorical values replaced by other valid values."""
|
|
23
|
+
|
|
24
|
+
if isinstance(columns, (str, bytes)) or not isinstance(columns, Sequence):
|
|
25
|
+
raise TypeError("columns must be a sequence of column names")
|
|
26
|
+
|
|
27
|
+
if any(not isinstance(column, str) for column in columns):
|
|
28
|
+
raise ValueError("columns must contain only column names")
|
|
29
|
+
|
|
30
|
+
validate_rate(rate)
|
|
31
|
+
if (
|
|
32
|
+
isinstance(random_state, (bool, np.bool_))
|
|
33
|
+
or not isinstance(random_state, (int, np.integer))
|
|
34
|
+
or random_state < 0
|
|
35
|
+
):
|
|
36
|
+
raise ValueError("random_state must be a non-negative integer")
|
|
37
|
+
|
|
38
|
+
if len(columns) != len(set(columns)):
|
|
39
|
+
raise ValueError("duplicate requested column names are not allowed")
|
|
40
|
+
|
|
41
|
+
stressed_X = X.copy()
|
|
42
|
+
|
|
43
|
+
if stressed_X.columns.has_duplicates:
|
|
44
|
+
duplicated_columns = (
|
|
45
|
+
stressed_X.columns[stressed_X.columns.duplicated()].unique().tolist()
|
|
46
|
+
)
|
|
47
|
+
requested_duplicates = [
|
|
48
|
+
column for column in columns if column in duplicated_columns
|
|
49
|
+
]
|
|
50
|
+
if requested_duplicates:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"duplicate column names are ambiguous: {requested_duplicates}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
missing_columns = [column for column in columns if column not in stressed_X.columns]
|
|
56
|
+
|
|
57
|
+
if missing_columns:
|
|
58
|
+
raise ValueError(f"Columns not found in X: {missing_columns}")
|
|
59
|
+
|
|
60
|
+
if not columns or stressed_X.empty or rate == 0.0:
|
|
61
|
+
return stressed_X
|
|
62
|
+
|
|
63
|
+
rng = np.random.default_rng(random_state)
|
|
64
|
+
|
|
65
|
+
for column in columns:
|
|
66
|
+
unique_values = stressed_X[column].dropna().unique()
|
|
67
|
+
|
|
68
|
+
if len(unique_values) < 2:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
valid_positions = np.flatnonzero(stressed_X[column].notna().to_numpy())
|
|
72
|
+
|
|
73
|
+
values_to_corrupt = int(len(valid_positions) * rate)
|
|
74
|
+
|
|
75
|
+
if rate > 0.0 and len(valid_positions) > 0:
|
|
76
|
+
values_to_corrupt = max(1, values_to_corrupt)
|
|
77
|
+
|
|
78
|
+
if values_to_corrupt == 0:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
selected_positions = rng.choice(
|
|
82
|
+
valid_positions,
|
|
83
|
+
size=values_to_corrupt,
|
|
84
|
+
replace=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for position in selected_positions:
|
|
88
|
+
current_value = stressed_X[column].iloc[position]
|
|
89
|
+
|
|
90
|
+
alternatives = [value for value in unique_values if value != current_value]
|
|
91
|
+
|
|
92
|
+
if not alternatives:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
alternative_index = rng.integers(len(alternatives))
|
|
96
|
+
stressed_X.iat[position, stressed_X.columns.get_loc(column)] = alternatives[
|
|
97
|
+
alternative_index
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
return stressed_X
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def evaluate_categorical_corruption_stress(
|
|
104
|
+
model: Any,
|
|
105
|
+
X_test: pd.DataFrame,
|
|
106
|
+
y_test: Any,
|
|
107
|
+
task: str,
|
|
108
|
+
columns: Sequence[str],
|
|
109
|
+
rate: float,
|
|
110
|
+
random_state: int = 42,
|
|
111
|
+
) -> StressTestOutcome:
|
|
112
|
+
"""Evaluate model performance after categorical value corruption."""
|
|
113
|
+
|
|
114
|
+
stressed_X = inject_categorical_corruption(
|
|
115
|
+
X_test,
|
|
116
|
+
columns=columns,
|
|
117
|
+
rate=rate,
|
|
118
|
+
random_state=random_state,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
baseline_result = check_model_performance(
|
|
122
|
+
model=model,
|
|
123
|
+
X_test=X_test,
|
|
124
|
+
y_test=y_test,
|
|
125
|
+
task=task,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not baseline_result.passed:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Baseline model performance could not be calculated: "
|
|
131
|
+
f"{baseline_result.message}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
stressed_result = check_model_performance(
|
|
135
|
+
model=model,
|
|
136
|
+
X_test=stressed_X,
|
|
137
|
+
y_test=y_test,
|
|
138
|
+
task=task,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
baseline_metric = float(baseline_result.evidence["metric_value"])
|
|
142
|
+
|
|
143
|
+
if not stressed_result.passed:
|
|
144
|
+
return StressTestOutcome(
|
|
145
|
+
stress_type="categorical_corruption",
|
|
146
|
+
baseline_metric=baseline_metric,
|
|
147
|
+
stressed_metric=float("nan"),
|
|
148
|
+
degradation=float("inf"),
|
|
149
|
+
passed=False,
|
|
150
|
+
evidence={
|
|
151
|
+
"columns": list(columns),
|
|
152
|
+
"rate": rate,
|
|
153
|
+
"random_state": random_state,
|
|
154
|
+
"baseline_metric": baseline_metric,
|
|
155
|
+
"stress_prediction_failed": True,
|
|
156
|
+
"error": stressed_result.message,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
stressed_metric = float(stressed_result.evidence["metric_value"])
|
|
161
|
+
|
|
162
|
+
degradation = calculate_degradation(
|
|
163
|
+
baseline_metric,
|
|
164
|
+
stressed_metric,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return StressTestOutcome(
|
|
168
|
+
stress_type="categorical_corruption",
|
|
169
|
+
baseline_metric=baseline_metric,
|
|
170
|
+
stressed_metric=stressed_metric,
|
|
171
|
+
degradation=degradation,
|
|
172
|
+
passed=True,
|
|
173
|
+
evidence={
|
|
174
|
+
"columns": list(columns),
|
|
175
|
+
"rate": rate,
|
|
176
|
+
"random_state": random_state,
|
|
177
|
+
"baseline_metric": baseline_metric,
|
|
178
|
+
"stressed_metric": stressed_metric,
|
|
179
|
+
"degradation": degradation,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ml_reliability.checks.model_performance import check_model_performance
|
|
8
|
+
|
|
9
|
+
from .base import StressTestOutcome, calculate_degradation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def perturb_feature_distribution(
|
|
13
|
+
X: pd.DataFrame,
|
|
14
|
+
shift: float = 0.0,
|
|
15
|
+
scale: float = 1.0,
|
|
16
|
+
random_state: int = 42,
|
|
17
|
+
) -> pd.DataFrame:
|
|
18
|
+
"""Return a copy of X with controlled numeric distribution perturbation."""
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
isinstance(random_state, (bool, np.bool_))
|
|
22
|
+
or not isinstance(random_state, (int, np.integer))
|
|
23
|
+
or random_state < 0
|
|
24
|
+
):
|
|
25
|
+
raise ValueError("random_state must be a non-negative integer")
|
|
26
|
+
|
|
27
|
+
for name, value in (("shift", shift), ("scale", scale)):
|
|
28
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(
|
|
29
|
+
value, (int, float, np.integer, np.floating)
|
|
30
|
+
):
|
|
31
|
+
raise TypeError(f"{name} must be a numeric value")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
is_finite = math.isfinite(value)
|
|
35
|
+
except OverflowError:
|
|
36
|
+
is_finite = False
|
|
37
|
+
|
|
38
|
+
if not is_finite:
|
|
39
|
+
if name == "scale":
|
|
40
|
+
raise ValueError("scale must be a finite number greater than 0")
|
|
41
|
+
raise ValueError("shift must be a finite number")
|
|
42
|
+
|
|
43
|
+
if scale <= 0:
|
|
44
|
+
raise ValueError("scale must be a finite number greater than 0")
|
|
45
|
+
stressed_X = X.copy()
|
|
46
|
+
|
|
47
|
+
if stressed_X.empty:
|
|
48
|
+
return stressed_X
|
|
49
|
+
|
|
50
|
+
numeric_columns = stressed_X.select_dtypes(include=np.number).columns
|
|
51
|
+
|
|
52
|
+
if len(numeric_columns) == 0:
|
|
53
|
+
return stressed_X
|
|
54
|
+
|
|
55
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
56
|
+
for column in numeric_columns:
|
|
57
|
+
stressed_X[column] = (stressed_X[column] * scale) + shift
|
|
58
|
+
|
|
59
|
+
original_values = X[numeric_columns].to_numpy(dtype=float)
|
|
60
|
+
stressed_values = stressed_X[numeric_columns].to_numpy(dtype=float)
|
|
61
|
+
|
|
62
|
+
original_missing = np.isnan(original_values)
|
|
63
|
+
stressed_missing = np.isnan(stressed_values)
|
|
64
|
+
|
|
65
|
+
new_non_finite = ~np.isfinite(stressed_values) & ~original_missing
|
|
66
|
+
|
|
67
|
+
if new_non_finite.any():
|
|
68
|
+
raise ValueError("distribution perturbation produced non-finite values")
|
|
69
|
+
|
|
70
|
+
if np.any(stressed_missing & ~original_missing):
|
|
71
|
+
raise ValueError("distribution perturbation produced non-finite values")
|
|
72
|
+
|
|
73
|
+
return stressed_X
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def evaluate_distribution_perturbation_stress(
|
|
77
|
+
model: Any,
|
|
78
|
+
X_test: pd.DataFrame,
|
|
79
|
+
y_test: Any,
|
|
80
|
+
task: str,
|
|
81
|
+
shift: float = 0.0,
|
|
82
|
+
scale: float = 1.0,
|
|
83
|
+
random_state: int = 42,
|
|
84
|
+
) -> StressTestOutcome:
|
|
85
|
+
"""Evaluate model performance after distribution perturbation."""
|
|
86
|
+
|
|
87
|
+
stressed_X = perturb_feature_distribution(
|
|
88
|
+
X_test,
|
|
89
|
+
shift=shift,
|
|
90
|
+
scale=scale,
|
|
91
|
+
random_state=random_state,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
baseline_result = check_model_performance(
|
|
95
|
+
model=model,
|
|
96
|
+
X_test=X_test,
|
|
97
|
+
y_test=y_test,
|
|
98
|
+
task=task,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if not baseline_result.passed:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Baseline model performance could not be calculated: "
|
|
104
|
+
f"{baseline_result.message}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
stressed_result = check_model_performance(
|
|
108
|
+
model=model,
|
|
109
|
+
X_test=stressed_X,
|
|
110
|
+
y_test=y_test,
|
|
111
|
+
task=task,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
baseline_metric = float(baseline_result.evidence["metric_value"])
|
|
115
|
+
|
|
116
|
+
if not stressed_result.passed:
|
|
117
|
+
return StressTestOutcome(
|
|
118
|
+
stress_type="distribution_perturbation",
|
|
119
|
+
baseline_metric=baseline_metric,
|
|
120
|
+
stressed_metric=float("nan"),
|
|
121
|
+
degradation=float("inf"),
|
|
122
|
+
passed=False,
|
|
123
|
+
evidence={
|
|
124
|
+
"shift": shift,
|
|
125
|
+
"scale": scale,
|
|
126
|
+
"random_state": random_state,
|
|
127
|
+
"baseline_metric": baseline_metric,
|
|
128
|
+
"stress_prediction_failed": True,
|
|
129
|
+
"error": stressed_result.message,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
stressed_metric = float(stressed_result.evidence["metric_value"])
|
|
134
|
+
|
|
135
|
+
degradation = calculate_degradation(
|
|
136
|
+
baseline_metric,
|
|
137
|
+
stressed_metric,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return StressTestOutcome(
|
|
141
|
+
stress_type="distribution_perturbation",
|
|
142
|
+
baseline_metric=baseline_metric,
|
|
143
|
+
stressed_metric=stressed_metric,
|
|
144
|
+
degradation=degradation,
|
|
145
|
+
passed=True,
|
|
146
|
+
evidence={
|
|
147
|
+
"shift": shift,
|
|
148
|
+
"scale": scale,
|
|
149
|
+
"random_state": random_state,
|
|
150
|
+
"baseline_metric": baseline_metric,
|
|
151
|
+
"stressed_metric": stressed_metric,
|
|
152
|
+
"degradation": degradation,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ml_reliability.checks.model_performance import check_model_performance
|
|
7
|
+
|
|
8
|
+
from .base import StressTestOutcome, calculate_degradation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def apply_feature_dropout(X: pd.DataFrame, features: Sequence[str]) -> pd.DataFrame:
|
|
12
|
+
"""Return a copy of X with selected features masked as missing."""
|
|
13
|
+
if isinstance(features, (str, bytes)) or not isinstance(features, Sequence):
|
|
14
|
+
raise TypeError("features must be a sequence of column names")
|
|
15
|
+
|
|
16
|
+
if any(not isinstance(feature, str) for feature in features):
|
|
17
|
+
raise ValueError("features must contain only column names")
|
|
18
|
+
|
|
19
|
+
stressed_X = X.copy()
|
|
20
|
+
|
|
21
|
+
if stressed_X.columns.has_duplicates:
|
|
22
|
+
duplicated_columns = (
|
|
23
|
+
stressed_X.columns[stressed_X.columns.duplicated()].unique().tolist()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
requested_duplicates = [
|
|
27
|
+
feature for feature in features if feature in duplicated_columns
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if requested_duplicates:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"duplicate column names are ambiguous: {requested_duplicates}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if not features:
|
|
36
|
+
return stressed_X
|
|
37
|
+
|
|
38
|
+
missing_features = [
|
|
39
|
+
feature for feature in features if feature not in stressed_X.columns
|
|
40
|
+
]
|
|
41
|
+
if missing_features:
|
|
42
|
+
raise ValueError(f"Features not found in X: {missing_features}")
|
|
43
|
+
|
|
44
|
+
for feature in features:
|
|
45
|
+
stressed_X[feature] = stressed_X[feature].mask(
|
|
46
|
+
pd.Series(True, index=stressed_X.index)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return stressed_X
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate_feature_dropout_stress(
|
|
53
|
+
model: Any,
|
|
54
|
+
X_test: pd.DataFrame,
|
|
55
|
+
y_test: Any,
|
|
56
|
+
task: str,
|
|
57
|
+
features: Sequence[str],
|
|
58
|
+
) -> StressTestOutcome:
|
|
59
|
+
"""Evaluate model performance after dropping selected features."""
|
|
60
|
+
|
|
61
|
+
stressed_X = apply_feature_dropout(
|
|
62
|
+
X_test,
|
|
63
|
+
features=features,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
baseline_result = check_model_performance(
|
|
67
|
+
model=model,
|
|
68
|
+
X_test=X_test,
|
|
69
|
+
y_test=y_test,
|
|
70
|
+
task=task,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not baseline_result.passed:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Baseline model performance could not be calculated: "
|
|
76
|
+
f"{baseline_result.message}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
stressed_result = check_model_performance(
|
|
80
|
+
model=model,
|
|
81
|
+
X_test=stressed_X,
|
|
82
|
+
y_test=y_test,
|
|
83
|
+
task=task,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
baseline_metric = float(baseline_result.evidence["metric_value"])
|
|
87
|
+
|
|
88
|
+
if not stressed_result.passed:
|
|
89
|
+
return StressTestOutcome(
|
|
90
|
+
stress_type="feature_dropout",
|
|
91
|
+
baseline_metric=baseline_metric,
|
|
92
|
+
stressed_metric=float("nan"),
|
|
93
|
+
degradation=float("inf"),
|
|
94
|
+
passed=False,
|
|
95
|
+
evidence={
|
|
96
|
+
"features": list(features),
|
|
97
|
+
"baseline_metric": baseline_metric,
|
|
98
|
+
"stress_prediction_failed": True,
|
|
99
|
+
"error": stressed_result.message,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
stressed_metric = float(stressed_result.evidence["metric_value"])
|
|
104
|
+
|
|
105
|
+
degradation = calculate_degradation(
|
|
106
|
+
baseline_metric,
|
|
107
|
+
stressed_metric,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return StressTestOutcome(
|
|
111
|
+
stress_type="feature_dropout",
|
|
112
|
+
baseline_metric=baseline_metric,
|
|
113
|
+
stressed_metric=stressed_metric,
|
|
114
|
+
degradation=degradation,
|
|
115
|
+
passed=True,
|
|
116
|
+
evidence={
|
|
117
|
+
"features": list(features),
|
|
118
|
+
"baseline_metric": baseline_metric,
|
|
119
|
+
"stressed_metric": stressed_metric,
|
|
120
|
+
"degradation": degradation,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
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_missing_values(
|
|
16
|
+
X: pd.DataFrame,
|
|
17
|
+
rate: float,
|
|
18
|
+
random_state: int = 42,
|
|
19
|
+
) -> pd.DataFrame:
|
|
20
|
+
"""Return a copy of X with a controlled proportion of eligible values replaced by NaN."""
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
isinstance(random_state, bool)
|
|
24
|
+
or not isinstance(random_state, (int, np.integer))
|
|
25
|
+
or random_state < 0
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("random_state must be a non-negative integer")
|
|
28
|
+
|
|
29
|
+
validate_rate(rate)
|
|
30
|
+
|
|
31
|
+
stressed_X = X.copy()
|
|
32
|
+
|
|
33
|
+
if X.empty or rate == 0.0:
|
|
34
|
+
return stressed_X
|
|
35
|
+
|
|
36
|
+
# Sample only existing non-missing cells so the requested corruption
|
|
37
|
+
# count represents newly introduced missing values.
|
|
38
|
+
valid_mask = ~stressed_X.isna().to_numpy()
|
|
39
|
+
valid_positions = np.flatnonzero(valid_mask.ravel())
|
|
40
|
+
|
|
41
|
+
values_to_corrupt = min(
|
|
42
|
+
len(valid_positions),
|
|
43
|
+
max(1, int(len(valid_positions) * rate)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if values_to_corrupt == 0:
|
|
47
|
+
return stressed_X
|
|
48
|
+
|
|
49
|
+
random_generator = np.random.default_rng(random_state)
|
|
50
|
+
|
|
51
|
+
selected_positions = random_generator.choice(
|
|
52
|
+
valid_positions,
|
|
53
|
+
size=values_to_corrupt,
|
|
54
|
+
replace=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
rows, columns = np.unravel_index(
|
|
58
|
+
selected_positions,
|
|
59
|
+
stressed_X.shape,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for row, column in zip(rows, columns):
|
|
63
|
+
stressed_X.iat[row, column] = float("nan")
|
|
64
|
+
|
|
65
|
+
return stressed_X
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_missing_value_stress(
|
|
69
|
+
model: Any,
|
|
70
|
+
X_test: pd.DataFrame,
|
|
71
|
+
y_test: Any,
|
|
72
|
+
task: str,
|
|
73
|
+
rate: float,
|
|
74
|
+
random_state: int = 42,
|
|
75
|
+
) -> StressTestOutcome:
|
|
76
|
+
"""Evaluate model performance after injecting missing values."""
|
|
77
|
+
|
|
78
|
+
stressed_X = inject_missing_values(
|
|
79
|
+
X_test,
|
|
80
|
+
rate=rate,
|
|
81
|
+
random_state=random_state,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
baseline_result = check_model_performance(
|
|
85
|
+
model=model,
|
|
86
|
+
X_test=X_test,
|
|
87
|
+
y_test=y_test,
|
|
88
|
+
task=task,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not baseline_result.passed:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Baseline model performance could not be calculated: "
|
|
94
|
+
f"{baseline_result.message}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
stressed_result = check_model_performance(
|
|
98
|
+
model=model,
|
|
99
|
+
X_test=stressed_X,
|
|
100
|
+
y_test=y_test,
|
|
101
|
+
task=task,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
baseline_metric = float(baseline_result.evidence["metric_value"])
|
|
105
|
+
|
|
106
|
+
if not stressed_result.passed:
|
|
107
|
+
return StressTestOutcome(
|
|
108
|
+
stress_type="missing_values",
|
|
109
|
+
baseline_metric=baseline_metric,
|
|
110
|
+
stressed_metric=float("nan"),
|
|
111
|
+
degradation=float("inf"),
|
|
112
|
+
passed=False,
|
|
113
|
+
evidence={
|
|
114
|
+
"rate": rate,
|
|
115
|
+
"random_state": random_state,
|
|
116
|
+
"baseline_metric": baseline_metric,
|
|
117
|
+
"stress_prediction_failed": True,
|
|
118
|
+
"error": stressed_result.message,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
stressed_metric = float(stressed_result.evidence["metric_value"])
|
|
123
|
+
|
|
124
|
+
degradation = calculate_degradation(
|
|
125
|
+
baseline_metric,
|
|
126
|
+
stressed_metric,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return StressTestOutcome(
|
|
130
|
+
stress_type="missing_values",
|
|
131
|
+
baseline_metric=baseline_metric,
|
|
132
|
+
stressed_metric=stressed_metric,
|
|
133
|
+
degradation=degradation,
|
|
134
|
+
passed=True,
|
|
135
|
+
evidence={
|
|
136
|
+
"rate": rate,
|
|
137
|
+
"random_state": random_state,
|
|
138
|
+
"baseline_metric": baseline_metric,
|
|
139
|
+
"stressed_metric": stressed_metric,
|
|
140
|
+
"degradation": degradation,
|
|
141
|
+
},
|
|
142
|
+
)
|