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,3 @@
1
+ from ml_reliability.suite import ReliabilityReport, ReliabilitySuite
2
+
3
+ __all__ = ["ReliabilityReport", "ReliabilitySuite"]
File without changes
@@ -0,0 +1,152 @@
1
+ import math
2
+ from typing import Any
3
+
4
+ from ml_reliability.core.result import CheckResult
5
+
6
+
7
+ def check_calibration(
8
+ model: Any,
9
+ X_test: Any,
10
+ y_test: Any,
11
+ ) -> CheckResult:
12
+ """
13
+ Calculate mean absolute probability error for binary classification.
14
+
15
+ The model must provide predict_proba().
16
+ """
17
+
18
+ if model is None:
19
+ return CheckResult(
20
+ check_id="model.calibration",
21
+ title="Classification Calibration",
22
+ category="performance",
23
+ passed=False,
24
+ severity="critical",
25
+ score=0.0,
26
+ message="A trained model is required.",
27
+ recommendation="Provide a trained classification model.",
28
+ )
29
+
30
+ if X_test is None or y_test is None:
31
+ return CheckResult(
32
+ check_id="model.calibration",
33
+ title="Classification Calibration",
34
+ category="performance",
35
+ passed=False,
36
+ severity="critical",
37
+ score=0.0,
38
+ message="X_test and y_test are required.",
39
+ recommendation="Provide both X_test and y_test.",
40
+ )
41
+
42
+ if not hasattr(model, "predict_proba") or not callable(model.predict_proba):
43
+ return CheckResult(
44
+ check_id="model.calibration",
45
+ title="Classification Calibration",
46
+ category="performance",
47
+ passed=False,
48
+ severity="critical",
49
+ score=0.0,
50
+ message="The model must provide a callable predict_proba() method.",
51
+ recommendation=(
52
+ "Provide a classification model implementing predict_proba()."
53
+ ),
54
+ )
55
+
56
+ try:
57
+ probabilities = list(model.predict_proba(X_test))
58
+ actual = list(y_test)
59
+
60
+ if not actual:
61
+ raise ValueError("y_test must contain at least one sample.")
62
+
63
+ if len(actual) != len(probabilities):
64
+ raise ValueError(
65
+ "Predicted probabilities and y_test must have the same length."
66
+ )
67
+
68
+ positive_probabilities = []
69
+
70
+ for probability in probabilities:
71
+ if len(probability) != 2:
72
+ raise ValueError(
73
+ "Binary classification probabilities must contain exactly two class probabilities."
74
+ )
75
+
76
+ try:
77
+ class_probabilities = [
78
+ float(probability[0]),
79
+ float(probability[1]),
80
+ ]
81
+ except (TypeError, ValueError, OverflowError) as exc:
82
+ raise ValueError(
83
+ "Class probabilities must be valid finite numbers."
84
+ ) from exc
85
+
86
+ if not all(math.isfinite(value) for value in class_probabilities):
87
+ raise ValueError("Class probabilities must be finite numbers.")
88
+
89
+ if any(value < 0.0 or value > 1.0 for value in class_probabilities):
90
+ raise ValueError("Class probabilities must be between 0 and 1.")
91
+
92
+ if not math.isclose(
93
+ sum(class_probabilities),
94
+ 1.0,
95
+ rel_tol=0.0,
96
+ abs_tol=1e-6,
97
+ ):
98
+ raise ValueError("Binary class probabilities must sum to 1.")
99
+
100
+ positive_probabilities.append(class_probabilities[1])
101
+
102
+ absolute_errors = [
103
+ abs(probability - target)
104
+ for probability, target in zip(
105
+ positive_probabilities,
106
+ actual,
107
+ )
108
+ ]
109
+
110
+ calibration_error = sum(absolute_errors) / len(actual)
111
+
112
+ score = round(
113
+ max(0.0, min(100.0, (1.0 - calibration_error) * 100.0)),
114
+ 2,
115
+ )
116
+
117
+ return CheckResult(
118
+ check_id="model.calibration",
119
+ title="Classification Calibration",
120
+ category="performance",
121
+ passed=True,
122
+ severity="info",
123
+ score=score,
124
+ message=(
125
+ "Classification probability calibration error "
126
+ f"calculated successfully: {calibration_error:.4f}. "
127
+ f"Calibration score: {score:.2f}/100."
128
+ ),
129
+ evidence={
130
+ "metric": "mean_absolute_probability_error",
131
+ "calibration_error": round(
132
+ float(calibration_error),
133
+ 4,
134
+ ),
135
+ "test_samples": len(actual),
136
+ },
137
+ recommendation="",
138
+ )
139
+
140
+ except Exception as exc: # noqa: BLE001 - Convert evaluation failures to check results.
141
+ return CheckResult(
142
+ check_id="model.calibration",
143
+ title="Classification Calibration",
144
+ category="performance",
145
+ passed=False,
146
+ severity="critical",
147
+ score=0.0,
148
+ message=f"Calibration calculation failed: {exc}",
149
+ recommendation=(
150
+ "Verify that predicted probabilities and test targets are valid."
151
+ ),
152
+ )
@@ -0,0 +1,155 @@
1
+ from typing import Any
2
+
3
+ import pandas as pd
4
+
5
+ from ml_reliability.core.result import CheckResult
6
+
7
+
8
+ def check_cardinality(X: Any) -> CheckResult:
9
+ """
10
+ Detect high-cardinality and potentially identifier-like columns.
11
+ """
12
+
13
+ if X is None:
14
+ return CheckResult(
15
+ check_id="data.cardinality",
16
+ title="Cardinality / ID Detection",
17
+ category="data_quality",
18
+ passed=False,
19
+ severity="critical",
20
+ score=0.0,
21
+ message="Input data is missing.",
22
+ recommendation="Provide X_train before running cardinality checks.",
23
+ )
24
+
25
+ if not isinstance(X, pd.DataFrame):
26
+ return CheckResult(
27
+ check_id="data.cardinality",
28
+ title="Cardinality / ID Detection",
29
+ category="data_quality",
30
+ passed=False,
31
+ severity="critical",
32
+ score=0.0,
33
+ message="Input must be a pandas DataFrame.",
34
+ recommendation="Provide tabular data as a pandas DataFrame.",
35
+ )
36
+
37
+ if X.empty:
38
+ return CheckResult(
39
+ check_id="data.cardinality",
40
+ title="Cardinality / ID Detection",
41
+ category="data_quality",
42
+ passed=False,
43
+ severity="critical",
44
+ score=0.0,
45
+ message="Input DataFrame contains zero rows.",
46
+ evidence={
47
+ "rows": 0,
48
+ "columns": int(X.shape[1]),
49
+ },
50
+ recommendation="Provide a DataFrame containing at least one row.",
51
+ )
52
+
53
+ if X.shape[1] == 0:
54
+ return CheckResult(
55
+ check_id="data.cardinality",
56
+ title="Cardinality / ID Detection",
57
+ category="data_quality",
58
+ passed=False,
59
+ severity="critical",
60
+ score=0.0,
61
+ message="Input DataFrame contains zero columns.",
62
+ evidence={
63
+ "rows": int(X.shape[0]),
64
+ "columns": 0,
65
+ },
66
+ recommendation="Provide a DataFrame containing at least one feature column.",
67
+ )
68
+
69
+ column_analysis = {}
70
+ high_cardinality_columns = []
71
+ identifier_like_columns = []
72
+ # Cardinality signals are unreliable on very small datasets.
73
+ # We still report the evidence, but avoid penalizing the dataset.
74
+ small_dataset = len(X) < 10
75
+
76
+ for column in X.columns:
77
+ total_values = int(X[column].notna().sum())
78
+ unique_values = int(X[column].nunique(dropna=True))
79
+
80
+ if total_values == 0:
81
+ uniqueness_percentage = 0.0
82
+ else:
83
+ uniqueness_percentage = (unique_values / total_values) * 100
84
+
85
+ uniqueness_percentage = round(uniqueness_percentage, 2)
86
+
87
+ column_analysis[str(column)] = {
88
+ "total_values": total_values,
89
+ "unique_values": unique_values,
90
+ "uniqueness_percentage": uniqueness_percentage,
91
+ }
92
+
93
+ if not small_dataset:
94
+ if uniqueness_percentage >= 80:
95
+ high_cardinality_columns.append(str(column))
96
+
97
+ if uniqueness_percentage > 95:
98
+ identifier_like_columns.append(str(column))
99
+
100
+ if small_dataset:
101
+ severity = "info"
102
+ score = 100.0
103
+ passed = True
104
+ message = (
105
+ "Dataset is too small for reliable cardinality-based identifier detection."
106
+ )
107
+ elif identifier_like_columns:
108
+ severity = "warning"
109
+ score = 50.0
110
+ passed = True
111
+ message = (
112
+ f"Detected {len(identifier_like_columns)} potentially "
113
+ "identifier-like column(s)."
114
+ )
115
+ elif high_cardinality_columns:
116
+ severity = "warning"
117
+ score = 70.0
118
+ passed = True
119
+ message = (
120
+ f"Detected {len(high_cardinality_columns)} high-cardinality column(s)."
121
+ )
122
+ else:
123
+ severity = "info"
124
+ score = 100.0
125
+ passed = True
126
+ message = "No high-cardinality columns were detected."
127
+
128
+ return CheckResult(
129
+ check_id="data.cardinality",
130
+ title="Cardinality / ID Detection",
131
+ category="data_quality",
132
+ passed=passed,
133
+ severity=severity,
134
+ score=score,
135
+ message=message,
136
+ evidence={
137
+ "total_columns": int(X.shape[1]),
138
+ "high_cardinality_columns": high_cardinality_columns,
139
+ "identifier_like_columns": identifier_like_columns,
140
+ "columns": column_analysis,
141
+ "small_dataset": small_dataset,
142
+ "minimum_rows_for_detection": 10,
143
+ },
144
+ recommendation=(
145
+ "Review identifier-like columns and determine whether they should "
146
+ "be excluded from model features."
147
+ if identifier_like_columns
148
+ else (
149
+ "Review high-cardinality columns for potential identifiers "
150
+ "or leakage before model training."
151
+ if high_cardinality_columns
152
+ else ""
153
+ )
154
+ ),
155
+ )
@@ -0,0 +1,151 @@
1
+ from typing import Any
2
+
3
+ import numpy as np
4
+
5
+ from ml_reliability.core.result import CheckResult
6
+
7
+
8
+ def check_classification_metrics(
9
+ model: Any,
10
+ X_test: Any,
11
+ y_test: Any,
12
+ ) -> CheckResult:
13
+ """
14
+ Calculate classification precision and recall.
15
+
16
+ This check currently supports binary classification.
17
+ """
18
+
19
+ if model is None:
20
+ return CheckResult(
21
+ check_id="model.classification_metrics",
22
+ title="Classification Metrics",
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:
32
+ return CheckResult(
33
+ check_id="model.classification_metrics",
34
+ title="Classification Metrics",
35
+ category="performance",
36
+ passed=False,
37
+ severity="critical",
38
+ score=0.0,
39
+ message="X_test and y_test are required.",
40
+ recommendation="Provide both X_test and y_test.",
41
+ )
42
+
43
+ if not hasattr(model, "predict") or not callable(model.predict):
44
+ return CheckResult(
45
+ check_id="model.classification_metrics",
46
+ title="Classification Metrics",
47
+ category="performance",
48
+ passed=False,
49
+ severity="critical",
50
+ score=0.0,
51
+ message="The model must provide a callable predict() method.",
52
+ recommendation="Provide a trained model implementing predict().",
53
+ )
54
+
55
+ try:
56
+ predictions = list(model.predict(X_test))
57
+ actual = list(y_test)
58
+
59
+ valid_labels = {0, 1}
60
+
61
+ for name, values in (
62
+ ("predictions", predictions),
63
+ ("targets", actual),
64
+ ):
65
+ for index, value in enumerate(values):
66
+ if (
67
+ isinstance(value, (bool, np.bool_))
68
+ or not isinstance(
69
+ value,
70
+ (int, float, np.integer, np.floating),
71
+ )
72
+ or value not in valid_labels
73
+ ):
74
+ raise ValueError(
75
+ f"Invalid binary label in {name} at index "
76
+ f"{index}: {value!r}. Labels must be 0 or 1."
77
+ )
78
+
79
+ if not actual:
80
+ raise ValueError("y_test must contain at least one sample.")
81
+
82
+ if len(actual) != len(predictions):
83
+ raise ValueError("Predictions and y_test must have the same length.")
84
+
85
+ true_positive = sum(
86
+ prediction == 1 and target == 1
87
+ for prediction, target in zip(predictions, actual)
88
+ )
89
+
90
+ false_positive = sum(
91
+ prediction == 1 and target != 1
92
+ for prediction, target in zip(predictions, actual)
93
+ )
94
+
95
+ false_negative = sum(
96
+ prediction != 1 and target == 1
97
+ for prediction, target in zip(predictions, actual)
98
+ )
99
+
100
+ if true_positive + false_positive == 0:
101
+ raise ValueError(
102
+ "Precision cannot be calculated because the model "
103
+ "predicted no positive samples."
104
+ )
105
+
106
+ precision = true_positive / (true_positive + false_positive)
107
+
108
+ if true_positive + false_negative == 0:
109
+ raise ValueError(
110
+ "Recall cannot be calculated because there are no "
111
+ "positive samples in y_test."
112
+ )
113
+
114
+ recall = true_positive / (true_positive + false_negative)
115
+
116
+ f1 = 2 * precision * recall / (precision + recall)
117
+
118
+ return CheckResult(
119
+ check_id="model.classification_metrics",
120
+ title="Classification Metrics",
121
+ category="performance",
122
+ passed=True,
123
+ severity="info",
124
+ score=round(float(f1 * 100.0), 2),
125
+ message=(
126
+ f"Classification metrics calculated successfully: "
127
+ f"precision={precision:.4f}, "
128
+ f"recall={recall:.4f}, "
129
+ f"f1={f1:.4f}."
130
+ ),
131
+ evidence={
132
+ "metric": "classification_metrics",
133
+ "precision": round(float(precision), 4),
134
+ "recall": round(float(recall), 4),
135
+ "f1": round(float(f1), 4),
136
+ "test_samples": len(actual),
137
+ },
138
+ recommendation="",
139
+ )
140
+
141
+ except Exception as exc: # noqa: BLE001 - Convert evaluation failures to check results.
142
+ return CheckResult(
143
+ check_id="model.classification_metrics",
144
+ title="Classification Metrics",
145
+ category="performance",
146
+ passed=False,
147
+ severity="critical",
148
+ score=0.0,
149
+ message=f"Classification metric calculation failed: {exc}",
150
+ recommendation=("Verify that predictions and test targets are valid."),
151
+ )
@@ -0,0 +1,172 @@
1
+ from typing import Any
2
+
3
+ import pandas as pd
4
+
5
+ from ml_reliability.core.result import CheckResult
6
+
7
+
8
+ def check_distribution_shift(
9
+ X_train: Any,
10
+ X_test: Any,
11
+ ) -> CheckResult:
12
+ """
13
+ Compare numeric feature distributions between training and test data.
14
+ """
15
+
16
+ if X_train is None or X_test is None:
17
+ return CheckResult(
18
+ check_id="data.distribution_shift",
19
+ title="Train/Test Distribution Shift",
20
+ category="distribution",
21
+ passed=False,
22
+ severity="critical",
23
+ score=0.0,
24
+ message="Both training and test data are required.",
25
+ recommendation="Provide X_train and X_test before running distribution shift checks.",
26
+ )
27
+
28
+ if not isinstance(X_train, pd.DataFrame) or not isinstance(X_test, pd.DataFrame):
29
+ return CheckResult(
30
+ check_id="data.distribution_shift",
31
+ title="Train/Test Distribution Shift",
32
+ category="distribution",
33
+ passed=False,
34
+ severity="critical",
35
+ score=0.0,
36
+ message="Training and test data must both be pandas DataFrames.",
37
+ recommendation="Provide X_train and X_test as pandas DataFrames.",
38
+ )
39
+
40
+ if X_train.empty or X_test.empty:
41
+ return CheckResult(
42
+ check_id="data.distribution_shift",
43
+ title="Train/Test Distribution Shift",
44
+ category="distribution",
45
+ passed=False,
46
+ severity="critical",
47
+ score=0.0,
48
+ message="Training and test data must both contain at least one row.",
49
+ evidence={
50
+ "train_rows": len(X_train),
51
+ "test_rows": len(X_test),
52
+ },
53
+ recommendation="Provide non-empty training and test datasets.",
54
+ )
55
+
56
+ shared_columns = [column for column in X_train.columns if column in X_test.columns]
57
+
58
+ numeric_columns = [
59
+ column
60
+ for column in shared_columns
61
+ if pd.api.types.is_numeric_dtype(X_train[column])
62
+ and pd.api.types.is_numeric_dtype(X_test[column])
63
+ ]
64
+
65
+ if not numeric_columns:
66
+ return CheckResult(
67
+ check_id="data.distribution_shift",
68
+ title="Train/Test Distribution Shift",
69
+ category="distribution",
70
+ passed=True,
71
+ severity="info",
72
+ score=100.0,
73
+ message="No shared numeric features were available for distribution comparison.",
74
+ evidence={
75
+ "shared_columns": [str(column) for column in shared_columns],
76
+ "numeric_features_compared": 0,
77
+ "shifted_features": [],
78
+ },
79
+ recommendation="Add numeric features or implement categorical distribution checks for categorical data.",
80
+ )
81
+
82
+ feature_statistics = {}
83
+ moderate_shift_features = []
84
+ significant_shift_features = []
85
+
86
+ for column in numeric_columns:
87
+ train_series = pd.to_numeric(X_train[column], errors="coerce").dropna()
88
+ test_series = pd.to_numeric(X_test[column], errors="coerce").dropna()
89
+
90
+ if train_series.empty or test_series.empty:
91
+ continue
92
+
93
+ train_mean = float(train_series.mean())
94
+ test_mean = float(test_series.mean())
95
+
96
+ absolute_difference = abs(test_mean - train_mean)
97
+
98
+ if train_mean != 0:
99
+ relative_shift = (absolute_difference / abs(train_mean)) * 100
100
+ else:
101
+ relative_shift = 0.0 if test_mean == 0 else float("inf")
102
+
103
+ if relative_shift < 10:
104
+ status = "stable"
105
+ elif relative_shift <= 20:
106
+ status = "moderate"
107
+ moderate_shift_features.append(str(column))
108
+ else:
109
+ status = "significant"
110
+ significant_shift_features.append(str(column))
111
+
112
+ feature_statistics[str(column)] = {
113
+ "train_mean": round(train_mean, 4),
114
+ "test_mean": round(test_mean, 4),
115
+ "absolute_difference": round(absolute_difference, 4),
116
+ "relative_shift_percentage": (
117
+ round(relative_shift, 2) if relative_shift != float("inf") else None
118
+ ),
119
+ "status": status,
120
+ }
121
+
122
+ if significant_shift_features:
123
+ severity = "warning"
124
+ score = 50.0
125
+ passed = True
126
+ message = (
127
+ f"Detected significant distribution shift in "
128
+ f"{len(significant_shift_features)} feature(s)."
129
+ )
130
+ elif moderate_shift_features:
131
+ severity = "warning"
132
+ score = 80.0
133
+ passed = True
134
+ message = (
135
+ f"Detected moderate distribution shift in "
136
+ f"{len(moderate_shift_features)} feature(s)."
137
+ )
138
+ else:
139
+ severity = "info"
140
+ score = 100.0
141
+ passed = True
142
+ message = "No significant numeric distribution shift was detected."
143
+
144
+ return CheckResult(
145
+ check_id="data.distribution_shift",
146
+ title="Train/Test Distribution Shift",
147
+ category="distribution",
148
+ passed=passed,
149
+ severity=severity,
150
+ score=score,
151
+ message=message,
152
+ evidence={
153
+ "train_rows": len(X_train),
154
+ "test_rows": len(X_test),
155
+ "shared_columns": [str(column) for column in shared_columns],
156
+ "numeric_features_compared": len(numeric_columns),
157
+ "moderate_shift_features": moderate_shift_features,
158
+ "significant_shift_features": significant_shift_features,
159
+ "feature_statistics": feature_statistics,
160
+ },
161
+ recommendation=(
162
+ "Investigate significant train/test distribution differences "
163
+ "before relying on model evaluation results."
164
+ if significant_shift_features
165
+ else (
166
+ "Monitor moderate distribution differences and verify that "
167
+ "they are expected."
168
+ if moderate_shift_features
169
+ else ""
170
+ )
171
+ ),
172
+ )