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,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
+ )