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 +9 -0
- evalkit/ci.py +37 -0
- evalkit/cli.py +192 -0
- evalkit/comparator.py +87 -0
- evalkit/config.py +38 -0
- evalkit/diagnostics.py +23 -0
- evalkit/drift.py +240 -0
- evalkit/evaluator.py +35 -0
- evalkit/history.py +54 -0
- evalkit/metrics.py +67 -0
- evalkit/monitoring.py +74 -0
- evalkit/profiler.py +15 -0
- evalkit/report.py +251 -0
- evalkit/rules.py +37 -0
- evalkit/tracker.py +186 -0
- evalkit/versioning.py +26 -0
- evalkit_ml-0.1.1.dist-info/METADATA +500 -0
- evalkit_ml-0.1.1.dist-info/RECORD +21 -0
- evalkit_ml-0.1.1.dist-info/WHEEL +5 -0
- evalkit_ml-0.1.1.dist-info/entry_points.txt +2 -0
- evalkit_ml-0.1.1.dist-info/top_level.txt +1 -0
evalkit/__init__.py
ADDED
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
|