modeltest 0.2.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.
- modeltest/__init__.py +28 -0
- modeltest/cli.py +92 -0
- modeltest/config.py +230 -0
- modeltest/core/__init__.py +18 -0
- modeltest/core/base.py +258 -0
- modeltest/core/report.py +93 -0
- modeltest/core/runner.py +14 -0
- modeltest/integrations/__init__.py +7 -0
- modeltest/integrations/mlflow.py +116 -0
- modeltest/scenarios/__init__.py +27 -0
- modeltest/scenarios/_utils.py +146 -0
- modeltest/scenarios/data.py +43 -0
- modeltest/scenarios/drift.py +84 -0
- modeltest/scenarios/explainability.py +150 -0
- modeltest/scenarios/fairness.py +81 -0
- modeltest/scenarios/performance.py +147 -0
- modeltest/scenarios/robustness.py +52 -0
- modeltest/wrappers.py +185 -0
- modeltest-0.2.0.dist-info/METADATA +252 -0
- modeltest-0.2.0.dist-info/RECORD +24 -0
- modeltest-0.2.0.dist-info/WHEEL +5 -0
- modeltest-0.2.0.dist-info/entry_points.txt +2 -0
- modeltest-0.2.0.dist-info/licenses/LICENSE +21 -0
- modeltest-0.2.0.dist-info/top_level.txt +1 -0
modeltest/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from modeltest.config import register, unregister
|
|
2
|
+
from modeltest.core.base import (
|
|
3
|
+
ModelSuite,
|
|
4
|
+
ModelTest,
|
|
5
|
+
SuiteResult,
|
|
6
|
+
TestContext,
|
|
7
|
+
TestResult,
|
|
8
|
+
)
|
|
9
|
+
from modeltest.core.runner import run_suite, run_test
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from importlib.metadata import version as _metadata_version
|
|
13
|
+
|
|
14
|
+
__version__ = _metadata_version("modeltest")
|
|
15
|
+
except Exception: # noqa: BLE001 - not installed via pip
|
|
16
|
+
__version__ = "unknown"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ModelTest",
|
|
20
|
+
"ModelSuite",
|
|
21
|
+
"SuiteResult",
|
|
22
|
+
"TestContext",
|
|
23
|
+
"TestResult",
|
|
24
|
+
"run_test",
|
|
25
|
+
"run_suite",
|
|
26
|
+
"register",
|
|
27
|
+
"unregister",
|
|
28
|
+
]
|
modeltest/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command-line interface: `modeltest validate`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from modeltest.core.base import ModelSuite
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: Optional[list] = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="modeltest", description="Unit tests for machine learning models."
|
|
15
|
+
)
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
validate = sub.add_parser("validate", help="Run a test suite against a model.")
|
|
19
|
+
validate.add_argument(
|
|
20
|
+
"--suite",
|
|
21
|
+
required=True,
|
|
22
|
+
help="Path to suite.py (defining `suite`) or suite.yaml (declarative).",
|
|
23
|
+
)
|
|
24
|
+
validate.add_argument("--model", required=True, help="Path to model (pickle).")
|
|
25
|
+
validate.add_argument("--data", required=True, help="Path to validation CSV.")
|
|
26
|
+
validate.add_argument(
|
|
27
|
+
"--target", default="target", help="Name of the target column."
|
|
28
|
+
)
|
|
29
|
+
validate.add_argument(
|
|
30
|
+
"--output", default=None, help="Write JUnit XML to this path."
|
|
31
|
+
)
|
|
32
|
+
validate.add_argument(
|
|
33
|
+
"--train-data", default=None, help="Optional training CSV (for drift tests)."
|
|
34
|
+
)
|
|
35
|
+
validate.set_defaults(func=_run_validate)
|
|
36
|
+
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
return args.func(args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_suite(path: str) -> "ModelSuite":
|
|
42
|
+
import os
|
|
43
|
+
|
|
44
|
+
ext = os.path.splitext(path)[1].lower()
|
|
45
|
+
if ext in (".yaml", ".yml"):
|
|
46
|
+
from modeltest.config import load_suite_yaml
|
|
47
|
+
|
|
48
|
+
return load_suite_yaml(path)
|
|
49
|
+
# default: treat as a Python module exposing `suite`
|
|
50
|
+
import importlib.util
|
|
51
|
+
|
|
52
|
+
spec = importlib.util.spec_from_file_location("suite_module", os.path.abspath(path))
|
|
53
|
+
mod = importlib.util.module_from_spec(spec)
|
|
54
|
+
spec.loader.exec_module(mod)
|
|
55
|
+
return mod.suite
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_validate(args: argparse.Namespace) -> int:
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
import joblib
|
|
62
|
+
import pandas as pd
|
|
63
|
+
|
|
64
|
+
suite: ModelSuite = _load_suite(args.suite)
|
|
65
|
+
model = joblib.load(os.path.abspath(args.model))
|
|
66
|
+
df = pd.read_csv(args.data)
|
|
67
|
+
y = df[args.target]
|
|
68
|
+
X = df.drop(columns=[args.target])
|
|
69
|
+
|
|
70
|
+
X_train = None
|
|
71
|
+
if args.train_data:
|
|
72
|
+
train_df = pd.read_csv(args.train_data)
|
|
73
|
+
X_train = train_df.drop(columns=[args.target])
|
|
74
|
+
|
|
75
|
+
result = suite.run(
|
|
76
|
+
model, X, y, X_train=X_train, model_name=os.path.basename(args.model)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if args.output:
|
|
80
|
+
from modeltest.core.report import to_junit_xml
|
|
81
|
+
|
|
82
|
+
out_path = os.path.abspath(args.output)
|
|
83
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
84
|
+
with open(out_path, "w") as fh:
|
|
85
|
+
fh.write(to_junit_xml(result))
|
|
86
|
+
|
|
87
|
+
print(result.report(style="table"))
|
|
88
|
+
return 0 if result.passed else 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|
modeltest/config.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Declarative suite definition: load a ModelSuite from YAML.
|
|
2
|
+
|
|
3
|
+
The YAML format mirrors the built-in scenarios with a ``type`` and ``params``:
|
|
4
|
+
|
|
5
|
+
.. code-block:: yaml
|
|
6
|
+
|
|
7
|
+
suite:
|
|
8
|
+
name: "Credit Scoring Model"
|
|
9
|
+
tests:
|
|
10
|
+
- type: minimum_accuracy
|
|
11
|
+
params:
|
|
12
|
+
threshold: 0.85
|
|
13
|
+
- type: group_performance
|
|
14
|
+
params:
|
|
15
|
+
metric: accuracy
|
|
16
|
+
threshold: 0.8
|
|
17
|
+
group_col: "gender"
|
|
18
|
+
- type: robustness
|
|
19
|
+
params: {noise_std: 0.01, max_drop: 0.03}
|
|
20
|
+
- type: data_drift
|
|
21
|
+
params: {features: [age, income], max_psi: 0.15}
|
|
22
|
+
- type: equal_opportunity
|
|
23
|
+
params: {protected_col: "gender", max_diff: 0.1}
|
|
24
|
+
- type: statistical_parity
|
|
25
|
+
params: {protected_col: "gender", max_diff: 0.1, min_ratio: 0.8}
|
|
26
|
+
- type: feature_dominance
|
|
27
|
+
params: {max_top_share: 0.9}
|
|
28
|
+
- type: top_features
|
|
29
|
+
params: {expected_features: [income, age], k: 2}
|
|
30
|
+
- type: confidence_threshold
|
|
31
|
+
params: {metric: accuracy, threshold: 0.85, n_boot: 1000, alpha: 0.05}
|
|
32
|
+
- type: data_invariant
|
|
33
|
+
params: {expected_columns: [age, income], max_null_ratio: 0.02}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import importlib
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
from modeltest.core.base import ModelSuite, ModelTest
|
|
44
|
+
from modeltest.scenarios import ( # type: ignore[attr-defined]
|
|
45
|
+
ConfidenceThresholdTest,
|
|
46
|
+
DataDriftTest,
|
|
47
|
+
DataInvariantTest,
|
|
48
|
+
EqualOpportunityTest,
|
|
49
|
+
FeatureDominanceTest,
|
|
50
|
+
GroupPerformanceTest,
|
|
51
|
+
KSTest,
|
|
52
|
+
MinimumAccuracyTest,
|
|
53
|
+
NoNullTest,
|
|
54
|
+
RobustnessTest,
|
|
55
|
+
StatisticalParityTest,
|
|
56
|
+
TopFeaturesTest,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Map YAML `type` strings to the scenario class used to build each test.
|
|
60
|
+
_REGISTRY = {
|
|
61
|
+
"minimum_accuracy": MinimumAccuracyTest,
|
|
62
|
+
"group_performance": GroupPerformanceTest,
|
|
63
|
+
"robustness": RobustnessTest,
|
|
64
|
+
"data_invariant": DataInvariantTest,
|
|
65
|
+
"no_null": NoNullTest,
|
|
66
|
+
"data_drift": DataDriftTest,
|
|
67
|
+
"ks": KSTest,
|
|
68
|
+
"equal_opportunity": EqualOpportunityTest,
|
|
69
|
+
"statistical_parity": StatisticalParityTest,
|
|
70
|
+
"feature_dominance": FeatureDominanceTest,
|
|
71
|
+
"top_features": TopFeaturesTest,
|
|
72
|
+
"confidence_threshold": ConfidenceThresholdTest,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register(type_name: str, cls: type) -> None:
|
|
77
|
+
"""Map a YAML ``type`` string to a custom test class.
|
|
78
|
+
|
|
79
|
+
Custom tests must subclass :class:`modeltest.ModelTest`. Registering
|
|
80
|
+
before :func:`load_suite_yaml` lets a suite reference your test by name::
|
|
81
|
+
|
|
82
|
+
from modeltest.config import register
|
|
83
|
+
register("my_error_check", MyErrorCheck)
|
|
84
|
+
|
|
85
|
+
# suite.yaml
|
|
86
|
+
# suite:
|
|
87
|
+
# tests:
|
|
88
|
+
# - type: my_error_check
|
|
89
|
+
# params: {max_errors: 5}
|
|
90
|
+
"""
|
|
91
|
+
if not (isinstance(cls, type) and issubclass(cls, ModelTest)):
|
|
92
|
+
raise TypeError(f"register() expects a ModelTest subclass, got {cls!r}")
|
|
93
|
+
_REGISTRY[type_name] = cls
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unregister(type_name: str) -> None:
|
|
97
|
+
"""Remove a previously registered type (built-ins included)."""
|
|
98
|
+
_REGISTRY.pop(type_name, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_type(type_name: str) -> type:
|
|
102
|
+
"""Look up a test class by registered name or a dotted import path.
|
|
103
|
+
|
|
104
|
+
Unknown names may be an import reference of the form ``module.path:Class``
|
|
105
|
+
or ``module.path.Class`` pointing at a custom test class. This lets a YAML
|
|
106
|
+
suite load tests defined outside modeltest without a ``register`` call.
|
|
107
|
+
"""
|
|
108
|
+
if type_name in _REGISTRY:
|
|
109
|
+
return _REGISTRY[type_name]
|
|
110
|
+
|
|
111
|
+
if ":" in type_name:
|
|
112
|
+
module_path, _, attr = type_name.partition(":")
|
|
113
|
+
cls = _import_attr(module_path, attr)
|
|
114
|
+
else:
|
|
115
|
+
module_path, _, attr = type_name.rpartition(".")
|
|
116
|
+
cls = _import_attr(module_path, attr) if module_path else None
|
|
117
|
+
|
|
118
|
+
if cls is None:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Unknown test type {type_name!r}. Known: "
|
|
121
|
+
f"{', '.join(sorted(_REGISTRY))}. Or use module.path:Class."
|
|
122
|
+
)
|
|
123
|
+
return cls
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _import_attr(module_path: str, attr: str) -> type:
|
|
127
|
+
module = _try_import(module_path)
|
|
128
|
+
cls = getattr(module, attr, None)
|
|
129
|
+
if cls is None:
|
|
130
|
+
raise ValueError(f"Module {module_path!r} has no attribute {attr!r}")
|
|
131
|
+
if not (isinstance(cls, type) and issubclass(cls, ModelTest)):
|
|
132
|
+
raise TypeError(f"{module_path}.{attr} is not a ModelTest subclass")
|
|
133
|
+
return cls
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _try_import(module_path: str):
|
|
137
|
+
import sys
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return importlib.import_module(module_path)
|
|
141
|
+
except ImportError:
|
|
142
|
+
# User-defined test modules are often plain files in the working
|
|
143
|
+
# directory; make sure CWD is importable so dotted paths "just work"
|
|
144
|
+
# from the CLI as well as from library code.
|
|
145
|
+
cwd = os.getcwd()
|
|
146
|
+
if cwd not in sys.path:
|
|
147
|
+
sys.path.insert(0, cwd)
|
|
148
|
+
try:
|
|
149
|
+
return importlib.import_module(module_path)
|
|
150
|
+
except ImportError as exc:
|
|
151
|
+
raise ValueError(f"Could not import module {module_path!r}: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_suite_yaml(path: str) -> ModelSuite:
|
|
155
|
+
"""Build a :class:`ModelSuite` from a YAML file.
|
|
156
|
+
|
|
157
|
+
Raises ``ValueError`` for unknown test types or malformed structure.
|
|
158
|
+
"""
|
|
159
|
+
with open(path) as fh:
|
|
160
|
+
doc = yaml.safe_load(fh)
|
|
161
|
+
|
|
162
|
+
suite_cfg = doc.get("suite")
|
|
163
|
+
if not isinstance(suite_cfg, dict):
|
|
164
|
+
raise ValueError("YAML must contain a top-level `suite:` mapping")
|
|
165
|
+
|
|
166
|
+
name = suite_cfg.get("name", "suite")
|
|
167
|
+
suite = ModelSuite(name=name)
|
|
168
|
+
|
|
169
|
+
for raw in suite_cfg.get("tests", []):
|
|
170
|
+
suite.add_test(_build_test(raw))
|
|
171
|
+
|
|
172
|
+
return suite
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_test(raw) -> object:
|
|
176
|
+
if not isinstance(raw, dict):
|
|
177
|
+
raise ValueError(f"Each test must be a mapping, got: {raw!r}")
|
|
178
|
+
|
|
179
|
+
type_name = raw.get("type")
|
|
180
|
+
cls = _resolve_type(type_name)
|
|
181
|
+
|
|
182
|
+
params = raw.get("params") or {}
|
|
183
|
+
if not isinstance(params, dict):
|
|
184
|
+
raise ValueError(f"`params` for {type_name!r} must be a mapping")
|
|
185
|
+
|
|
186
|
+
# Accept clear YAML-friendly aliases for a couple of constructor names.
|
|
187
|
+
params = {_PARAM_ALIASES.get(k, k): v for k, v in params.items()}
|
|
188
|
+
|
|
189
|
+
# Ignore unknown params instead of crashing, but surface typos for hard cases.
|
|
190
|
+
import inspect
|
|
191
|
+
|
|
192
|
+
sig = inspect.signature(cls.__init__)
|
|
193
|
+
valid = {k for k in sig.parameters if k not in ("self", "args", "kwargs")}
|
|
194
|
+
unknown = set(params) - valid
|
|
195
|
+
if unknown:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Unknown params for {type_name!r}: {sorted(unknown)}. "
|
|
198
|
+
f"Valid: {sorted(valid)}"
|
|
199
|
+
)
|
|
200
|
+
return cls(**params)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_PARAM_ALIASES = {
|
|
204
|
+
"features": "feature_cols",
|
|
205
|
+
"columns": "feature_cols",
|
|
206
|
+
"group": "group_col",
|
|
207
|
+
"protected": "protected_col",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def dump_suite_yaml(suite: ModelSuite, path: str) -> None:
|
|
212
|
+
"""Serialise a suite back to YAML (best-effort; only built-in tests)."""
|
|
213
|
+
import inspect
|
|
214
|
+
|
|
215
|
+
doc = {"suite": {"name": suite.name, "tests": []}}
|
|
216
|
+
for test in suite.tests:
|
|
217
|
+
params = {
|
|
218
|
+
k: v
|
|
219
|
+
for k, v in vars(test).items()
|
|
220
|
+
if not k.startswith("_")
|
|
221
|
+
and k in inspect.signature(type(test).__init__).parameters
|
|
222
|
+
}
|
|
223
|
+
doc["suite"]["tests"].append({"type": _inverse_name(test), "params": params})
|
|
224
|
+
with open(path, "w") as fh:
|
|
225
|
+
yaml.safe_dump(doc, fh, sort_keys=False)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _inverse_name(test) -> str:
|
|
229
|
+
inverse = {v: k for k, v in _REGISTRY.items()}
|
|
230
|
+
return inverse.get(type(test), type(test).__name__)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from modeltest.core.base import (
|
|
2
|
+
ModelSuite,
|
|
3
|
+
ModelTest,
|
|
4
|
+
SuiteResult,
|
|
5
|
+
TestContext,
|
|
6
|
+
TestResult,
|
|
7
|
+
)
|
|
8
|
+
from modeltest.core.runner import run_suite, run_test
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ModelTest",
|
|
12
|
+
"ModelSuite",
|
|
13
|
+
"SuiteResult",
|
|
14
|
+
"TestContext",
|
|
15
|
+
"TestResult",
|
|
16
|
+
"run_test",
|
|
17
|
+
"run_suite",
|
|
18
|
+
]
|
modeltest/core/base.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Core primitives: ModelTest, TestContext, TestResult, ModelSuite.
|
|
2
|
+
|
|
3
|
+
The design mirrors how `pytest` structures tests but is oriented to ML:
|
|
4
|
+
each test receives a rich context (model + data + metadata) instead of a
|
|
5
|
+
bare function signature. A test *passes* by returning normally and *fails*
|
|
6
|
+
by raising an assertion / returning a TestResult with passed=False.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestStatus(str, Enum):
|
|
17
|
+
__test__ = False # don't let pytest collect this as a test class
|
|
18
|
+
|
|
19
|
+
PASSED = "PASSED"
|
|
20
|
+
FAILED = "FAILED"
|
|
21
|
+
ERROR = "ERROR"
|
|
22
|
+
SKIPPED = "SKIPPED"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TestContext:
|
|
27
|
+
"""Everything a test may need at runtime.
|
|
28
|
+
|
|
29
|
+
A unified context (rather than a loose `model, X, y` signature) lets tests
|
|
30
|
+
grow (drift needs train vs val; explainability needs metadata) without
|
|
31
|
+
breaking the base API.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__test__ = False # don't let pytest collect this as a test class
|
|
35
|
+
|
|
36
|
+
model: Any
|
|
37
|
+
X_val: Any
|
|
38
|
+
y_val: Any
|
|
39
|
+
X_train: Optional[Any] = None
|
|
40
|
+
y_train: Optional[Any] = None
|
|
41
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
cache_predictions: bool = True
|
|
43
|
+
|
|
44
|
+
# Per-suite prediction cache shared across every test that runs on this
|
|
45
|
+
# context (run_suite passes the same instance to all tests).
|
|
46
|
+
_cache: Optional[Dict[str, Any]] = field(default=None, repr=False)
|
|
47
|
+
|
|
48
|
+
_wrapper: Optional[Any] = field(default=None, repr=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def model_name(self) -> str:
|
|
52
|
+
return str(self.metadata.get("model_name", type(self.model).__name__))
|
|
53
|
+
|
|
54
|
+
def _wrapped(self) -> Any:
|
|
55
|
+
"""Return a normalized wrapper around ``self.model`` (lazily built)."""
|
|
56
|
+
if self._wrapper is None:
|
|
57
|
+
from modeltest.wrappers import wrap
|
|
58
|
+
|
|
59
|
+
self._wrapper = wrap(self.model)
|
|
60
|
+
return self._wrapper
|
|
61
|
+
|
|
62
|
+
def predict(self, X: Any = None) -> Any:
|
|
63
|
+
"""Predict with caching.
|
|
64
|
+
|
|
65
|
+
Delegates to the model via the framework adapter while transparently
|
|
66
|
+
dropping non-feature columns (via ``model_features``). When
|
|
67
|
+
``cache_predictions`` is on, the result is keyed by a content hash of
|
|
68
|
+
the input so that multiple tests predicting on the *same data* run the
|
|
69
|
+
model only once per suite.
|
|
70
|
+
|
|
71
|
+
Passing ``X=None`` predicts on ``self.X_val``.
|
|
72
|
+
"""
|
|
73
|
+
from modeltest.scenarios._utils import model_features
|
|
74
|
+
|
|
75
|
+
X = self.X_val if X is None else X
|
|
76
|
+
wrapped = self._wrapped()
|
|
77
|
+
X_feat = model_features(wrapped, X)
|
|
78
|
+
|
|
79
|
+
if not self.cache_predictions:
|
|
80
|
+
return wrapped.predict(X_feat)
|
|
81
|
+
|
|
82
|
+
if self._cache is None:
|
|
83
|
+
self._cache = {}
|
|
84
|
+
|
|
85
|
+
key = self._fingerprint(X_feat)
|
|
86
|
+
cached = self._cache.get(key)
|
|
87
|
+
if cached is not None:
|
|
88
|
+
return cached
|
|
89
|
+
pred = wrapped.predict(X_feat)
|
|
90
|
+
self._cache[key] = pred
|
|
91
|
+
return pred
|
|
92
|
+
|
|
93
|
+
def predict_proba(self, X: Any = None) -> Any:
|
|
94
|
+
"""Probability estimates via the wrapper (or ``None`` if unsupported)."""
|
|
95
|
+
from modeltest.scenarios._utils import model_features
|
|
96
|
+
|
|
97
|
+
X = self.X_val if X is None else X
|
|
98
|
+
return self._wrapped().predict_proba(model_features(self._wrapped(), X))
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _fingerprint(X: Any) -> str:
|
|
102
|
+
"""Cheap-ish content key for a validation input."""
|
|
103
|
+
import hashlib
|
|
104
|
+
|
|
105
|
+
if hasattr(X, "columns") and hasattr(X, "values"):
|
|
106
|
+
# pandas DataFrame / Series: hash column names + row content.
|
|
107
|
+
try:
|
|
108
|
+
from pandas.util import hash_pandas_object
|
|
109
|
+
|
|
110
|
+
h = hashlib.sha1()
|
|
111
|
+
h.update("|".join(map(str, X.columns)).encode())
|
|
112
|
+
h.update(hash_pandas_object(X, index=True).values.tobytes())
|
|
113
|
+
return h.hexdigest()
|
|
114
|
+
except Exception: # noqa: BLE001 - fall back below
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
import pickle
|
|
118
|
+
|
|
119
|
+
return hashlib.sha1(pickle.dumps(X, protocol=4)).hexdigest()
|
|
120
|
+
except Exception: # noqa: BLE001
|
|
121
|
+
return f"id-{id(X)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class TestResult:
|
|
126
|
+
"""Outcome of running a single test."""
|
|
127
|
+
|
|
128
|
+
__test__ = False # pytest: an outcome object, not a test hook
|
|
129
|
+
|
|
130
|
+
name: str
|
|
131
|
+
status: TestStatus
|
|
132
|
+
detail: str = ""
|
|
133
|
+
duration_ms: float = 0.0
|
|
134
|
+
metrics: Dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def passed(self) -> bool:
|
|
138
|
+
return self.status == TestStatus.PASSED
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _now_ms() -> float:
|
|
142
|
+
import time
|
|
143
|
+
|
|
144
|
+
return time.perf_counter() * 1000
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ModelTest:
|
|
148
|
+
"""Base class for all model tests.
|
|
149
|
+
|
|
150
|
+
Subclasses override `test(self, ctx)`. Raising ``AssertionError`` (or any
|
|
151
|
+
``assert`` failure) marks the test as FAILED; raising anything else marks
|
|
152
|
+
it as ERROR. Returning a :class:`TestResult` lets a test fully control the
|
|
153
|
+
outcome (useful for warning-only / non-blocking checks).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
name: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
def test(self, ctx: TestContext) -> Any:
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
|
|
161
|
+
def run(self, ctx: TestContext) -> TestResult:
|
|
162
|
+
import time
|
|
163
|
+
|
|
164
|
+
start = time.perf_counter()
|
|
165
|
+
status, detail, metrics = TestStatus.PASSED, "", {}
|
|
166
|
+
try:
|
|
167
|
+
outcome = self.test(ctx)
|
|
168
|
+
if isinstance(outcome, TestResult):
|
|
169
|
+
return outcome
|
|
170
|
+
except AssertionError as exc:
|
|
171
|
+
status = TestStatus.FAILED
|
|
172
|
+
detail = str(exc)
|
|
173
|
+
except Exception as exc: # noqa: BLE001 - unknown failures are ERRORs
|
|
174
|
+
status = TestStatus.ERROR
|
|
175
|
+
detail = f"{type(exc).__name__}: {exc}"
|
|
176
|
+
duration = (time.perf_counter() - start) * 1000
|
|
177
|
+
return TestResult(
|
|
178
|
+
name=self.name or type(self).__name__,
|
|
179
|
+
status=status,
|
|
180
|
+
detail=detail,
|
|
181
|
+
duration_ms=duration,
|
|
182
|
+
metrics=metrics,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ModelSuite:
|
|
187
|
+
"""A collection of tests run together against a model + data."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, name: str = "suite", tests: Optional[List[ModelTest]] = None):
|
|
190
|
+
self.name = name
|
|
191
|
+
self.tests: List[ModelTest] = list(tests) if tests else []
|
|
192
|
+
|
|
193
|
+
def add_test(self, test: ModelTest) -> "ModelSuite":
|
|
194
|
+
self.tests.append(test)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def add_tests(self, *tests: ModelTest) -> "ModelSuite":
|
|
198
|
+
self.tests.extend(tests)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def run(
|
|
202
|
+
self,
|
|
203
|
+
model: Any,
|
|
204
|
+
X_val: Any,
|
|
205
|
+
y_val: Any,
|
|
206
|
+
X_train: Any = None,
|
|
207
|
+
y_train: Any = None,
|
|
208
|
+
**metadata: Any,
|
|
209
|
+
) -> "SuiteResult":
|
|
210
|
+
ctx = TestContext(
|
|
211
|
+
model=model,
|
|
212
|
+
X_val=X_val,
|
|
213
|
+
y_val=y_val,
|
|
214
|
+
X_train=X_train,
|
|
215
|
+
y_train=y_train,
|
|
216
|
+
metadata=metadata or {},
|
|
217
|
+
)
|
|
218
|
+
from modeltest.core.runner import run_suite
|
|
219
|
+
|
|
220
|
+
return run_suite(self, ctx)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class SuiteResult:
|
|
225
|
+
"""Aggregate outcome of running every test in a suite."""
|
|
226
|
+
|
|
227
|
+
suite_name: str
|
|
228
|
+
results: List[TestResult] = field(default_factory=list)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def passed(self) -> bool:
|
|
232
|
+
return all(r.passed for r in self.results)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def num_passed(self) -> int:
|
|
236
|
+
return sum(1 for r in self.results if r.passed)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def num_failed(self) -> int:
|
|
240
|
+
return sum(1 for r in self.results if not r.passed)
|
|
241
|
+
|
|
242
|
+
def report(self, style: str = "table") -> str:
|
|
243
|
+
from modeltest.core.report import render_report
|
|
244
|
+
|
|
245
|
+
return render_report(self, style=style)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _ResultProxy:
|
|
249
|
+
"""Thin dict-like for test authors to attach metrics."""
|
|
250
|
+
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def assert_metric(
|
|
255
|
+
actual: float, expected: Any, op: Callable[[float, Any], bool], msg: str
|
|
256
|
+
) -> None:
|
|
257
|
+
if not op(actual, expected):
|
|
258
|
+
raise AssertionError(msg)
|