melite 0.2.0__tar.gz → 0.2.2__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.
- {melite-0.2.0 → melite-0.2.2}/CHANGELOG.md +38 -0
- {melite-0.2.0 → melite-0.2.2}/CITATION.cff +2 -2
- {melite-0.2.0 → melite-0.2.2}/PKG-INFO +24 -15
- {melite-0.2.0 → melite-0.2.2}/README.md +22 -14
- {melite-0.2.0 → melite-0.2.2}/melite/config.py +15 -10
- {melite-0.2.0 → melite-0.2.2}/melite/export_best_model.py +45 -10
- {melite-0.2.0 → melite-0.2.2}/melite/main.py +9 -1
- {melite-0.2.0 → melite-0.2.2}/melite/model_training.py +25 -3
- {melite-0.2.0 → melite-0.2.2}/melite/version.py +1 -1
- {melite-0.2.0 → melite-0.2.2}/pyproject.toml +1 -0
- melite-0.2.2/scripts/smoke_install_wheel.py +221 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_config.py +45 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_export.py +163 -4
- melite-0.2.2/tests/test_model_training.py +128 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_predict.py +21 -0
- {melite-0.2.0 → melite-0.2.2}/.gitignore +0 -0
- {melite-0.2.0 → melite-0.2.2}/COPYING +0 -0
- {melite-0.2.0 → melite-0.2.2}/COPYING.LESSER +0 -0
- {melite-0.2.0 → melite-0.2.2}/LICENSE +0 -0
- {melite-0.2.0 → melite-0.2.2}/environment.yml +0 -0
- {melite-0.2.0 → melite-0.2.2}/examples/example_config.toml +0 -0
- {melite-0.2.0 → melite-0.2.2}/examples/generate_sample_data.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/__init__.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/cli.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/config_default.toml +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/load_dataset.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/plot_metrics.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/predict.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/melite/result_manager.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/__init__.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/conftest.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_cli.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_examples.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_load_dataset.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_main.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_plot_metrics.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_public_api.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_result_manager.py +0 -0
- {melite-0.2.0 → melite-0.2.2}/tests/test_version.py +0 -0
|
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [v0.2.2] - 2026-05-28
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- SVC is now trained as a `StandardScaler` -> `SVC` sklearn `Pipeline`.
|
|
14
|
+
- Scaling is applied only to SVC; `RandomForestClassifier` and
|
|
15
|
+
`XGBClassifier` remain unscaled direct estimators.
|
|
16
|
+
- The full SVC search grid now includes a linear kernel with `svc__C` values
|
|
17
|
+
`[0.01, 0.1, 1, 10]`.
|
|
18
|
+
- Exported SVC models now preserve the same `StandardScaler` -> `SVC`
|
|
19
|
+
pipeline used during benchmarking.
|
|
20
|
+
|
|
21
|
+
### Compatibility
|
|
22
|
+
- Legacy export compatibility is preserved for older SVC parameter
|
|
23
|
+
dictionaries that use unprefixed keys such as `C` and `kernel` instead of
|
|
24
|
+
pipeline keys such as `svc__C` and `svc__kernel`.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## [v0.2.1] - 2026-05-27
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `[models].active` / `ACTIVE_MODELS` now controls which model families are
|
|
32
|
+
trained during benchmarking.
|
|
33
|
+
- `melite export` now uses strict dataset loading for registry-based datasets
|
|
34
|
+
and no longer falls back to `arr.files[0]` when an `.npz` file lacks `X`.
|
|
35
|
+
- Added an installed-wheel smoke test that builds the wheel, installs it
|
|
36
|
+
outside the repository checkout, runs a toy `[datasets.toy]` smoke benchmark,
|
|
37
|
+
exports row 0 non-interactively, and verifies generated artifacts.
|
|
38
|
+
|
|
39
|
+
### Compatibility
|
|
40
|
+
- The top-level public API remains unchanged:
|
|
41
|
+
`Config`, `load_datasets`, `plot_cv_distributions`, `predict`, and
|
|
42
|
+
`__version__`.
|
|
43
|
+
- Legacy `reduction_type` + `level` export rows remain supported, but
|
|
44
|
+
individual legacy `.npz` files must now contain an explicit `X` array.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
10
48
|
## [v0.2.0] - 2026-05-26
|
|
11
49
|
|
|
12
50
|
### Added
|
|
@@ -2,8 +2,8 @@ cff-version: 1.2.0
|
|
|
2
2
|
message: "If you use this software, please cite it as below."
|
|
3
3
|
type: software
|
|
4
4
|
title: "MELITE: Multi-model Evaluation and Learning for Inference-ready Tabular Experiments"
|
|
5
|
-
version: "0.2.
|
|
6
|
-
date-released: "2026-05-
|
|
5
|
+
version: "0.2.2"
|
|
6
|
+
date-released: "2026-05-28"
|
|
7
7
|
authors:
|
|
8
8
|
- family-names: "Contreras-Torres"
|
|
9
9
|
given-names: "Flavio F."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: melite
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Tabular classification benchmarking toolkit for model selection, repeated stratified cross-validation, final model export, and artifact-based inference.
|
|
5
5
|
Project-URL: Homepage, https://github.com/NanoBiostructuresRG/melite
|
|
6
6
|
Project-URL: Repository, https://github.com/NanoBiostructuresRG/melite
|
|
@@ -28,6 +28,7 @@ Requires-Dist: scikit-learn>=1.2
|
|
|
28
28
|
Requires-Dist: xgboost>=1.7
|
|
29
29
|
Provides-Extra: dev
|
|
30
30
|
Requires-Dist: build>=1.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: hatchling>=1.18; extra == 'dev'
|
|
31
32
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
33
|
Requires-Dist: twine>=4.0; extra == 'dev'
|
|
33
34
|
Provides-Extra: docs
|
|
@@ -40,7 +41,7 @@ Description-Content-Type: text/markdown
|
|
|
40
41
|
|
|
41
42
|
[](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
|
|
42
43
|
[](LICENSE)
|
|
43
|
-
[]()
|
|
44
45
|
[]()
|
|
45
46
|
|
|
46
47
|
**MELITE** is a pre-stable Python toolkit for tabular classification
|
|
@@ -59,7 +60,7 @@ Project: MELITE
|
|
|
59
60
|
PyPI distribution: melite
|
|
60
61
|
Import package: melite
|
|
61
62
|
CLI: melite
|
|
62
|
-
Version: 0.2.
|
|
63
|
+
Version: 0.2.2
|
|
63
64
|
License: LGPL-3.0-or-later
|
|
64
65
|
Status: alpha / pre-stable
|
|
65
66
|
```
|
|
@@ -171,6 +172,19 @@ Registered datasets are loaded strictly: missing files, missing `X`, non-2D or
|
|
|
171
172
|
non-numeric `X`, length mismatches, and embedded `y` mismatches fail the run.
|
|
172
173
|
Legacy `[benchmark].reduction_types` and `levels` configs are still accepted
|
|
173
174
|
and are normalized into equivalent dataset entries such as `PCA70` and `UMAP90`.
|
|
175
|
+
|
|
176
|
+
Model families are controlled by `[models].active`:
|
|
177
|
+
|
|
178
|
+
```toml
|
|
179
|
+
[models]
|
|
180
|
+
active = ["svc", "rf", "xgb"]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Remove a key to skip that family during training. Valid keys are `svc`, `rf`,
|
|
184
|
+
and `xgb`.
|
|
185
|
+
|
|
186
|
+
SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn pipeline.
|
|
187
|
+
Random Forest and XGBoost are trained as unscaled estimators.
|
|
174
188
|
|
|
175
189
|
## CLI
|
|
176
190
|
|
|
@@ -203,7 +217,7 @@ from melite import __version__
|
|
|
203
217
|
```
|
|
204
218
|
|
|
205
219
|
Modules not listed above are importable directly but are not part of the public
|
|
206
|
-
contract and may change before
|
|
220
|
+
contract and may change before 1.0.
|
|
207
221
|
|
|
208
222
|
## Input Format
|
|
209
223
|
|
|
@@ -234,13 +248,14 @@ Local inputs and generated artifacts such as `raw/`, `data/`, `output/`,
|
|
|
234
248
|
|
|
235
249
|
## Validation
|
|
236
250
|
|
|
237
|
-
The current `dev/v0.2.
|
|
251
|
+
The current `dev/v0.2.2` branch targets:
|
|
238
252
|
|
|
239
253
|
```bash
|
|
240
254
|
python -m pytest tests/ -v --basetemp=.review_pytest_tmp -o cache_dir=.review_pytest_cache
|
|
241
255
|
mkdocs build --strict
|
|
242
|
-
python -m build
|
|
256
|
+
python -m build --no-isolation
|
|
243
257
|
python -m twine check dist/*
|
|
258
|
+
python scripts/smoke_install_wheel.py
|
|
244
259
|
melite --help
|
|
245
260
|
melite run --help
|
|
246
261
|
melite export --help
|
|
@@ -253,20 +268,14 @@ If you use MELITE in your research, please cite it using the metadata in
|
|
|
253
268
|
[CITATION.cff](CITATION.cff).
|
|
254
269
|
|
|
255
270
|
```text
|
|
256
|
-
Contreras-Torres, F. F., & Murrieta, A. C. (2026). MELITE: Multi-model
|
|
257
|
-
Evaluation and Learning for Inference-ready Tabular Experiments (0.1.11).
|
|
258
|
-
Tecnologico de Monterrey. https://github.com/NanoBiostructuresRG/melite
|
|
271
|
+
Contreras-Torres, F. F., & Murrieta, A. C. (2026). MELITE: Multi-model Evaluation and Learning for Inference-ready Tabular Experiments. Zenodo. https://doi.org/10.5281/zenodo.20382752
|
|
259
272
|
```
|
|
260
273
|
|
|
261
274
|
## Authors
|
|
262
275
|
|
|
263
|
-
Developed by **Flavio F. Contreras-Torres
|
|
264
|
-
|
|
265
|
-
Tecnologico de Monterrey
|
|
266
|
-
|
|
267
|
-
Co-author: **Ana C. Murrieta**
|
|
276
|
+
Developed by **Flavio F. Contreras-Torres**. Tecnologico de Monterrey
|
|
268
277
|
|
|
269
|
-
Tecnologico de Monterrey
|
|
278
|
+
Co-author: **Ana C. Murrieta**. Tecnologico de Monterrey
|
|
270
279
|
|
|
271
280
|
## License
|
|
272
281
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
7
7
|
|
|
8
8
|
**MELITE** is a pre-stable Python toolkit for tabular classification
|
|
@@ -21,7 +21,7 @@ Project: MELITE
|
|
|
21
21
|
PyPI distribution: melite
|
|
22
22
|
Import package: melite
|
|
23
23
|
CLI: melite
|
|
24
|
-
Version: 0.2.
|
|
24
|
+
Version: 0.2.2
|
|
25
25
|
License: LGPL-3.0-or-later
|
|
26
26
|
Status: alpha / pre-stable
|
|
27
27
|
```
|
|
@@ -133,6 +133,19 @@ Registered datasets are loaded strictly: missing files, missing `X`, non-2D or
|
|
|
133
133
|
non-numeric `X`, length mismatches, and embedded `y` mismatches fail the run.
|
|
134
134
|
Legacy `[benchmark].reduction_types` and `levels` configs are still accepted
|
|
135
135
|
and are normalized into equivalent dataset entries such as `PCA70` and `UMAP90`.
|
|
136
|
+
|
|
137
|
+
Model families are controlled by `[models].active`:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
[models]
|
|
141
|
+
active = ["svc", "rf", "xgb"]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Remove a key to skip that family during training. Valid keys are `svc`, `rf`,
|
|
145
|
+
and `xgb`.
|
|
146
|
+
|
|
147
|
+
SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn pipeline.
|
|
148
|
+
Random Forest and XGBoost are trained as unscaled estimators.
|
|
136
149
|
|
|
137
150
|
## CLI
|
|
138
151
|
|
|
@@ -165,7 +178,7 @@ from melite import __version__
|
|
|
165
178
|
```
|
|
166
179
|
|
|
167
180
|
Modules not listed above are importable directly but are not part of the public
|
|
168
|
-
contract and may change before
|
|
181
|
+
contract and may change before 1.0.
|
|
169
182
|
|
|
170
183
|
## Input Format
|
|
171
184
|
|
|
@@ -196,13 +209,14 @@ Local inputs and generated artifacts such as `raw/`, `data/`, `output/`,
|
|
|
196
209
|
|
|
197
210
|
## Validation
|
|
198
211
|
|
|
199
|
-
The current `dev/v0.2.
|
|
212
|
+
The current `dev/v0.2.2` branch targets:
|
|
200
213
|
|
|
201
214
|
```bash
|
|
202
215
|
python -m pytest tests/ -v --basetemp=.review_pytest_tmp -o cache_dir=.review_pytest_cache
|
|
203
216
|
mkdocs build --strict
|
|
204
|
-
python -m build
|
|
217
|
+
python -m build --no-isolation
|
|
205
218
|
python -m twine check dist/*
|
|
219
|
+
python scripts/smoke_install_wheel.py
|
|
206
220
|
melite --help
|
|
207
221
|
melite run --help
|
|
208
222
|
melite export --help
|
|
@@ -215,20 +229,14 @@ If you use MELITE in your research, please cite it using the metadata in
|
|
|
215
229
|
[CITATION.cff](CITATION.cff).
|
|
216
230
|
|
|
217
231
|
```text
|
|
218
|
-
Contreras-Torres, F. F., & Murrieta, A. C. (2026). MELITE: Multi-model
|
|
219
|
-
Evaluation and Learning for Inference-ready Tabular Experiments (0.1.11).
|
|
220
|
-
Tecnologico de Monterrey. https://github.com/NanoBiostructuresRG/melite
|
|
232
|
+
Contreras-Torres, F. F., & Murrieta, A. C. (2026). MELITE: Multi-model Evaluation and Learning for Inference-ready Tabular Experiments. Zenodo. https://doi.org/10.5281/zenodo.20382752
|
|
221
233
|
```
|
|
222
234
|
|
|
223
235
|
## Authors
|
|
224
236
|
|
|
225
|
-
Developed by **Flavio F. Contreras-Torres
|
|
226
|
-
|
|
227
|
-
Tecnologico de Monterrey
|
|
228
|
-
|
|
229
|
-
Co-author: **Ana C. Murrieta**
|
|
237
|
+
Developed by **Flavio F. Contreras-Torres**. Tecnologico de Monterrey
|
|
230
238
|
|
|
231
|
-
Tecnologico de Monterrey
|
|
239
|
+
Co-author: **Ana C. Murrieta**. Tecnologico de Monterrey
|
|
232
240
|
|
|
233
241
|
## License
|
|
234
242
|
|
|
@@ -197,8 +197,8 @@ class Config:
|
|
|
197
197
|
return [
|
|
198
198
|
{
|
|
199
199
|
"model": ["svc"],
|
|
200
|
-
"
|
|
201
|
-
"
|
|
200
|
+
"svc__kernel": ["linear"],
|
|
201
|
+
"svc__C": [1],
|
|
202
202
|
},
|
|
203
203
|
{
|
|
204
204
|
"model": ["rf"],
|
|
@@ -223,17 +223,22 @@ class Config:
|
|
|
223
223
|
return [
|
|
224
224
|
{
|
|
225
225
|
"model": ["svc"],
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"coef0": [0.0, 0.1, 0.02, 0.6, 0.8, 1],
|
|
229
|
-
"gamma": [0.001, 0.002, 0.004, 0.008, 0.01, 0.02, 0.04, 0.08, 0.1, 0.2],
|
|
230
|
-
"degree": [3, 4, 5],
|
|
226
|
+
"svc__kernel": ["linear"],
|
|
227
|
+
"svc__C": [0.01, 0.1, 1, 10],
|
|
231
228
|
},
|
|
232
229
|
{
|
|
233
230
|
"model": ["svc"],
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
231
|
+
"svc__kernel": ["poly"],
|
|
232
|
+
"svc__C": [0.01, 0.1, 1, 10],
|
|
233
|
+
"svc__coef0": [0.0, 0.1, 0.2, 0.6, 0.8, 1],
|
|
234
|
+
"svc__gamma": [0.001, 0.002, 0.004, 0.008, 0.01, 0.02, 0.04, 0.08, 0.1, 0.2],
|
|
235
|
+
"svc__degree": [3, 4, 5],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"model": ["svc"],
|
|
239
|
+
"svc__kernel": ["rbf"],
|
|
240
|
+
"svc__C": [0.01, 0.02, 0.1, 0.2, 1, 2, 10, 20],
|
|
241
|
+
"svc__gamma": [0.001, 0.002, 0.004, 0.008, 0.01, 0.02, 0.04, 0.08, 0.1, 0.2],
|
|
237
242
|
},
|
|
238
243
|
{
|
|
239
244
|
"model": ["rf"],
|
|
@@ -20,10 +20,12 @@ import joblib
|
|
|
20
20
|
import numpy as np
|
|
21
21
|
import pandas as pd
|
|
22
22
|
from .config import Config
|
|
23
|
-
from .load_dataset import _load_one_dataset
|
|
23
|
+
from .load_dataset import load_datasets, _load_one_dataset
|
|
24
24
|
from .plot_metrics import plot_cv_distributions
|
|
25
25
|
from sklearn.ensemble import RandomForestClassifier
|
|
26
26
|
from sklearn.model_selection import cross_validate, RepeatedStratifiedKFold
|
|
27
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
28
|
+
from sklearn.preprocessing import StandardScaler
|
|
27
29
|
from sklearn.svm import SVC
|
|
28
30
|
from xgboost import XGBClassifier
|
|
29
31
|
|
|
@@ -32,7 +34,6 @@ __all__ = ["Finalizer"]
|
|
|
32
34
|
logger = logging.getLogger(__name__)
|
|
33
35
|
|
|
34
36
|
MODEL_MAP = {
|
|
35
|
-
"SVC": SVC,
|
|
36
37
|
"RandomForestClassifier": RandomForestClassifier,
|
|
37
38
|
"XGBClassifier": XGBClassifier,
|
|
38
39
|
}
|
|
@@ -70,20 +71,19 @@ class DatasetLoader:
|
|
|
70
71
|
def load_row(self, row: pd.Series) -> Tuple[np.ndarray, np.ndarray]:
|
|
71
72
|
"""Load the dataset referenced by a result row.
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
Dataset-registry result rows are resolved by their ``dataset`` id in
|
|
74
75
|
``cfg.DATASETS``. Older result rows without ``dataset`` fall back to
|
|
75
76
|
the legacy ``reduction_type`` + ``level`` lookup.
|
|
76
77
|
"""
|
|
77
78
|
if "dataset" in row and _has_value(row.get("dataset")):
|
|
78
79
|
dataset_id = str(row.get("dataset"))
|
|
79
80
|
try:
|
|
80
|
-
|
|
81
|
+
dataset = load_datasets(self._cfg)[dataset_id]
|
|
81
82
|
except KeyError as exc:
|
|
82
83
|
raise KeyError(
|
|
83
84
|
f"Dataset '{dataset_id}' from results.csv is not registered "
|
|
84
85
|
"in cfg.DATASETS."
|
|
85
86
|
) from exc
|
|
86
|
-
dataset = _load_one_dataset(dataset_id, spec)
|
|
87
87
|
return dataset["X"], dataset["y"]
|
|
88
88
|
|
|
89
89
|
return self.load(row.reduction_type, int(row.level))
|
|
@@ -129,9 +129,19 @@ class DatasetLoader:
|
|
|
129
129
|
fp = self._data_root / f"{reduction}{level}.npz"
|
|
130
130
|
if not fp.exists():
|
|
131
131
|
return None
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
dataset_id = f"{reduction}{level}"
|
|
133
|
+
spec = {
|
|
134
|
+
"path": fp,
|
|
135
|
+
"label_path": Path(self._cfg.PATHS["INPUT"]) / "labels.npy",
|
|
136
|
+
"metadata": {
|
|
137
|
+
"family": "dimensionality",
|
|
138
|
+
"method": reduction,
|
|
139
|
+
"level": level,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
dataset = _load_one_dataset(dataset_id, spec)
|
|
143
|
+
self._labels = dataset["y"]
|
|
144
|
+
return dataset["X"]
|
|
135
145
|
|
|
136
146
|
def _try_aggregated_file(self, reduction: str, level: int) -> np.ndarray | None:
|
|
137
147
|
fp = self._data_root / f"{reduction}s.npz"
|
|
@@ -142,7 +152,23 @@ class DatasetLoader:
|
|
|
142
152
|
key = pattern.format(rtype=reduction, lvl=level)
|
|
143
153
|
if key in arr:
|
|
144
154
|
self._ensure_labels()
|
|
145
|
-
|
|
155
|
+
X = arr[key]
|
|
156
|
+
if X.ndim != 2:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Legacy dataset '{reduction}{level}' X must be 2D; "
|
|
159
|
+
f"got shape {X.shape}."
|
|
160
|
+
)
|
|
161
|
+
if not np.issubdtype(X.dtype, np.number):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"Legacy dataset '{reduction}{level}' X must be numeric; "
|
|
164
|
+
f"got dtype {X.dtype}."
|
|
165
|
+
)
|
|
166
|
+
if len(self._labels) != X.shape[0]:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Legacy dataset '{reduction}{level}' X/y length mismatch: "
|
|
169
|
+
f"X has {X.shape[0]} rows, y has {len(self._labels)} labels."
|
|
170
|
+
)
|
|
171
|
+
return X
|
|
146
172
|
raise KeyError(f"Level {level} not found inside {fp.name}.")
|
|
147
173
|
|
|
148
174
|
def _ensure_labels(self) -> None:
|
|
@@ -307,7 +333,16 @@ class Finalizer:
|
|
|
307
333
|
def _build_model(name: str, serialised_params: str) -> Any:
|
|
308
334
|
params = ast.literal_eval(serialised_params)
|
|
309
335
|
if name == "SVC":
|
|
310
|
-
|
|
336
|
+
model = SklearnPipeline([
|
|
337
|
+
("scaler", StandardScaler()),
|
|
338
|
+
("svc", SVC()),
|
|
339
|
+
])
|
|
340
|
+
svc_params = {
|
|
341
|
+
key if "__" in key else f"svc__{key}": value
|
|
342
|
+
for key, value in params.items()
|
|
343
|
+
}
|
|
344
|
+
svc_params.setdefault("svc__probability", True)
|
|
345
|
+
return model.set_params(**svc_params)
|
|
311
346
|
try:
|
|
312
347
|
return MODEL_MAP[name](**params)
|
|
313
348
|
except KeyError as exc:
|
|
@@ -10,6 +10,8 @@ import logging
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
import numpy as np
|
|
13
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
14
|
+
from sklearn.svm import SVC
|
|
13
15
|
|
|
14
16
|
from .config import Config
|
|
15
17
|
from .load_dataset import load_datasets
|
|
@@ -103,6 +105,12 @@ class Main:
|
|
|
103
105
|
return method
|
|
104
106
|
return None
|
|
105
107
|
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _model_name(model):
|
|
110
|
+
if isinstance(model, SklearnPipeline) and isinstance(model.steps[-1][1], SVC):
|
|
111
|
+
return "SVC"
|
|
112
|
+
return model.__class__.__name__
|
|
113
|
+
|
|
106
114
|
def run(self) -> None:
|
|
107
115
|
"""Execute the benchmarking pipeline for all configured datasets.
|
|
108
116
|
|
|
@@ -145,7 +153,7 @@ class Main:
|
|
|
145
153
|
) = self.pipeline.run(X_train, y_train, reduction_type or dataset_id, level)
|
|
146
154
|
|
|
147
155
|
params = self._clean_params(best_params)
|
|
148
|
-
model_name = best_model
|
|
156
|
+
model_name = self._model_name(best_model)
|
|
149
157
|
|
|
150
158
|
metadata_lines = [
|
|
151
159
|
f"Family: {family}" if family is not None else None,
|
|
@@ -13,6 +13,8 @@ from abc import ABC, abstractmethod
|
|
|
13
13
|
import numpy as np
|
|
14
14
|
from sklearn.ensemble import RandomForestClassifier
|
|
15
15
|
from sklearn.model_selection import GridSearchCV, RepeatedStratifiedKFold, cross_validate
|
|
16
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
17
|
+
from sklearn.preprocessing import StandardScaler
|
|
16
18
|
from sklearn.svm import SVC
|
|
17
19
|
from xgboost import XGBClassifier
|
|
18
20
|
|
|
@@ -82,10 +84,30 @@ class MultiModelTrainer(ModelTrainer):
|
|
|
82
84
|
rs = getattr(self.config, "RANDOM_STATE", 42)
|
|
83
85
|
|
|
84
86
|
self.model_builders = {
|
|
85
|
-
"svc": lambda:
|
|
87
|
+
"svc": lambda: SklearnPipeline([
|
|
88
|
+
("scaler", StandardScaler()),
|
|
89
|
+
("svc", SVC(probability=True, random_state=rs)),
|
|
90
|
+
]),
|
|
86
91
|
"rf": lambda: RandomForestClassifier(random_state=rs, n_jobs=-1),
|
|
87
92
|
"xgb": lambda: XGBClassifier(eval_metric="logloss", random_state=rs, n_jobs=-1),
|
|
88
93
|
}
|
|
94
|
+
self.active_models = self._validate_active_models()
|
|
95
|
+
|
|
96
|
+
def _validate_active_models(self):
|
|
97
|
+
active_models = getattr(self.config, "ACTIVE_MODELS", list(self.model_builders))
|
|
98
|
+
if not active_models:
|
|
99
|
+
raise ValueError("ACTIVE_MODELS must contain at least one model key.")
|
|
100
|
+
|
|
101
|
+
unknown = [model for model in active_models if model not in self.model_builders]
|
|
102
|
+
if unknown:
|
|
103
|
+
unknown_models = ", ".join(unknown)
|
|
104
|
+
valid_models = ", ".join(self.model_builders)
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Unknown active model(s): {unknown_models}. "
|
|
107
|
+
f"Valid model keys are: {valid_models}."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return list(active_models)
|
|
89
111
|
|
|
90
112
|
def _build_cv_strategy(self):
|
|
91
113
|
cv_cfg = self.config.get_cv_config()
|
|
@@ -169,7 +191,7 @@ class MultiModelTrainer(ModelTrainer):
|
|
|
169
191
|
def train_and_select_best_model(self, X_train, y_train, reduction_type, level):
|
|
170
192
|
"""Train all active models and return the best configuration.
|
|
171
193
|
|
|
172
|
-
For each model key
|
|
194
|
+
For each configured active model key, runs grid search followed by
|
|
173
195
|
cross-validation. The model with the highest mean F1-macro is selected.
|
|
174
196
|
|
|
175
197
|
Parameters
|
|
@@ -209,7 +231,7 @@ class MultiModelTrainer(ModelTrainer):
|
|
|
209
231
|
"auc": None, "auc_std": None,
|
|
210
232
|
}
|
|
211
233
|
|
|
212
|
-
for model_name in self.
|
|
234
|
+
for model_name in self.active_models:
|
|
213
235
|
model = self.model_builders[model_name]()
|
|
214
236
|
param_grid = self._filter_param_grid(model_name)
|
|
215
237
|
tuned_model, params = self.perform_grid_search(model, X_train, y_train, param_grid)
|
|
@@ -6,7 +6,7 @@ It is read by ``hatchling`` at build time via ``[tool.hatch.version]``
|
|
|
6
6
|
and imported by ``result_manager`` to stamp generated reports.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "0.2.
|
|
9
|
+
__version__ = "0.2.2"
|
|
10
10
|
PROJECT_NAME = "MELITE"
|
|
11
11
|
PROJECT_VERSION = __version__
|
|
12
12
|
PROJECT_STATUS = "alpha"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
"""Installed-wheel smoke test for the MELITE toy dataset workflow.
|
|
3
|
+
|
|
4
|
+
The script builds a wheel from the current checkout, installs it into a
|
|
5
|
+
temporary virtual environment, creates a tiny strict ``[datasets.toy]``
|
|
6
|
+
configuration outside the repository, runs ``melite run --smoke``, exports row
|
|
7
|
+
0 non-interactively, and verifies the expected artifacts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import csv
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
25
|
+
EXPECTED_API = [
|
|
26
|
+
"Config",
|
|
27
|
+
"load_datasets",
|
|
28
|
+
"plot_cv_distributions",
|
|
29
|
+
"predict",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run(cmd: list[str | Path], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
|
35
|
+
display = " ".join(str(part) for part in cmd)
|
|
36
|
+
print(f"[smoke] {display}")
|
|
37
|
+
subprocess.run([str(part) for part in cmd], cwd=cwd, env=env, check=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _venv_python(venv_dir: Path) -> Path:
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
return venv_dir / "Scripts" / "python.exe"
|
|
43
|
+
return venv_dir / "bin" / "python"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _venv_melite(venv_dir: Path) -> Path:
|
|
47
|
+
if os.name == "nt":
|
|
48
|
+
return venv_dir / "Scripts" / "melite.exe"
|
|
49
|
+
return venv_dir / "bin" / "melite"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_wheel(dist_dir: Path) -> Path:
|
|
53
|
+
if dist_dir.exists():
|
|
54
|
+
shutil.rmtree(dist_dir)
|
|
55
|
+
_run(
|
|
56
|
+
[sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", dist_dir],
|
|
57
|
+
cwd=REPO_ROOT,
|
|
58
|
+
)
|
|
59
|
+
wheels = sorted(dist_dir.glob("melite-*.whl"))
|
|
60
|
+
if len(wheels) != 1:
|
|
61
|
+
raise RuntimeError(f"Expected exactly one MELITE wheel in {dist_dir}, got {wheels}")
|
|
62
|
+
return wheels[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_toy_project(work_dir: Path) -> Path:
|
|
66
|
+
raw_dir = work_dir / "raw"
|
|
67
|
+
data_dir = work_dir / "data"
|
|
68
|
+
output_dir = work_dir / "output"
|
|
69
|
+
raw_dir.mkdir()
|
|
70
|
+
data_dir.mkdir()
|
|
71
|
+
output_dir.mkdir()
|
|
72
|
+
|
|
73
|
+
X = np.array([
|
|
74
|
+
[0.0, 0.1, 1.0],
|
|
75
|
+
[0.1, 0.0, 0.9],
|
|
76
|
+
[0.2, 0.1, 1.1],
|
|
77
|
+
[0.1, 0.2, 1.0],
|
|
78
|
+
[0.2, 0.0, 0.8],
|
|
79
|
+
[0.0, 0.2, 1.2],
|
|
80
|
+
[1.0, 1.1, 0.0],
|
|
81
|
+
[1.1, 1.0, 0.1],
|
|
82
|
+
[0.9, 1.2, 0.0],
|
|
83
|
+
[1.2, 0.9, 0.2],
|
|
84
|
+
[1.0, 1.0, 0.0],
|
|
85
|
+
[1.1, 1.2, 0.1],
|
|
86
|
+
], dtype=float)
|
|
87
|
+
y = np.array([0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], dtype=np.int64)
|
|
88
|
+
|
|
89
|
+
label_path = raw_dir / "labels.npy"
|
|
90
|
+
dataset_path = data_dir / "toy.npz"
|
|
91
|
+
np.save(label_path, y)
|
|
92
|
+
np.savez(dataset_path, X=X, y=y)
|
|
93
|
+
|
|
94
|
+
config_path = work_dir / "toy_config.toml"
|
|
95
|
+
config_path.write_text(
|
|
96
|
+
f"""
|
|
97
|
+
[paths]
|
|
98
|
+
input = "{raw_dir.as_posix()}/"
|
|
99
|
+
dataset = "{data_dir.as_posix()}/"
|
|
100
|
+
output = "{output_dir.as_posix()}/"
|
|
101
|
+
|
|
102
|
+
[benchmark]
|
|
103
|
+
random_state = 42
|
|
104
|
+
|
|
105
|
+
[cv]
|
|
106
|
+
n_splits = 3
|
|
107
|
+
n_repeats = 1
|
|
108
|
+
|
|
109
|
+
[cv_smoke]
|
|
110
|
+
n_splits = 3
|
|
111
|
+
n_repeats = 1
|
|
112
|
+
|
|
113
|
+
[models]
|
|
114
|
+
active = ["svc"]
|
|
115
|
+
|
|
116
|
+
[datasets.toy]
|
|
117
|
+
path = "{dataset_path.as_posix()}"
|
|
118
|
+
label_path = "{label_path.as_posix()}"
|
|
119
|
+
family = "smoke"
|
|
120
|
+
method = "toy"
|
|
121
|
+
description = "Installed wheel smoke dataset"
|
|
122
|
+
""".lstrip(),
|
|
123
|
+
encoding="utf-8",
|
|
124
|
+
)
|
|
125
|
+
return config_path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_imports(python: Path) -> None:
|
|
129
|
+
code = f"""
|
|
130
|
+
import pathlib
|
|
131
|
+
import melite
|
|
132
|
+
expected = {EXPECTED_API!r}
|
|
133
|
+
assert melite.__all__ == expected, melite.__all__
|
|
134
|
+
for name in expected:
|
|
135
|
+
assert hasattr(melite, name), f"{{name}} missing"
|
|
136
|
+
assert 'load_dataset' not in melite.__all__
|
|
137
|
+
assert 'ResultManager' not in melite.__all__
|
|
138
|
+
repo = pathlib.Path({str(REPO_ROOT)!r}).resolve()
|
|
139
|
+
module_path = pathlib.Path(melite.__file__).resolve()
|
|
140
|
+
assert repo not in module_path.parents, module_path
|
|
141
|
+
print(melite.__version__, module_path)
|
|
142
|
+
"""
|
|
143
|
+
_run([python, "-c", code], cwd=REPO_ROOT.parent)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _verify_outputs(work_dir: Path) -> None:
|
|
147
|
+
output_dir = work_dir / "output"
|
|
148
|
+
results_csv = output_dir / "results.csv"
|
|
149
|
+
model_path = output_dir / "Model_SVC_toy.pkl"
|
|
150
|
+
figure_path = output_dir / "figures" / "SVC_toy.png"
|
|
151
|
+
|
|
152
|
+
for path in (results_csv, model_path, figure_path):
|
|
153
|
+
if not path.exists():
|
|
154
|
+
raise AssertionError(f"Expected artifact was not created: {path}")
|
|
155
|
+
|
|
156
|
+
with open(results_csv, newline="", encoding="utf-8") as f:
|
|
157
|
+
rows = list(csv.DictReader(f))
|
|
158
|
+
if len(rows) != 1:
|
|
159
|
+
raise AssertionError(f"Expected one result row, got {len(rows)}")
|
|
160
|
+
row = rows[0]
|
|
161
|
+
if row["dataset"] != "toy":
|
|
162
|
+
raise AssertionError(f"Expected dataset 'toy', got {row['dataset']!r}")
|
|
163
|
+
if row["model_name"] != "SVC":
|
|
164
|
+
raise AssertionError(f"Expected model 'SVC', got {row['model_name']!r}")
|
|
165
|
+
if row["smoke"] != "True":
|
|
166
|
+
raise AssertionError(f"Expected smoke=True row, got {row['smoke']!r}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--keep-temp",
|
|
173
|
+
action="store_true",
|
|
174
|
+
help="Keep the temporary smoke directory for debugging.",
|
|
175
|
+
)
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
|
|
178
|
+
temp_root = Path(tempfile.mkdtemp(prefix="melite-wheel-smoke-")).resolve()
|
|
179
|
+
print(f"[smoke] temp root: {temp_root}")
|
|
180
|
+
try:
|
|
181
|
+
wheel = _build_wheel(temp_root / "dist")
|
|
182
|
+
venv_dir = temp_root / "venv"
|
|
183
|
+
work_dir = temp_root / "work"
|
|
184
|
+
work_dir.mkdir()
|
|
185
|
+
|
|
186
|
+
_run([sys.executable, "-m", "venv", "--system-site-packages", venv_dir], cwd=temp_root)
|
|
187
|
+
python = _venv_python(venv_dir)
|
|
188
|
+
melite = _venv_melite(venv_dir)
|
|
189
|
+
|
|
190
|
+
_run([python, "-m", "pip", "install", "--no-deps", wheel], cwd=temp_root)
|
|
191
|
+
_check_imports(python)
|
|
192
|
+
|
|
193
|
+
config_path = _write_toy_project(work_dir)
|
|
194
|
+
_run([melite, "run", "--config", config_path, "--smoke"], cwd=work_dir)
|
|
195
|
+
_run(
|
|
196
|
+
[
|
|
197
|
+
melite,
|
|
198
|
+
"export",
|
|
199
|
+
"--config",
|
|
200
|
+
config_path,
|
|
201
|
+
"--row",
|
|
202
|
+
"0",
|
|
203
|
+
"--csv",
|
|
204
|
+
work_dir / "output" / "results.csv",
|
|
205
|
+
"--outdir",
|
|
206
|
+
work_dir / "output",
|
|
207
|
+
"--force",
|
|
208
|
+
],
|
|
209
|
+
cwd=work_dir,
|
|
210
|
+
)
|
|
211
|
+
_verify_outputs(work_dir)
|
|
212
|
+
print("[smoke] installed-wheel toy workflow passed")
|
|
213
|
+
finally:
|
|
214
|
+
if args.keep_temp:
|
|
215
|
+
print(f"[smoke] kept temp root: {temp_root}")
|
|
216
|
+
else:
|
|
217
|
+
shutil.rmtree(temp_root, ignore_errors=True)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
|
@@ -48,6 +48,51 @@ def test_config_smoke_false_uses_full_grids():
|
|
|
48
48
|
assert has_multiple
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_config_svc_grid_uses_pipeline_parameter_names():
|
|
52
|
+
cfg = Config()
|
|
53
|
+
svc_entries = [
|
|
54
|
+
entry for entry in cfg.PARAM_GRID
|
|
55
|
+
if entry["model"] == ["svc"]
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
assert svc_entries
|
|
59
|
+
for entry in svc_entries:
|
|
60
|
+
assert "C" not in entry
|
|
61
|
+
assert "kernel" not in entry
|
|
62
|
+
assert "gamma" not in entry
|
|
63
|
+
assert "svc__C" in entry
|
|
64
|
+
assert "svc__kernel" in entry
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_config_full_svc_grid_includes_linear_kernel_without_unused_params():
|
|
68
|
+
cfg = Config()
|
|
69
|
+
|
|
70
|
+
linear_entries = [
|
|
71
|
+
entry for entry in cfg.PARAM_GRID
|
|
72
|
+
if entry["model"] == ["svc"] and entry["svc__kernel"] == ["linear"]
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
assert linear_entries == [{
|
|
76
|
+
"model": ["svc"],
|
|
77
|
+
"svc__kernel": ["linear"],
|
|
78
|
+
"svc__C": [0.01, 0.1, 1, 10],
|
|
79
|
+
}]
|
|
80
|
+
assert "svc__gamma" not in linear_entries[0]
|
|
81
|
+
assert "svc__degree" not in linear_entries[0]
|
|
82
|
+
assert "svc__coef0" not in linear_entries[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_config_smoke_svc_grid_uses_pipeline_parameter_names():
|
|
86
|
+
cfg = Config(smoke=True)
|
|
87
|
+
svc_entry = next(
|
|
88
|
+
entry for entry in cfg.PARAM_GRID
|
|
89
|
+
if entry["model"] == ["svc"]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert svc_entry["svc__kernel"] == ["linear"]
|
|
93
|
+
assert svc_entry["svc__C"] == [1]
|
|
94
|
+
|
|
95
|
+
|
|
51
96
|
def test_config_setup_creates_directories(tmp_path):
|
|
52
97
|
cfg = Config()
|
|
53
98
|
cfg.PATHS = {
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
import csv
|
|
5
5
|
|
|
6
|
+
import joblib
|
|
6
7
|
import numpy as np
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
9
10
|
from melite.config import Config
|
|
10
11
|
from melite.export_best_model import Finalizer
|
|
12
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
13
|
+
from sklearn.preprocessing import StandardScaler
|
|
14
|
+
from sklearn.svm import SVC
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
class DummyModel:
|
|
@@ -44,6 +48,14 @@ def _write_npz(tmp_path, name, X, y):
|
|
|
44
48
|
return path
|
|
45
49
|
|
|
46
50
|
|
|
51
|
+
def _write_npz_without_X(tmp_path, name, Z):
|
|
52
|
+
data_dir = tmp_path / "data"
|
|
53
|
+
data_dir.mkdir(exist_ok=True)
|
|
54
|
+
path = data_dir / f"{name}.npz"
|
|
55
|
+
np.savez(path, Z=Z)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
47
59
|
def _write_results_csv(path, fieldnames, row):
|
|
48
60
|
path.parent.mkdir(exist_ok=True)
|
|
49
61
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
@@ -137,7 +149,7 @@ def test_export_dataset_row_uses_dataset_id_for_artifact(monkeypatch, tmp_path):
|
|
|
137
149
|
"description": "",
|
|
138
150
|
"reduction_type": "",
|
|
139
151
|
"model_name": "SVC",
|
|
140
|
-
"parameters": "{'
|
|
152
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
141
153
|
"f1_macro": 0.8,
|
|
142
154
|
"accuracy": 0.8,
|
|
143
155
|
"auc_roc": 0.9,
|
|
@@ -152,7 +164,81 @@ def test_export_dataset_row_uses_dataset_id_for_artifact(monkeypatch, tmp_path):
|
|
|
152
164
|
assert (tmp_path / "output" / "Model_SVC_morgan_r2_2048.pkl").exists()
|
|
153
165
|
|
|
154
166
|
|
|
155
|
-
def
|
|
167
|
+
def test_export_dataset_row_uses_strict_load_datasets(monkeypatch, tmp_path):
|
|
168
|
+
import melite.export_best_model as export_module
|
|
169
|
+
|
|
170
|
+
label_path, y = _write_labels(tmp_path)
|
|
171
|
+
cfg = _make_config(tmp_path)
|
|
172
|
+
cfg.DATASETS = {
|
|
173
|
+
"maccs": {
|
|
174
|
+
"path": str(tmp_path / "data" / "maccs.npz"),
|
|
175
|
+
"label_path": str(label_path),
|
|
176
|
+
"metadata": {"family": "fingerprints", "method": "MACCS"},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
csv_path = tmp_path / "output" / "results.csv"
|
|
180
|
+
_write_results_csv(
|
|
181
|
+
csv_path,
|
|
182
|
+
["dataset", "model_name", "parameters", "smoke"],
|
|
183
|
+
{
|
|
184
|
+
"dataset": "maccs",
|
|
185
|
+
"model_name": "SVC",
|
|
186
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
187
|
+
"smoke": False,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
calls = []
|
|
191
|
+
|
|
192
|
+
def fake_load_datasets(config):
|
|
193
|
+
calls.append(config)
|
|
194
|
+
return {
|
|
195
|
+
"maccs": {
|
|
196
|
+
"X": np.ones((20, 5)),
|
|
197
|
+
"y": y,
|
|
198
|
+
"metadata": {"family": "fingerprints", "method": "MACCS"},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(export_module, "load_datasets", fake_load_datasets)
|
|
203
|
+
monkeypatch.setattr(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
|
|
204
|
+
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
205
|
+
|
|
206
|
+
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
207
|
+
|
|
208
|
+
assert calls == [cfg]
|
|
209
|
+
assert (tmp_path / "output" / "Model_SVC_maccs.pkl").exists()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_export_dataset_npz_without_X_fails_clearly(monkeypatch, tmp_path):
|
|
213
|
+
label_path, _ = _write_labels(tmp_path)
|
|
214
|
+
dataset_path = _write_npz_without_X(tmp_path, "maccs", np.ones((20, 5)))
|
|
215
|
+
cfg = _make_config(tmp_path)
|
|
216
|
+
cfg.DATASETS = {
|
|
217
|
+
"maccs": {
|
|
218
|
+
"path": str(dataset_path),
|
|
219
|
+
"label_path": str(label_path),
|
|
220
|
+
"metadata": {"family": "fingerprints", "method": "MACCS"},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
csv_path = tmp_path / "output" / "results.csv"
|
|
224
|
+
_write_results_csv(
|
|
225
|
+
csv_path,
|
|
226
|
+
["dataset", "model_name", "parameters", "smoke"],
|
|
227
|
+
{
|
|
228
|
+
"dataset": "maccs",
|
|
229
|
+
"model_name": "SVC",
|
|
230
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
231
|
+
"smoke": False,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
monkeypatch.setattr(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
|
|
235
|
+
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
236
|
+
|
|
237
|
+
with pytest.raises(ValueError, match="Required key 'X' not found"):
|
|
238
|
+
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_export_legacy_row_with_valid_X_and_labels_succeeds(monkeypatch, tmp_path):
|
|
156
242
|
_, y = _write_labels(tmp_path)
|
|
157
243
|
_write_npz(tmp_path, "PCA70", np.ones((20, 5)), y)
|
|
158
244
|
cfg = _make_config(tmp_path)
|
|
@@ -167,7 +253,7 @@ def test_export_legacy_row_falls_back_to_reduction_and_level(monkeypatch, tmp_pa
|
|
|
167
253
|
"reduction_type": "PCA",
|
|
168
254
|
"level": 70,
|
|
169
255
|
"model_name": "SVC",
|
|
170
|
-
"parameters": "{'
|
|
256
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
171
257
|
"f1_macro": 0.8,
|
|
172
258
|
"accuracy": 0.8,
|
|
173
259
|
"auc_roc": 0.9,
|
|
@@ -182,6 +268,79 @@ def test_export_legacy_row_falls_back_to_reduction_and_level(monkeypatch, tmp_pa
|
|
|
182
268
|
assert (tmp_path / "output" / "Model_SVC_PCA70.pkl").exists()
|
|
183
269
|
|
|
184
270
|
|
|
271
|
+
def test_export_svc_saves_scaler_pipeline(monkeypatch, tmp_path):
|
|
272
|
+
label_path, y = _write_labels(tmp_path)
|
|
273
|
+
dataset_path = _write_npz(tmp_path, "toy", np.ones((20, 5)), y)
|
|
274
|
+
cfg = _make_config(tmp_path)
|
|
275
|
+
cfg.DATASETS = {
|
|
276
|
+
"toy": {
|
|
277
|
+
"path": str(dataset_path),
|
|
278
|
+
"label_path": str(label_path),
|
|
279
|
+
"metadata": {"family": "smoke", "method": "toy"},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
csv_path = tmp_path / "output" / "results.csv"
|
|
283
|
+
_write_results_csv(
|
|
284
|
+
csv_path,
|
|
285
|
+
["dataset", "model_name", "parameters", "smoke"],
|
|
286
|
+
{
|
|
287
|
+
"dataset": "toy",
|
|
288
|
+
"model_name": "SVC",
|
|
289
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
290
|
+
"smoke": False,
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
294
|
+
|
|
295
|
+
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
296
|
+
|
|
297
|
+
model = joblib.load(tmp_path / "output" / "Model_SVC_toy.pkl")
|
|
298
|
+
assert isinstance(model, SklearnPipeline)
|
|
299
|
+
assert list(model.named_steps) == ["scaler", "svc"]
|
|
300
|
+
assert isinstance(model.named_steps["scaler"], StandardScaler)
|
|
301
|
+
assert isinstance(model.named_steps["svc"], SVC)
|
|
302
|
+
assert model.named_steps["svc"].kernel == "linear"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_export_svc_accepts_legacy_unprefixed_parameters():
|
|
306
|
+
model = Finalizer._build_model("SVC", "{'kernel': 'linear', 'C': 1}")
|
|
307
|
+
|
|
308
|
+
assert isinstance(model, SklearnPipeline)
|
|
309
|
+
assert model.named_steps["svc"].kernel == "linear"
|
|
310
|
+
assert model.named_steps["svc"].C == 1
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_export_legacy_npz_without_X_does_not_fallback_to_first_key(
|
|
314
|
+
monkeypatch, tmp_path
|
|
315
|
+
):
|
|
316
|
+
_write_labels(tmp_path)
|
|
317
|
+
_write_npz_without_X(tmp_path, "PCA70", np.ones((20, 5)))
|
|
318
|
+
cfg = _make_config(tmp_path)
|
|
319
|
+
csv_path = tmp_path / "output" / "results.csv"
|
|
320
|
+
_write_results_csv(
|
|
321
|
+
csv_path,
|
|
322
|
+
[
|
|
323
|
+
"reduction_type", "level", "model_name", "parameters",
|
|
324
|
+
"f1_macro", "accuracy", "auc_roc", "smoke",
|
|
325
|
+
],
|
|
326
|
+
{
|
|
327
|
+
"reduction_type": "PCA",
|
|
328
|
+
"level": 70,
|
|
329
|
+
"model_name": "SVC",
|
|
330
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
331
|
+
"f1_macro": 0.8,
|
|
332
|
+
"accuracy": 0.8,
|
|
333
|
+
"auc_roc": 0.9,
|
|
334
|
+
"smoke": False,
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
monkeypatch.setattr(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
|
|
338
|
+
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
339
|
+
|
|
340
|
+
with pytest.raises(ValueError, match="Required key 'X' not found"):
|
|
341
|
+
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
342
|
+
|
|
343
|
+
|
|
185
344
|
def test_cv_plot_uses_dataset_id_for_figure(monkeypatch, tmp_path):
|
|
186
345
|
import melite.export_best_model as export_module
|
|
187
346
|
|
|
@@ -193,7 +352,7 @@ def test_cv_plot_uses_dataset_id_for_figure(monkeypatch, tmp_path):
|
|
|
193
352
|
{
|
|
194
353
|
"dataset": "rdkit_descriptors",
|
|
195
354
|
"model_name": "SVC",
|
|
196
|
-
"parameters": "{'
|
|
355
|
+
"parameters": "{'svc__kernel': 'linear', 'svc__C': 1}",
|
|
197
356
|
"smoke": False,
|
|
198
357
|
},
|
|
199
358
|
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
"""Tests for MELITE model training selection behavior."""
|
|
3
|
+
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
10
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
11
|
+
from sklearn.preprocessing import StandardScaler
|
|
12
|
+
from sklearn.svm import SVC
|
|
13
|
+
from xgboost import XGBClassifier
|
|
14
|
+
|
|
15
|
+
from melite.model_training import MultiModelTrainer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _config(active_models):
|
|
19
|
+
return SimpleNamespace(
|
|
20
|
+
ACTIVE_MODELS=active_models,
|
|
21
|
+
RANDOM_STATE=42,
|
|
22
|
+
PARAM_GRID=[
|
|
23
|
+
{"model": ["svc"], "svc__C": [1], "svc__kernel": ["linear"]},
|
|
24
|
+
{"model": ["rf"], "n_estimators": [10]},
|
|
25
|
+
{"model": ["xgb"], "n_estimators": [10]},
|
|
26
|
+
],
|
|
27
|
+
get_cv_config=lambda: {
|
|
28
|
+
"n_splits": 2,
|
|
29
|
+
"n_repeats": 1,
|
|
30
|
+
"random_state": 42,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _trainer_with_fake_training(active_models):
|
|
36
|
+
trainer = MultiModelTrainer(_config(active_models))
|
|
37
|
+
trainer.model_builders = {
|
|
38
|
+
"svc": lambda: "svc-estimator",
|
|
39
|
+
"rf": lambda: "rf-estimator",
|
|
40
|
+
"xgb": lambda: "xgb-estimator",
|
|
41
|
+
}
|
|
42
|
+
calls = []
|
|
43
|
+
|
|
44
|
+
def fake_grid_search(model, X_train, y_train, param_grid):
|
|
45
|
+
model_name = model.split("-")[0]
|
|
46
|
+
calls.append((model_name, param_grid))
|
|
47
|
+
return f"{model_name}-tuned", {"model": model_name}
|
|
48
|
+
|
|
49
|
+
def fake_cross_validate(model, X_train, y_train):
|
|
50
|
+
model_name = model.split("-")[0]
|
|
51
|
+
scores = {"svc": 0.7, "rf": 0.8, "xgb": 0.6}
|
|
52
|
+
return scores[model_name], 0.01, 0.75, 0.02, 0.85, 0.03
|
|
53
|
+
|
|
54
|
+
trainer.perform_grid_search = fake_grid_search
|
|
55
|
+
trainer.cross_validate_model = fake_cross_validate
|
|
56
|
+
return trainer, calls
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_active_models_svc_trains_only_svc():
|
|
60
|
+
trainer, calls = _trainer_with_fake_training(["svc"])
|
|
61
|
+
|
|
62
|
+
result = trainer.train_and_select_best_model(
|
|
63
|
+
np.ones((4, 2)),
|
|
64
|
+
np.array([0, 1, 0, 1]),
|
|
65
|
+
"PCA",
|
|
66
|
+
70,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert [model_name for model_name, _ in calls] == ["svc"]
|
|
70
|
+
assert result[0] == "svc-tuned"
|
|
71
|
+
assert result[1] == {"model": "svc"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_active_models_rf_trains_only_rf():
|
|
75
|
+
trainer, calls = _trainer_with_fake_training(["rf"])
|
|
76
|
+
|
|
77
|
+
result = trainer.train_and_select_best_model(
|
|
78
|
+
np.ones((4, 2)),
|
|
79
|
+
np.array([0, 1, 0, 1]),
|
|
80
|
+
"PCA",
|
|
81
|
+
70,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert [model_name for model_name, _ in calls] == ["rf"]
|
|
85
|
+
assert result[0] == "rf-tuned"
|
|
86
|
+
assert result[1] == {"model": "rf"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_svc_builder_returns_scaler_then_svc_pipeline():
|
|
90
|
+
trainer = MultiModelTrainer(_config(["svc"]))
|
|
91
|
+
|
|
92
|
+
model = trainer.model_builders["svc"]()
|
|
93
|
+
|
|
94
|
+
assert isinstance(model, SklearnPipeline)
|
|
95
|
+
assert list(model.named_steps) == ["scaler", "svc"]
|
|
96
|
+
assert isinstance(model.named_steps["scaler"], StandardScaler)
|
|
97
|
+
assert isinstance(model.named_steps["svc"], SVC)
|
|
98
|
+
assert model.named_steps["svc"].probability is True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_rf_and_xgb_builders_remain_unscaled_direct_estimators():
|
|
102
|
+
trainer = MultiModelTrainer(_config(["rf", "xgb"]))
|
|
103
|
+
|
|
104
|
+
rf = trainer.model_builders["rf"]()
|
|
105
|
+
xgb = trainer.model_builders["xgb"]()
|
|
106
|
+
|
|
107
|
+
assert isinstance(rf, RandomForestClassifier)
|
|
108
|
+
assert isinstance(xgb, XGBClassifier)
|
|
109
|
+
assert not isinstance(rf, SklearnPipeline)
|
|
110
|
+
assert not isinstance(xgb, SklearnPipeline)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_filter_param_grid_preserves_svc_pipeline_prefixes():
|
|
114
|
+
trainer = MultiModelTrainer(_config(["svc"]))
|
|
115
|
+
|
|
116
|
+
grid = trainer._filter_param_grid("svc")
|
|
117
|
+
|
|
118
|
+
assert grid == [{"svc__C": [1], "svc__kernel": ["linear"]}]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_invalid_active_model_raises_clear_error():
|
|
122
|
+
with pytest.raises(ValueError, match="Unknown active model\\(s\\): knn"):
|
|
123
|
+
MultiModelTrainer(_config(["svc", "knn"]))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_empty_active_models_raises_clear_error():
|
|
127
|
+
with pytest.raises(ValueError, match="ACTIVE_MODELS must contain at least one"):
|
|
128
|
+
MultiModelTrainer(_config([]))
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pytest
|
|
6
|
+
import joblib
|
|
6
7
|
from melite.predict import predict
|
|
8
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
9
|
+
from sklearn.preprocessing import StandardScaler
|
|
10
|
+
from sklearn.svm import SVC
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
def test_predict_returns_dict(tmp_model):
|
|
@@ -73,3 +77,20 @@ def test_3d_input_raises_value_error(tmp_model):
|
|
|
73
77
|
X_3d = np.random.rand(10, 5, 3).astype(np.float32)
|
|
74
78
|
with pytest.raises(ValueError, match="2-D numpy array"):
|
|
75
79
|
predict(tmp_model, X_3d)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_predict_loads_exported_svc_pipeline(tmp_path):
|
|
83
|
+
X_train = np.random.rand(20, 5).astype(np.float32)
|
|
84
|
+
y_train = np.array([0, 1] * 10, dtype=np.int64)
|
|
85
|
+
model = SklearnPipeline([
|
|
86
|
+
("scaler", StandardScaler()),
|
|
87
|
+
("svc", SVC(kernel="linear", C=1, probability=True, random_state=42)),
|
|
88
|
+
])
|
|
89
|
+
model.fit(X_train, y_train)
|
|
90
|
+
model_path = tmp_path / "Model_SVC_toy.pkl"
|
|
91
|
+
joblib.dump(model, model_path)
|
|
92
|
+
|
|
93
|
+
result = predict(model_path, np.random.rand(4, 5).astype(np.float32))
|
|
94
|
+
|
|
95
|
+
assert result["predictions"].shape == (4,)
|
|
96
|
+
assert result["probabilities"].shape == (4, 2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|