modeltest 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- modeltest-0.2.0/PKG-INFO +252 -0
- modeltest-0.2.0/README.md +209 -0
- modeltest-0.2.0/modeltest/__init__.py +28 -0
- modeltest-0.2.0/modeltest/cli.py +92 -0
- modeltest-0.2.0/modeltest/config.py +230 -0
- modeltest-0.2.0/modeltest/core/__init__.py +18 -0
- modeltest-0.2.0/modeltest/core/base.py +258 -0
- modeltest-0.2.0/modeltest/core/report.py +93 -0
- modeltest-0.2.0/modeltest/core/runner.py +14 -0
- modeltest-0.2.0/modeltest/integrations/__init__.py +7 -0
- modeltest-0.2.0/modeltest/integrations/mlflow.py +116 -0
- modeltest-0.2.0/modeltest/scenarios/__init__.py +27 -0
- modeltest-0.2.0/modeltest/scenarios/_utils.py +146 -0
- modeltest-0.2.0/modeltest/scenarios/data.py +43 -0
- modeltest-0.2.0/modeltest/scenarios/drift.py +84 -0
- modeltest-0.2.0/modeltest/scenarios/explainability.py +150 -0
- modeltest-0.2.0/modeltest/scenarios/fairness.py +81 -0
- modeltest-0.2.0/modeltest/scenarios/performance.py +147 -0
- modeltest-0.2.0/modeltest/scenarios/robustness.py +52 -0
- modeltest-0.2.0/modeltest/wrappers.py +185 -0
- modeltest-0.2.0/modeltest.egg-info/PKG-INFO +252 -0
- modeltest-0.2.0/modeltest.egg-info/SOURCES.txt +41 -0
- modeltest-0.2.0/modeltest.egg-info/dependency_links.txt +1 -0
- modeltest-0.2.0/modeltest.egg-info/entry_points.txt +2 -0
- modeltest-0.2.0/modeltest.egg-info/requires.txt +19 -0
- modeltest-0.2.0/modeltest.egg-info/top_level.txt +1 -0
- modeltest-0.2.0/pyproject.toml +68 -0
- modeltest-0.2.0/setup.cfg +4 -0
- modeltest-0.2.0/tests/test_bootstrap.py +135 -0
- modeltest-0.2.0/tests/test_cache.py +92 -0
- modeltest-0.2.0/tests/test_cli.py +167 -0
- modeltest-0.2.0/tests/test_config.py +143 -0
- modeltest-0.2.0/tests/test_core.py +263 -0
- modeltest-0.2.0/tests/test_custom.py +129 -0
- modeltest-0.2.0/tests/test_data_invariants.py +73 -0
- modeltest-0.2.0/tests/test_drift.py +131 -0
- modeltest-0.2.0/tests/test_explainability.py +165 -0
- modeltest-0.2.0/tests/test_fairness.py +98 -0
- modeltest-0.2.0/tests/test_mlflow.py +155 -0
- modeltest-0.2.0/tests/test_pipeline.py +122 -0
- modeltest-0.2.0/tests/test_report.py +94 -0
- modeltest-0.2.0/tests/test_wrappers.py +460 -0
modeltest-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 modeltest contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
modeltest-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modeltest
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: If you test your code, why not your model? Unit tests for machine learning models.
|
|
5
|
+
Author: modeltest contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Tzinny-dev/model-test
|
|
8
|
+
Project-URL: Repository, https://github.com/Tzinny-dev/model-test
|
|
9
|
+
Project-URL: Issues, https://github.com/Tzinny-dev/model-test/issues
|
|
10
|
+
Keywords: machine-learning,mlops,testing,validation,ci
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: numpy
|
|
27
|
+
Requires-Dist: pandas
|
|
28
|
+
Requires-Dist: scikit-learn
|
|
29
|
+
Requires-Dist: scipy
|
|
30
|
+
Requires-Dist: PyYAML
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff; extra == "dev"
|
|
35
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
36
|
+
Requires-Dist: shap; extra == "dev"
|
|
37
|
+
Requires-Dist: mlflow; extra == "dev"
|
|
38
|
+
Provides-Extra: explain
|
|
39
|
+
Requires-Dist: shap; extra == "explain"
|
|
40
|
+
Provides-Extra: mlflow
|
|
41
|
+
Requires-Dist: mlflow; extra == "mlflow"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# modeltest
|
|
45
|
+
|
|
46
|
+
> If you test your code, why not your model?
|
|
47
|
+
|
|
48
|
+
`modeltest` is a unit-testing framework for machine learning models. It lets you
|
|
49
|
+
define contracts for model quality, robustness, fairness, and data invariants,
|
|
50
|
+
and run them automatically in your CI/CD pipeline — just like `pytest` for code.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from modeltest import ModelSuite
|
|
56
|
+
from modeltest.scenarios import MinimumAccuracyTest, GroupPerformanceTest
|
|
57
|
+
|
|
58
|
+
suite = ModelSuite(name="Fraud Detection")
|
|
59
|
+
suite.add_test(MinimumAccuracyTest(threshold=0.85))
|
|
60
|
+
suite.add_test(GroupPerformanceTest(metric="accuracy", threshold=0.8, group_col="gender"))
|
|
61
|
+
|
|
62
|
+
result = suite.run(model, X_val, y_val, model_name="fraud_rf")
|
|
63
|
+
print(result.report(style="table"))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## CLI
|
|
67
|
+
|
|
68
|
+
After training, validate a model contract from the command line:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Declarative suite (recommended)
|
|
72
|
+
modeltest validate \
|
|
73
|
+
--suite suite.yaml \
|
|
74
|
+
--model model.pkl \
|
|
75
|
+
--data validation.csv \
|
|
76
|
+
--target target \
|
|
77
|
+
--train-data train.csv \
|
|
78
|
+
--output report.xml
|
|
79
|
+
|
|
80
|
+
# Python suite (suite.py exposing `suite`)
|
|
81
|
+
modeltest validate --suite suite.py --model model.pkl --data validation.csv --target target
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Extras:
|
|
85
|
+
- `--output report.xml` writes JUnit XML for CI reporters.
|
|
86
|
+
- `--train-data` enables the drift tests (they compare train vs. validation distributions).
|
|
87
|
+
- Exit code is `1` if any test fails, `0` otherwise.
|
|
88
|
+
|
|
89
|
+
## Prediction caching
|
|
90
|
+
|
|
91
|
+
Within a single `suite.run(...)`, predictions are computed once and reused
|
|
92
|
+
across every test. `TestContext.predict()` caches by a content hash of the
|
|
93
|
+
input, so tests predicting on the *same* data (`MinimumAccuracyTest`,
|
|
94
|
+
`GroupPerformanceTest`, the fairness tests, ...) each reuse the result instead
|
|
95
|
+
of re-running the model. Perturbed inputs (e.g. the robustness test's noisy
|
|
96
|
+
copy) get their own cache entry, so caching never compromises correctness.
|
|
97
|
+
|
|
98
|
+
Disable it if you need a fresh prediction every call:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
ctx = TestContext(model=model, X_val=X_val, y_val=y_val, cache_predictions=False)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## YAML suites
|
|
105
|
+
|
|
106
|
+
Define your contract declaratively — no code needed:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
suite:
|
|
110
|
+
name: "Credit Scoring Model"
|
|
111
|
+
tests:
|
|
112
|
+
- type: minimum_accuracy
|
|
113
|
+
params: {threshold: 0.85}
|
|
114
|
+
- type: group_performance
|
|
115
|
+
params: {metric: accuracy, threshold: 0.8, group_col: "gender"}
|
|
116
|
+
- type: robustness
|
|
117
|
+
params: {noise_std: 0.01, max_drop: 0.03}
|
|
118
|
+
- type: data_drift
|
|
119
|
+
params: {features: [age, income], max_psi: 0.15}
|
|
120
|
+
- type: equal_opportunity
|
|
121
|
+
params: {protected: "gender", max_diff: 0.1}
|
|
122
|
+
- type: statistical_parity
|
|
123
|
+
params: {protected: "gender", max_diff: 0.1, min_ratio: 0.8}
|
|
124
|
+
- type: data_invariant
|
|
125
|
+
params: {expected_columns: [age, income], max_null_ratio: 0.02}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Multi-framework support
|
|
129
|
+
|
|
130
|
+
`TestContext` talks to models through a small adapter interface
|
|
131
|
+
(`modeltest.wrappers`). Out of the box it normalizes:
|
|
132
|
+
|
|
133
|
+
- **scikit-learn** estimators (`predict`, and `predict_proba` when available)
|
|
134
|
+
- **PyTorch** `nn.Module` (`predict` = argmax over logits, `predict_proba` = softmax)
|
|
135
|
+
- **Keras / TensorFlow** models (binary threshold or multiclass argmax)
|
|
136
|
+
|
|
137
|
+
Pass any of these straight to `suite.run(model, ...)`; the right adapter is
|
|
138
|
+
picked automatically. Custom framework? Implement a
|
|
139
|
+
`ModelWrapper` subclass and pass an instance as the model.
|
|
140
|
+
|
|
141
|
+
**scikit-learn `Pipeline`s** (feature engineering + model) work out of the box:
|
|
142
|
+
the suite predicts through the whole pipeline, `model_features` filters
|
|
143
|
+
validation data to the pipeline's raw input columns, and the SHAP-based
|
|
144
|
+
explainability tests explain the final estimator against the engineered
|
|
145
|
+
features (e.g. `num__age`, `cat__cat_a`).
|
|
146
|
+
|
|
147
|
+
## Built-in test types
|
|
148
|
+
|
|
149
|
+
| `type` (YAML) | Class | Checks |
|
|
150
|
+
|---------------|-------|--------|
|
|
151
|
+
| `minimum_accuracy` | `MinimumAccuracyTest` | Global metric above threshold |
|
|
152
|
+
| `group_performance` | `GroupPerformanceTest` | Metric above threshold per subgroup |
|
|
153
|
+
| `confidence_threshold` | `ConfidenceThresholdTest` | Metric floor via bootstrap CI (lower/upper bound) |
|
|
154
|
+
| `robustness` | `RobustnessTest` | Performance under feature noise |
|
|
155
|
+
| `data_invariant` | `DataInvariantTest` | Expected columns / null ratios |
|
|
156
|
+
| `no_null` | `NoNullTest` | No missing values |
|
|
157
|
+
| `data_drift` | `DataDriftTest` | PSI between train & validation |
|
|
158
|
+
| `ks` | `KSTest` | KS p-value per column |
|
|
159
|
+
| `equal_opportunity` | `EqualOpportunityTest` | Balanced TPR across protected groups |
|
|
160
|
+
| `statistical_parity` | `StatisticalParityTest` | Balanced selection rate (+ 4/5ths rule) |
|
|
161
|
+
| `feature_dominance` | `FeatureDominanceTest` | No single feature dominates attribution |
|
|
162
|
+
| `top_features` | `TopFeaturesTest` | Top-K attributed features are expected |
|
|
163
|
+
|
|
164
|
+
> Explainability tests use SHAP. Install with `pip install modeltest[explain]`
|
|
165
|
+
> (or `modeltest`'s `explain` extra). You can also pass your own explainer
|
|
166
|
+
> callable to any explainability test.
|
|
167
|
+
|
|
168
|
+
## Custom tests
|
|
169
|
+
|
|
170
|
+
Any class subclassing `ModelTest` can be referenced from a YAML suite
|
|
171
|
+
directly, by dotted import path — no registration required:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
suite:
|
|
175
|
+
name: "Income Model"
|
|
176
|
+
tests:
|
|
177
|
+
- type: minimum_accuracy
|
|
178
|
+
params: {threshold: 0.85}
|
|
179
|
+
- type: myproject.custom_tests:ZeroPredictionShareTest
|
|
180
|
+
params: {min_positive_share: 0.01}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Both `module.path:Class` and `module.path.Class` work. The module is looked
|
|
184
|
+
up on the import path (your current working directory is added automatically).
|
|
185
|
+
Programmatic registration is also available for short, friendlier names:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from modeltest import register
|
|
189
|
+
register("zero_share", ZeroPredictionShareTest)
|
|
190
|
+
# ...now use `type: zero_share` in YAML
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## MLflow integration
|
|
194
|
+
|
|
195
|
+
Log a finished validation run into [MLflow](https://mlflow.org/) as an
|
|
196
|
+
experiment run — one param per test, one metric per numeric value, and the
|
|
197
|
+
full JSON report saved as an artifact.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
pip install modeltest[mlflow]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import mlflow
|
|
205
|
+
from modeltest import ModelSuite
|
|
206
|
+
from modeltest.integrations.mlflow import log_suite_result
|
|
207
|
+
from modeltest.scenarios import MinimumAccuracyTest
|
|
208
|
+
|
|
209
|
+
suite = ModelSuite(name="fraud-v2")
|
|
210
|
+
suite.add_test(MinimumAccuracyTest(threshold=0.85))
|
|
211
|
+
result = suite.run(model, X_val, y_val)
|
|
212
|
+
|
|
213
|
+
with mlflow.start_run():
|
|
214
|
+
log_suite_result(result)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`log_suite_result` also supports a `run_id` parameter for logging into a
|
|
218
|
+
specific (possibly already-finished) run, plus optional `param_prefix` /
|
|
219
|
+
`metric_prefix` to namespace the logged names.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
Install dev tools and run the quality gates:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
make install # pip install -e ".[dev]"
|
|
227
|
+
make lint # ruff check
|
|
228
|
+
make format # ruff format
|
|
229
|
+
make test # pytest (with 80% coverage gate)
|
|
230
|
+
make precommit # install git pre-commit hooks (lint+format)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Continuous integration mirrors these gates: `lint`, `test-library` (with
|
|
234
|
+
coverage) and `validate-model` all run on every push / PR
|
|
235
|
+
([.github/workflows/validate.yml](.github/workflows/validate.yml)).
|
|
236
|
+
|
|
237
|
+
## CI/CD
|
|
238
|
+
|
|
239
|
+
A ready-to-use [GitHub Actions workflow](.github/workflows/validate.yml) runs the
|
|
240
|
+
library's own tests and validates your model contract on every push / PR. It
|
|
241
|
+
publishes both reports (as JUnit) and fails the pipeline if the model doesn't
|
|
242
|
+
meet its contract.
|
|
243
|
+
|
|
244
|
+
The model job trains a sample model and validates it:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
modeltest validate --suite examples/suite.yaml --model examples/model.pkl \
|
|
248
|
+
--data examples/validation.csv --target target --train-data examples/train.csv
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
To point it at your real artifacts, update the `validate-model` job's `run` step paths.
|
|
252
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# modeltest
|
|
2
|
+
|
|
3
|
+
> If you test your code, why not your model?
|
|
4
|
+
|
|
5
|
+
`modeltest` is a unit-testing framework for machine learning models. It lets you
|
|
6
|
+
define contracts for model quality, robustness, fairness, and data invariants,
|
|
7
|
+
and run them automatically in your CI/CD pipeline — just like `pytest` for code.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from modeltest import ModelSuite
|
|
13
|
+
from modeltest.scenarios import MinimumAccuracyTest, GroupPerformanceTest
|
|
14
|
+
|
|
15
|
+
suite = ModelSuite(name="Fraud Detection")
|
|
16
|
+
suite.add_test(MinimumAccuracyTest(threshold=0.85))
|
|
17
|
+
suite.add_test(GroupPerformanceTest(metric="accuracy", threshold=0.8, group_col="gender"))
|
|
18
|
+
|
|
19
|
+
result = suite.run(model, X_val, y_val, model_name="fraud_rf")
|
|
20
|
+
print(result.report(style="table"))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## CLI
|
|
24
|
+
|
|
25
|
+
After training, validate a model contract from the command line:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Declarative suite (recommended)
|
|
29
|
+
modeltest validate \
|
|
30
|
+
--suite suite.yaml \
|
|
31
|
+
--model model.pkl \
|
|
32
|
+
--data validation.csv \
|
|
33
|
+
--target target \
|
|
34
|
+
--train-data train.csv \
|
|
35
|
+
--output report.xml
|
|
36
|
+
|
|
37
|
+
# Python suite (suite.py exposing `suite`)
|
|
38
|
+
modeltest validate --suite suite.py --model model.pkl --data validation.csv --target target
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Extras:
|
|
42
|
+
- `--output report.xml` writes JUnit XML for CI reporters.
|
|
43
|
+
- `--train-data` enables the drift tests (they compare train vs. validation distributions).
|
|
44
|
+
- Exit code is `1` if any test fails, `0` otherwise.
|
|
45
|
+
|
|
46
|
+
## Prediction caching
|
|
47
|
+
|
|
48
|
+
Within a single `suite.run(...)`, predictions are computed once and reused
|
|
49
|
+
across every test. `TestContext.predict()` caches by a content hash of the
|
|
50
|
+
input, so tests predicting on the *same* data (`MinimumAccuracyTest`,
|
|
51
|
+
`GroupPerformanceTest`, the fairness tests, ...) each reuse the result instead
|
|
52
|
+
of re-running the model. Perturbed inputs (e.g. the robustness test's noisy
|
|
53
|
+
copy) get their own cache entry, so caching never compromises correctness.
|
|
54
|
+
|
|
55
|
+
Disable it if you need a fresh prediction every call:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
ctx = TestContext(model=model, X_val=X_val, y_val=y_val, cache_predictions=False)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## YAML suites
|
|
62
|
+
|
|
63
|
+
Define your contract declaratively — no code needed:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
suite:
|
|
67
|
+
name: "Credit Scoring Model"
|
|
68
|
+
tests:
|
|
69
|
+
- type: minimum_accuracy
|
|
70
|
+
params: {threshold: 0.85}
|
|
71
|
+
- type: group_performance
|
|
72
|
+
params: {metric: accuracy, threshold: 0.8, group_col: "gender"}
|
|
73
|
+
- type: robustness
|
|
74
|
+
params: {noise_std: 0.01, max_drop: 0.03}
|
|
75
|
+
- type: data_drift
|
|
76
|
+
params: {features: [age, income], max_psi: 0.15}
|
|
77
|
+
- type: equal_opportunity
|
|
78
|
+
params: {protected: "gender", max_diff: 0.1}
|
|
79
|
+
- type: statistical_parity
|
|
80
|
+
params: {protected: "gender", max_diff: 0.1, min_ratio: 0.8}
|
|
81
|
+
- type: data_invariant
|
|
82
|
+
params: {expected_columns: [age, income], max_null_ratio: 0.02}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Multi-framework support
|
|
86
|
+
|
|
87
|
+
`TestContext` talks to models through a small adapter interface
|
|
88
|
+
(`modeltest.wrappers`). Out of the box it normalizes:
|
|
89
|
+
|
|
90
|
+
- **scikit-learn** estimators (`predict`, and `predict_proba` when available)
|
|
91
|
+
- **PyTorch** `nn.Module` (`predict` = argmax over logits, `predict_proba` = softmax)
|
|
92
|
+
- **Keras / TensorFlow** models (binary threshold or multiclass argmax)
|
|
93
|
+
|
|
94
|
+
Pass any of these straight to `suite.run(model, ...)`; the right adapter is
|
|
95
|
+
picked automatically. Custom framework? Implement a
|
|
96
|
+
`ModelWrapper` subclass and pass an instance as the model.
|
|
97
|
+
|
|
98
|
+
**scikit-learn `Pipeline`s** (feature engineering + model) work out of the box:
|
|
99
|
+
the suite predicts through the whole pipeline, `model_features` filters
|
|
100
|
+
validation data to the pipeline's raw input columns, and the SHAP-based
|
|
101
|
+
explainability tests explain the final estimator against the engineered
|
|
102
|
+
features (e.g. `num__age`, `cat__cat_a`).
|
|
103
|
+
|
|
104
|
+
## Built-in test types
|
|
105
|
+
|
|
106
|
+
| `type` (YAML) | Class | Checks |
|
|
107
|
+
|---------------|-------|--------|
|
|
108
|
+
| `minimum_accuracy` | `MinimumAccuracyTest` | Global metric above threshold |
|
|
109
|
+
| `group_performance` | `GroupPerformanceTest` | Metric above threshold per subgroup |
|
|
110
|
+
| `confidence_threshold` | `ConfidenceThresholdTest` | Metric floor via bootstrap CI (lower/upper bound) |
|
|
111
|
+
| `robustness` | `RobustnessTest` | Performance under feature noise |
|
|
112
|
+
| `data_invariant` | `DataInvariantTest` | Expected columns / null ratios |
|
|
113
|
+
| `no_null` | `NoNullTest` | No missing values |
|
|
114
|
+
| `data_drift` | `DataDriftTest` | PSI between train & validation |
|
|
115
|
+
| `ks` | `KSTest` | KS p-value per column |
|
|
116
|
+
| `equal_opportunity` | `EqualOpportunityTest` | Balanced TPR across protected groups |
|
|
117
|
+
| `statistical_parity` | `StatisticalParityTest` | Balanced selection rate (+ 4/5ths rule) |
|
|
118
|
+
| `feature_dominance` | `FeatureDominanceTest` | No single feature dominates attribution |
|
|
119
|
+
| `top_features` | `TopFeaturesTest` | Top-K attributed features are expected |
|
|
120
|
+
|
|
121
|
+
> Explainability tests use SHAP. Install with `pip install modeltest[explain]`
|
|
122
|
+
> (or `modeltest`'s `explain` extra). You can also pass your own explainer
|
|
123
|
+
> callable to any explainability test.
|
|
124
|
+
|
|
125
|
+
## Custom tests
|
|
126
|
+
|
|
127
|
+
Any class subclassing `ModelTest` can be referenced from a YAML suite
|
|
128
|
+
directly, by dotted import path — no registration required:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
suite:
|
|
132
|
+
name: "Income Model"
|
|
133
|
+
tests:
|
|
134
|
+
- type: minimum_accuracy
|
|
135
|
+
params: {threshold: 0.85}
|
|
136
|
+
- type: myproject.custom_tests:ZeroPredictionShareTest
|
|
137
|
+
params: {min_positive_share: 0.01}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Both `module.path:Class` and `module.path.Class` work. The module is looked
|
|
141
|
+
up on the import path (your current working directory is added automatically).
|
|
142
|
+
Programmatic registration is also available for short, friendlier names:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from modeltest import register
|
|
146
|
+
register("zero_share", ZeroPredictionShareTest)
|
|
147
|
+
# ...now use `type: zero_share` in YAML
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## MLflow integration
|
|
151
|
+
|
|
152
|
+
Log a finished validation run into [MLflow](https://mlflow.org/) as an
|
|
153
|
+
experiment run — one param per test, one metric per numeric value, and the
|
|
154
|
+
full JSON report saved as an artifact.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install modeltest[mlflow]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
import mlflow
|
|
162
|
+
from modeltest import ModelSuite
|
|
163
|
+
from modeltest.integrations.mlflow import log_suite_result
|
|
164
|
+
from modeltest.scenarios import MinimumAccuracyTest
|
|
165
|
+
|
|
166
|
+
suite = ModelSuite(name="fraud-v2")
|
|
167
|
+
suite.add_test(MinimumAccuracyTest(threshold=0.85))
|
|
168
|
+
result = suite.run(model, X_val, y_val)
|
|
169
|
+
|
|
170
|
+
with mlflow.start_run():
|
|
171
|
+
log_suite_result(result)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`log_suite_result` also supports a `run_id` parameter for logging into a
|
|
175
|
+
specific (possibly already-finished) run, plus optional `param_prefix` /
|
|
176
|
+
`metric_prefix` to namespace the logged names.
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
Install dev tools and run the quality gates:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
make install # pip install -e ".[dev]"
|
|
184
|
+
make lint # ruff check
|
|
185
|
+
make format # ruff format
|
|
186
|
+
make test # pytest (with 80% coverage gate)
|
|
187
|
+
make precommit # install git pre-commit hooks (lint+format)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Continuous integration mirrors these gates: `lint`, `test-library` (with
|
|
191
|
+
coverage) and `validate-model` all run on every push / PR
|
|
192
|
+
([.github/workflows/validate.yml](.github/workflows/validate.yml)).
|
|
193
|
+
|
|
194
|
+
## CI/CD
|
|
195
|
+
|
|
196
|
+
A ready-to-use [GitHub Actions workflow](.github/workflows/validate.yml) runs the
|
|
197
|
+
library's own tests and validates your model contract on every push / PR. It
|
|
198
|
+
publishes both reports (as JUnit) and fails the pipeline if the model doesn't
|
|
199
|
+
meet its contract.
|
|
200
|
+
|
|
201
|
+
The model job trains a sample model and validates it:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
modeltest validate --suite examples/suite.yaml --model examples/model.pkl \
|
|
205
|
+
--data examples/validation.csv --target target --train-data examples/train.csv
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
To point it at your real artifacts, update the `validate-model` job's `run` step paths.
|
|
209
|
+
|
|
@@ -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
|
+
]
|
|
@@ -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())
|