evalkit-ml 0.1.1__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.
evalkit/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .tracker import ModelTracker, track
2
+ from .metrics import register_metric, unregister_metric
3
+
4
+ __all__ = [
5
+ "ModelTracker",
6
+ "track",
7
+ "register_metric",
8
+ "unregister_metric",
9
+ ]
evalkit/ci.py ADDED
@@ -0,0 +1,37 @@
1
+ import json
2
+
3
+ from .rules import evaluate_rules, rules_passed
4
+
5
+
6
+ def check_report(report, thresholds=None):
7
+ result = report.check_regression(
8
+ thresholds=thresholds
9
+ )
10
+
11
+ if result["status"] == "regression_detected":
12
+ return False
13
+
14
+ return True
15
+
16
+
17
+ def check_report_with_rules(report, thresholds=None, rules=None):
18
+ regression_passed = check_report(report, thresholds)
19
+ rule_results = evaluate_rules(report.metrics, rules)
20
+ report.rule_results = rule_results
21
+ return regression_passed and rules_passed(rule_results)
22
+
23
+
24
+ def ci_output(report, passed, fmt="text"):
25
+ payload = {
26
+ "run_id": report.run_id,
27
+ "model": report.model_name,
28
+ "passed": passed,
29
+ "metrics": report.metrics,
30
+ "rules": report.rule_results or {},
31
+ }
32
+ if fmt == "json":
33
+ return json.dumps(payload, indent=4)
34
+ if fmt == "github":
35
+ level = "notice" if passed else "error"
36
+ return f"::{level} title=EvalKit::CI {'PASSED' if passed else 'FAILED'}"
37
+ return f"CI Status: {'PASSED' if passed else 'FAILED'}"
evalkit/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+
5
+ from .ci import check_report_with_rules, ci_output
6
+ from .comparator import ReportComparator
7
+ from .config import load_config, merge_thresholds
8
+ from .report import EvaluationReport
9
+
10
+
11
+ def load_json(filepath):
12
+ with open(filepath, "r") as file:
13
+ return json.load(file)
14
+
15
+
16
+ def load_report(filepath):
17
+ data = load_json(filepath)
18
+
19
+ if isinstance(data, list):
20
+ if not data:
21
+ raise ValueError(f"Report file is empty: {filepath}")
22
+ data = data[-1]
23
+
24
+ return EvaluationReport(
25
+ model_name=data["model"],
26
+ model_version=data.get("model_version"),
27
+ timestamp=data.get("timestamp"),
28
+ run_id=data.get("run_id"),
29
+ metrics=data["metrics"],
30
+ diagnostics=data.get("diagnostics", {}),
31
+ data_profile=data.get("data_profile", {}),
32
+ performance=data.get("performance", {}),
33
+ previous_report=data.get("previous_report"),
34
+ drift=data.get("drift"),
35
+ model_metadata=data.get("model_metadata", {}),
36
+ dataset_metadata=data.get("dataset_metadata", {}),
37
+ feature_monitoring=data.get("feature_monitoring", {}),
38
+ prediction_distribution=data.get("prediction_distribution", {}),
39
+ explainability=data.get("explainability", {}),
40
+ rule_results=data.get("rule_results", {}),
41
+ )
42
+
43
+
44
+ def parse_thresholds(values):
45
+ thresholds = {}
46
+
47
+ for value in values:
48
+ try:
49
+ metric, threshold = value.split("=", 1)
50
+ thresholds[metric] = float(threshold)
51
+ except ValueError as error:
52
+ raise argparse.ArgumentTypeError(
53
+ "thresholds must use METRIC=VALUE format"
54
+ ) from error
55
+
56
+ return thresholds
57
+
58
+
59
+ def add_threshold_argument(parser):
60
+ parser.add_argument(
61
+ "--threshold",
62
+ action="append",
63
+ default=[],
64
+ metavar="METRIC=VALUE",
65
+ help="Allowed metric change; may be repeated",
66
+ )
67
+
68
+
69
+ def print_comparison(results):
70
+ for metric, result in results.items():
71
+ if metric == "_model_version":
72
+ continue
73
+
74
+ print(
75
+ f"{metric}: "
76
+ f"{result['previous']:.4f} -> "
77
+ f"{result['current']:.4f} "
78
+ f"({result['status']})"
79
+ )
80
+
81
+ version = results.get("_model_version")
82
+ if version:
83
+ print(
84
+ f"model_version: {version['previous']} -> "
85
+ f"{version['current']} "
86
+ f"({'changed' if version['changed'] else 'unchanged'})"
87
+ )
88
+
89
+
90
+ def command_check(args):
91
+ config = load_config(args.config)
92
+ report = load_report(args.report)
93
+
94
+ if args.previous:
95
+ report.previous_report = load_json(args.previous)
96
+ if isinstance(report.previous_report, list):
97
+ report.previous_report = report.previous_report[-1]
98
+
99
+ thresholds = merge_thresholds(config, parse_thresholds(args.threshold))
100
+ passed = check_report_with_rules(
101
+ report,
102
+ thresholds=thresholds,
103
+ rules=config.get("rules", {}),
104
+ )
105
+ print(ci_output(report, passed, args.output))
106
+
107
+ return 0 if passed else 1
108
+
109
+
110
+ def command_compare(args):
111
+ config = load_config(args.config)
112
+ previous = load_json(args.previous)
113
+ current = load_json(args.current)
114
+
115
+ if isinstance(previous, list):
116
+ previous = previous[-1]
117
+ if isinstance(current, list):
118
+ current = current[-1]
119
+
120
+ results = ReportComparator(previous, current).compare(
121
+ thresholds=merge_thresholds(config, parse_thresholds(args.threshold))
122
+ )
123
+ print_comparison(results)
124
+ return 0
125
+
126
+
127
+ def command_history(args):
128
+ config = load_config(args.config)
129
+ data = load_json(args.file or config["history_file"])
130
+ runs = data if isinstance(data, list) else [data]
131
+
132
+ if args.limit:
133
+ runs = runs[-args.limit:]
134
+
135
+ start_index = len(data) - len(runs) + 1 if isinstance(data, list) else 1
136
+ for index, run in enumerate(runs, start=start_index):
137
+ print(
138
+ f"{index}: {run.get('model')} "
139
+ f"{run.get('model_version', 'unknown')} "
140
+ f"{run.get('timestamp', 'unknown')}"
141
+ )
142
+
143
+ return 0
144
+
145
+
146
+ def build_parser():
147
+ parser = argparse.ArgumentParser(
148
+ prog="evalkit",
149
+ description="ML model evaluation toolkit",
150
+ )
151
+ commands = parser.add_subparsers(dest="command", required=True)
152
+
153
+ check = commands.add_parser("check", help="Check a report for regression")
154
+ check.add_argument("--report", required=True)
155
+ check.add_argument("--previous")
156
+ check.add_argument("--config", default="evalkit.yaml")
157
+ check.add_argument(
158
+ "--output",
159
+ choices=["text", "json", "github"],
160
+ default="text",
161
+ )
162
+ add_threshold_argument(check)
163
+ check.set_defaults(handler=command_check)
164
+
165
+ compare = commands.add_parser("compare", help="Compare two reports")
166
+ compare.add_argument("--current", required=True)
167
+ compare.add_argument("--previous", required=True)
168
+ compare.add_argument("--config", default="evalkit.yaml")
169
+ add_threshold_argument(compare)
170
+ compare.set_defaults(handler=command_compare)
171
+
172
+ history = commands.add_parser("history", help="List saved evaluation reports")
173
+ history.add_argument("--file")
174
+ history.add_argument("--config", default="evalkit.yaml")
175
+ history.add_argument("--limit", type=int)
176
+ history.set_defaults(handler=command_history)
177
+
178
+ return parser
179
+
180
+
181
+ def main():
182
+ parser = build_parser()
183
+ args = parser.parse_args()
184
+
185
+ try:
186
+ return args.handler(args)
187
+ except (FileNotFoundError, KeyError, ValueError) as error:
188
+ parser.error(str(error))
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())
evalkit/comparator.py ADDED
@@ -0,0 +1,87 @@
1
+ METRIC_DIRECTION = {
2
+ "accuracy": "higher",
3
+ "precision": "higher",
4
+ "recall": "higher",
5
+ "f1": "higher",
6
+
7
+ "mae": "lower",
8
+ "mse": "lower",
9
+ "rmse": "lower",
10
+
11
+ "r2": "higher",
12
+
13
+ "fit_time": "lower",
14
+ "prediction_time": "lower",
15
+ }
16
+
17
+
18
+ class ReportComparator:
19
+
20
+ def __init__(self, previous, current):
21
+ self.previous = previous
22
+ self.current = current
23
+
24
+ def compare(self, thresholds=None):
25
+ if thresholds is None:
26
+ thresholds = {}
27
+
28
+ results = {}
29
+
30
+ previous_version = self.previous.get("model_version")
31
+ current_version = self.current.get("model_version")
32
+
33
+ version_changed = (
34
+ previous_version != current_version
35
+ )
36
+
37
+ previous_metrics = self.previous["metrics"]
38
+ current_metrics = self.current["metrics"]
39
+
40
+ for metric, current_value in current_metrics.items():
41
+
42
+ if metric not in previous_metrics:
43
+ continue
44
+
45
+ previous_value = previous_metrics[metric]
46
+ change = current_value - previous_value
47
+
48
+ direction = METRIC_DIRECTION.get(metric)
49
+ threshold = thresholds.get(metric, 0)
50
+
51
+ if direction == "higher":
52
+
53
+ if change < -threshold:
54
+ status = "degraded"
55
+ elif change > threshold:
56
+ status = "improved"
57
+ else:
58
+ status = "unchanged"
59
+
60
+ elif direction == "lower":
61
+
62
+ if change > threshold:
63
+ status = "degraded"
64
+ elif change < -threshold:
65
+ status = "improved"
66
+ else:
67
+ status = "unchanged"
68
+
69
+ else:
70
+ status = "unknown"
71
+
72
+ results[metric] = {
73
+ "previous": previous_value,
74
+ "current": current_value,
75
+ "change": change,
76
+ "threshold": threshold,
77
+ "status": status,
78
+ }
79
+
80
+ results["_model_version"] = {
81
+ "previous": previous_version,
82
+ "current": current_version,
83
+ "changed": version_changed,
84
+ }
85
+
86
+ return results
87
+
evalkit/config.py ADDED
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+
3
+
4
+ DEFAULT_CONFIG = {
5
+ "history_file": "evalkit_history.json",
6
+ "thresholds": {},
7
+ "report_format": "text",
8
+ }
9
+
10
+
11
+ def load_config(filepath="evalkit.yaml"):
12
+ config = dict(DEFAULT_CONFIG)
13
+ path = Path(filepath)
14
+
15
+ if not path.exists():
16
+ return config
17
+
18
+ try:
19
+ import yaml
20
+ except ImportError as error:
21
+ raise RuntimeError(
22
+ "YAML configuration requires PyYAML. Install it with 'pip install pyyaml'."
23
+ ) from error
24
+
25
+ with path.open("r") as file:
26
+ loaded = yaml.safe_load(file) or {}
27
+
28
+ if not isinstance(loaded, dict):
29
+ raise ValueError("Configuration must contain a YAML mapping")
30
+
31
+ config.update(loaded)
32
+ return config
33
+
34
+
35
+ def merge_thresholds(config, cli_thresholds):
36
+ thresholds = dict(config.get("thresholds", {}) or {})
37
+ thresholds.update(cli_thresholds)
38
+ return thresholds
evalkit/diagnostics.py ADDED
@@ -0,0 +1,23 @@
1
+ import numpy as np
2
+ from sklearn.metrics import confusion_matrix
3
+
4
+
5
+ def classification_diagnostics(y_true, y_pred):
6
+
7
+ matrix = confusion_matrix(y_true, y_pred)
8
+
9
+ classes, counts = np.unique(
10
+ y_true,
11
+ return_counts=True
12
+ )
13
+
14
+ class_distribution = {
15
+ str(cls): int(count)
16
+ for cls, count in zip(classes, counts)
17
+ }
18
+
19
+ return {
20
+ "confusion_matrix": matrix.tolist(),
21
+ "class_distribution": class_distribution,
22
+ "number_of_classes": len(classes),
23
+ }
evalkit/drift.py ADDED
@@ -0,0 +1,240 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.stats import ks_2samp
4
+
5
+
6
+ def calculate_psi(expected, actual, bins=10):
7
+ """
8
+ Calculate Population Stability Index (PSI)
9
+ between two numeric distributions.
10
+ """
11
+
12
+ expected = np.asarray(expected)
13
+ actual = np.asarray(actual)
14
+
15
+ breakpoints = np.percentile(
16
+ expected,
17
+ np.linspace(0, 100, bins + 1)
18
+ )
19
+
20
+ breakpoints[0] = -np.inf
21
+ breakpoints[-1] = np.inf
22
+
23
+ expected_counts, _ = np.histogram(
24
+ expected,
25
+ bins=breakpoints
26
+ )
27
+
28
+ actual_counts, _ = np.histogram(
29
+ actual,
30
+ bins=breakpoints
31
+ )
32
+
33
+ expected_pct = expected_counts / len(expected)
34
+ actual_pct = actual_counts / len(actual)
35
+
36
+ # Avoid division by zero
37
+ expected_pct = np.where(
38
+ expected_pct == 0,
39
+ 0.0001,
40
+ expected_pct
41
+ )
42
+
43
+ actual_pct = np.where(
44
+ actual_pct == 0,
45
+ 0.0001,
46
+ actual_pct
47
+ )
48
+
49
+ psi = np.sum(
50
+ (actual_pct - expected_pct)
51
+ * np.log(actual_pct / expected_pct)
52
+ )
53
+
54
+ return float(psi)
55
+
56
+
57
+ def detect_numeric_drift(
58
+ reference,
59
+ current,
60
+ threshold=0.2
61
+ ):
62
+ """
63
+ Detect drift between reference and current
64
+ numeric datasets.
65
+ """
66
+
67
+ reference = pd.DataFrame(reference)
68
+ current = pd.DataFrame(current)
69
+
70
+ results = {}
71
+
72
+ common_columns = reference.columns.intersection(
73
+ current.columns
74
+ )
75
+
76
+ for column in common_columns:
77
+
78
+ if not (
79
+ pd.api.types.is_numeric_dtype(
80
+ reference[column]
81
+ )
82
+ and
83
+ pd.api.types.is_numeric_dtype(
84
+ current[column]
85
+ )
86
+ ):
87
+ continue
88
+
89
+ reference_values = reference[column].dropna()
90
+ current_values = current[column].dropna()
91
+
92
+ if len(reference_values) == 0 or len(current_values) == 0:
93
+ continue
94
+
95
+ psi = calculate_psi(
96
+ reference_values,
97
+ current_values
98
+ )
99
+ ks_statistic, ks_pvalue = ks_2samp(
100
+ reference_values,
101
+ current_values
102
+ )
103
+
104
+ if psi >= threshold:
105
+ status = "drifted"
106
+ else:
107
+ status = "stable"
108
+
109
+ results[column] = {
110
+ "psi": psi,
111
+ "ks_statistic": float(ks_statistic),
112
+ "ks_pvalue": float(ks_pvalue),
113
+ "threshold": threshold,
114
+ "status": status
115
+ }
116
+
117
+ return results
118
+
119
+ def detect_categorical_drift(
120
+ reference,
121
+ current,
122
+ threshold=0.2
123
+ ):
124
+ """
125
+ Detect drift between reference and current
126
+ categorical datasets.
127
+ """
128
+
129
+ reference = pd.DataFrame(reference)
130
+ current = pd.DataFrame(current)
131
+
132
+ results = {}
133
+
134
+ common_columns = reference.columns.intersection(
135
+ current.columns
136
+ )
137
+
138
+ for column in common_columns:
139
+
140
+ if (
141
+ pd.api.types.is_numeric_dtype(reference[column])
142
+ or
143
+ pd.api.types.is_numeric_dtype(current[column])
144
+ ):
145
+ continue
146
+
147
+ reference_counts = (
148
+ reference[column]
149
+ .value_counts(normalize=True)
150
+ )
151
+
152
+ current_counts = (
153
+ current[column]
154
+ .value_counts(normalize=True)
155
+ )
156
+
157
+ categories = set(reference_counts.index).union(
158
+ current_counts.index
159
+ )
160
+
161
+ psi = 0.0
162
+
163
+ for category in categories:
164
+
165
+ expected = reference_counts.get(
166
+ category,
167
+ 0
168
+ )
169
+
170
+ actual = current_counts.get(
171
+ category,
172
+ 0
173
+ )
174
+
175
+ expected = max(expected, 0.0001)
176
+ actual = max(actual, 0.0001)
177
+
178
+ psi += (
179
+ (actual - expected)
180
+ * np.log(actual / expected)
181
+ )
182
+
183
+ reference_probability = np.array([
184
+ max(reference_counts.get(category, 0), 0.0001)
185
+ for category in categories
186
+ ])
187
+ current_probability = np.array([
188
+ max(current_counts.get(category, 0), 0.0001)
189
+ for category in categories
190
+ ])
191
+ reference_probability /= reference_probability.sum()
192
+ current_probability /= current_probability.sum()
193
+ midpoint = (reference_probability + current_probability) / 2
194
+ js_divergence = 0.5 * np.sum(
195
+ reference_probability * np.log(reference_probability / midpoint)
196
+ ) + 0.5 * np.sum(
197
+ current_probability * np.log(current_probability / midpoint)
198
+ )
199
+
200
+ if psi >= threshold:
201
+ status = "drifted"
202
+ else:
203
+ status = "stable"
204
+
205
+ results[column] = {
206
+ "psi": float(psi),
207
+ "js_divergence": float(js_divergence),
208
+ "threshold": threshold,
209
+ "status": status
210
+ }
211
+
212
+ return results
213
+
214
+
215
+ def detect_drift(
216
+ reference,
217
+ current,
218
+ threshold=0.2
219
+ ):
220
+ """
221
+ Automatically detect numeric and categorical
222
+ drift between reference and current datasets.
223
+ """
224
+
225
+ numeric_results = detect_numeric_drift(
226
+ reference,
227
+ current,
228
+ threshold=threshold
229
+ )
230
+
231
+ categorical_results = detect_categorical_drift(
232
+ reference,
233
+ current,
234
+ threshold=threshold
235
+ )
236
+
237
+ return {
238
+ "numeric": numeric_results,
239
+ "categorical": categorical_results
240
+ }
evalkit/evaluator.py ADDED
@@ -0,0 +1,35 @@
1
+ from sklearn.base import is_classifier, is_regressor
2
+
3
+ from .metrics import (
4
+ classification_metrics,
5
+ regression_metrics,
6
+ )
7
+
8
+
9
+ class Evaluator:
10
+
11
+ def __init__(self, model, metrics=None):
12
+ self.model = model
13
+ self.metrics = metrics or {}
14
+
15
+ def evaluate(self, y_true, y_pred):
16
+ if is_classifier(self.model):
17
+ results = classification_metrics(
18
+ y_true,
19
+ y_pred
20
+ )
21
+ elif is_regressor(self.model):
22
+ results = regression_metrics(
23
+ y_true,
24
+ y_pred
25
+ )
26
+ else:
27
+ raise NotImplementedError(
28
+ "This model type is not supported yet."
29
+ )
30
+
31
+ results.update({
32
+ name: float(function(y_true, y_pred))
33
+ for name, function in self.metrics.items()
34
+ })
35
+ return results