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