melite 0.2.2__tar.gz → 0.2.3__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.2 → melite-0.2.3}/.gitignore +2 -0
- {melite-0.2.2 → melite-0.2.3}/CHANGELOG.md +25 -0
- {melite-0.2.2 → melite-0.2.3}/CITATION.cff +1 -1
- {melite-0.2.2 → melite-0.2.3}/PKG-INFO +18 -9
- {melite-0.2.2 → melite-0.2.3}/README.md +17 -8
- {melite-0.2.2 → melite-0.2.3}/melite/config.py +10 -3
- {melite-0.2.2 → melite-0.2.3}/melite/config_default.toml +1 -1
- {melite-0.2.2 → melite-0.2.3}/melite/export_best_model.py +63 -3
- {melite-0.2.2 → melite-0.2.3}/melite/model_training.py +38 -1
- {melite-0.2.2 → melite-0.2.3}/melite/version.py +1 -1
- {melite-0.2.2 → melite-0.2.3}/tests/test_export.py +80 -5
- {melite-0.2.2 → melite-0.2.3}/tests/test_model_training.py +81 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_predict.py +20 -0
- {melite-0.2.2 → melite-0.2.3}/COPYING +0 -0
- {melite-0.2.2 → melite-0.2.3}/COPYING.LESSER +0 -0
- {melite-0.2.2 → melite-0.2.3}/LICENSE +0 -0
- {melite-0.2.2 → melite-0.2.3}/environment.yml +0 -0
- {melite-0.2.2 → melite-0.2.3}/examples/example_config.toml +0 -0
- {melite-0.2.2 → melite-0.2.3}/examples/generate_sample_data.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/__init__.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/cli.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/load_dataset.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/main.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/plot_metrics.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/predict.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/melite/result_manager.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/pyproject.toml +0 -0
- {melite-0.2.2 → melite-0.2.3}/scripts/smoke_install_wheel.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/__init__.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/conftest.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_cli.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_config.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_examples.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_load_dataset.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_main.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_plot_metrics.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_public_api.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_result_manager.py +0 -0
- {melite-0.2.2 → melite-0.2.3}/tests/test_version.py +0 -0
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [v0.2.3] - 2026-06-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added opt-in experimental stacking via the `stack` model key in
|
|
14
|
+
`[models].active`.
|
|
15
|
+
- Added sklearn `StackingClassifier` support using `stack_method="predict_proba"`,
|
|
16
|
+
`passthrough=False`, and `LogisticRegression` as the final estimator.
|
|
17
|
+
- Added export and prediction coverage for saved stacking `.pkl` artifacts.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Standalone SVC remains a `StandardScaler` -> `SVC` pipeline.
|
|
21
|
+
- The SVC base estimator inside stacking is also scaled and uses
|
|
22
|
+
`SVC(probability=True)` so stacking combines probability outputs.
|
|
23
|
+
- Random Forest and XGBoost remain unscaled direct estimators because they are
|
|
24
|
+
tree-based models and do not require feature scaling by default.
|
|
25
|
+
- Stacking-internal CV uses the configured split count and random state with
|
|
26
|
+
one repeat to satisfy sklearn's out-of-fold prediction requirements.
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
- Stacking is experimental and disabled by default.
|
|
30
|
+
- Export remains `.pkl` via `joblib`.
|
|
31
|
+
- Optuna and MLflow are intentionally out of scope for v0.2.3.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
10
35
|
## [v0.2.2] - 2026-05-28
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -2,7 +2,7 @@ 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.
|
|
5
|
+
version: "0.2.3"
|
|
6
6
|
date-released: "2026-05-28"
|
|
7
7
|
authors:
|
|
8
8
|
- family-names: "Contreras-Torres"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: melite
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
[](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
|
|
43
43
|
[](LICENSE)
|
|
44
|
-
[]()
|
|
45
45
|
[]()
|
|
46
46
|
|
|
47
47
|
**MELITE** is a pre-stable Python toolkit for tabular classification
|
|
@@ -60,7 +60,7 @@ Project: MELITE
|
|
|
60
60
|
PyPI distribution: melite
|
|
61
61
|
Import package: melite
|
|
62
62
|
CLI: melite
|
|
63
|
-
Version: 0.2.
|
|
63
|
+
Version: 0.2.3
|
|
64
64
|
License: LGPL-3.0-or-later
|
|
65
65
|
Status: alpha / pre-stable
|
|
66
66
|
```
|
|
@@ -133,7 +133,7 @@ print(result["probabilities"])
|
|
|
133
133
|
| MELITE does | MELITE does not |
|
|
134
134
|
|-------------|-----------------|
|
|
135
135
|
| Accept prepared `X` and `y` arrays. | Generate fingerprints. |
|
|
136
|
-
| Benchmark SVC, Random Forest, and
|
|
136
|
+
| Benchmark SVC, Random Forest, XGBoost, and opt-in experimental stacking classifiers. | Process SMILES. |
|
|
137
137
|
| Select the best row by F1-macro. | Generate PCA or UMAP reductions from raw data. |
|
|
138
138
|
| Export a final retrained `.pkl` model. | Act as a general AutoML framework. |
|
|
139
139
|
| Run artifact-based inference through `predict()`. | Promise a stable 1.0 API yet. |
|
|
@@ -181,10 +181,19 @@ active = ["svc", "rf", "xgb"]
|
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
Remove a key to skip that family during training. Valid keys are `svc`, `rf`,
|
|
184
|
-
and `
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
`xgb`, and experimental `stack`. Stacking is opt-in; add `"stack"` to
|
|
185
|
+
`active` to evaluate an sklearn `StackingClassifier` alongside the default
|
|
186
|
+
families.
|
|
187
|
+
|
|
188
|
+
Standalone SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn
|
|
189
|
+
pipeline because SVM/kernel-based methods are sensitive to feature scale.
|
|
190
|
+
Random Forest and XGBoost are tree-based models and remain unscaled by
|
|
191
|
+
default. Experimental stacking uses `stack_method="predict_proba"` with a
|
|
192
|
+
scaled probabilistic SVC base estimator, unscaled RF/XGBoost base estimators,
|
|
193
|
+
and a logistic regression final estimator. Its internal stacking CV uses the
|
|
194
|
+
configured split count and random state with one repeat to satisfy sklearn's
|
|
195
|
+
out-of-fold prediction requirements. Final exports remain `.pkl` artifacts
|
|
196
|
+
serialized with `joblib`; Optuna and MLflow are not part of v0.2.3.
|
|
188
197
|
|
|
189
198
|
## CLI
|
|
190
199
|
|
|
@@ -248,7 +257,7 @@ Local inputs and generated artifacts such as `raw/`, `data/`, `output/`,
|
|
|
248
257
|
|
|
249
258
|
## Validation
|
|
250
259
|
|
|
251
|
-
The current `dev/v0.2.
|
|
260
|
+
The current `dev/v0.2.3` branch targets:
|
|
252
261
|
|
|
253
262
|
```bash
|
|
254
263
|
python -m pytest tests/ -v --basetemp=.review_pytest_tmp -o cache_dir=.review_pytest_cache
|
|
@@ -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.3
|
|
25
25
|
License: LGPL-3.0-or-later
|
|
26
26
|
Status: alpha / pre-stable
|
|
27
27
|
```
|
|
@@ -94,7 +94,7 @@ print(result["probabilities"])
|
|
|
94
94
|
| MELITE does | MELITE does not |
|
|
95
95
|
|-------------|-----------------|
|
|
96
96
|
| Accept prepared `X` and `y` arrays. | Generate fingerprints. |
|
|
97
|
-
| Benchmark SVC, Random Forest, and
|
|
97
|
+
| Benchmark SVC, Random Forest, XGBoost, and opt-in experimental stacking classifiers. | Process SMILES. |
|
|
98
98
|
| Select the best row by F1-macro. | Generate PCA or UMAP reductions from raw data. |
|
|
99
99
|
| Export a final retrained `.pkl` model. | Act as a general AutoML framework. |
|
|
100
100
|
| Run artifact-based inference through `predict()`. | Promise a stable 1.0 API yet. |
|
|
@@ -142,10 +142,19 @@ active = ["svc", "rf", "xgb"]
|
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
Remove a key to skip that family during training. Valid keys are `svc`, `rf`,
|
|
145
|
-
and `
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
`xgb`, and experimental `stack`. Stacking is opt-in; add `"stack"` to
|
|
146
|
+
`active` to evaluate an sklearn `StackingClassifier` alongside the default
|
|
147
|
+
families.
|
|
148
|
+
|
|
149
|
+
Standalone SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn
|
|
150
|
+
pipeline because SVM/kernel-based methods are sensitive to feature scale.
|
|
151
|
+
Random Forest and XGBoost are tree-based models and remain unscaled by
|
|
152
|
+
default. Experimental stacking uses `stack_method="predict_proba"` with a
|
|
153
|
+
scaled probabilistic SVC base estimator, unscaled RF/XGBoost base estimators,
|
|
154
|
+
and a logistic regression final estimator. Its internal stacking CV uses the
|
|
155
|
+
configured split count and random state with one repeat to satisfy sklearn's
|
|
156
|
+
out-of-fold prediction requirements. Final exports remain `.pkl` artifacts
|
|
157
|
+
serialized with `joblib`; Optuna and MLflow are not part of v0.2.3.
|
|
149
158
|
|
|
150
159
|
## CLI
|
|
151
160
|
|
|
@@ -209,7 +218,7 @@ Local inputs and generated artifacts such as `raw/`, `data/`, `output/`,
|
|
|
209
218
|
|
|
210
219
|
## Validation
|
|
211
220
|
|
|
212
|
-
The current `dev/v0.2.
|
|
221
|
+
The current `dev/v0.2.3` branch targets:
|
|
213
222
|
|
|
214
223
|
```bash
|
|
215
224
|
python -m pytest tests/ -v --basetemp=.review_pytest_tmp -o cache_dir=.review_pytest_cache
|
|
@@ -76,7 +76,8 @@ class Config:
|
|
|
76
76
|
Normalized dataset registry keyed by user-defined dataset id. Each
|
|
77
77
|
entry contains ``path``, ``label_path``, and ``metadata`` keys.
|
|
78
78
|
ACTIVE_MODELS : list of str
|
|
79
|
-
Model keys to include in the benchmark (e.g. ``["svc", "rf", "xgb"]
|
|
79
|
+
Model keys to include in the benchmark (e.g. ``["svc", "rf", "xgb"]``;
|
|
80
|
+
add ``"stack"`` to opt in to experimental stacking).
|
|
80
81
|
CV_CONFIG : dict
|
|
81
82
|
Cross-validation settings with keys ``n_splits``, ``n_repeats``, and
|
|
82
83
|
``random_state``.
|
|
@@ -84,7 +85,7 @@ class Config:
|
|
|
84
85
|
Raw hyperparameter grid definitions, one entry per model configuration.
|
|
85
86
|
PARAM_GRID_BY_MODEL : dict
|
|
86
87
|
Compiled :class:`~sklearn.model_selection.ParameterGrid` objects keyed
|
|
87
|
-
by model name (``"svc"``, ``"rf"``, ``"xgb"``).
|
|
88
|
+
by model name (``"svc"``, ``"rf"``, ``"xgb"``, ``"stack"``).
|
|
88
89
|
|
|
89
90
|
Examples
|
|
90
91
|
--------
|
|
@@ -219,6 +220,9 @@ class Config:
|
|
|
219
220
|
"reg_alpha": [0],
|
|
220
221
|
"reg_lambda": [1],
|
|
221
222
|
},
|
|
223
|
+
{
|
|
224
|
+
"model": ["stack"],
|
|
225
|
+
},
|
|
222
226
|
]
|
|
223
227
|
return [
|
|
224
228
|
{
|
|
@@ -259,6 +263,9 @@ class Config:
|
|
|
259
263
|
"reg_alpha": [0, 0.5],
|
|
260
264
|
"reg_lambda": [1, 2],
|
|
261
265
|
},
|
|
266
|
+
{
|
|
267
|
+
"model": ["stack"],
|
|
268
|
+
},
|
|
262
269
|
]
|
|
263
270
|
|
|
264
271
|
def _group_param_grid_by_model(self) -> dict:
|
|
@@ -291,7 +298,7 @@ class Config:
|
|
|
291
298
|
Parameters
|
|
292
299
|
----------
|
|
293
300
|
model : str
|
|
294
|
-
Model key. One of ``"svc"``, ``"rf"``, or ``"
|
|
301
|
+
Model key. One of ``"svc"``, ``"rf"``, ``"xgb"``, or ``"stack"``.
|
|
295
302
|
|
|
296
303
|
Returns
|
|
297
304
|
-------
|
|
@@ -22,7 +22,8 @@ import pandas as pd
|
|
|
22
22
|
from .config import Config
|
|
23
23
|
from .load_dataset import load_datasets, _load_one_dataset
|
|
24
24
|
from .plot_metrics import plot_cv_distributions
|
|
25
|
-
from sklearn.ensemble import RandomForestClassifier
|
|
25
|
+
from sklearn.ensemble import RandomForestClassifier, StackingClassifier
|
|
26
|
+
from sklearn.linear_model import LogisticRegression
|
|
26
27
|
from sklearn.model_selection import cross_validate, RepeatedStratifiedKFold
|
|
27
28
|
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
28
29
|
from sklearn.preprocessing import StandardScaler
|
|
@@ -52,6 +53,49 @@ def _safe_filename_part(value: Any) -> str:
|
|
|
52
53
|
return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()).strip("_")
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
def _build_cv_strategy(cv_config: dict | None):
|
|
57
|
+
if cv_config is None:
|
|
58
|
+
cv_config = {"n_splits": 10, "n_repeats": 5, "random_state": 42}
|
|
59
|
+
# StackingClassifier uses cross_val_predict internally, which requires
|
|
60
|
+
# one partition of the data rather than repeated test assignments.
|
|
61
|
+
return RepeatedStratifiedKFold(
|
|
62
|
+
n_splits=cv_config["n_splits"],
|
|
63
|
+
n_repeats=1,
|
|
64
|
+
random_state=cv_config["random_state"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_stacking_classifier(
|
|
69
|
+
random_state: int = 42,
|
|
70
|
+
cv_config: dict | None = None,
|
|
71
|
+
) -> StackingClassifier:
|
|
72
|
+
return StackingClassifier(
|
|
73
|
+
estimators=[
|
|
74
|
+
("svc", SklearnPipeline([
|
|
75
|
+
("scaler", StandardScaler()),
|
|
76
|
+
("svc", SVC(probability=True, random_state=random_state)),
|
|
77
|
+
])),
|
|
78
|
+
("rf", RandomForestClassifier(random_state=random_state, n_jobs=-1)),
|
|
79
|
+
(
|
|
80
|
+
"xgb",
|
|
81
|
+
XGBClassifier(
|
|
82
|
+
eval_metric="logloss",
|
|
83
|
+
random_state=random_state,
|
|
84
|
+
n_jobs=-1,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
final_estimator=LogisticRegression(
|
|
89
|
+
random_state=random_state,
|
|
90
|
+
max_iter=1000,
|
|
91
|
+
),
|
|
92
|
+
cv=_build_cv_strategy(cv_config),
|
|
93
|
+
stack_method="predict_proba",
|
|
94
|
+
passthrough=False,
|
|
95
|
+
n_jobs=-1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
55
99
|
class DatasetLoader:
|
|
56
100
|
"""Load feature matrices and labels for model retraining.
|
|
57
101
|
|
|
@@ -290,7 +334,12 @@ class Finalizer:
|
|
|
290
334
|
row = self._get_selected_row()
|
|
291
335
|
self._check_smoke_guard(row)
|
|
292
336
|
X, y = self._loader.load_row(row)
|
|
293
|
-
model = self._build_model(
|
|
337
|
+
model = self._build_model(
|
|
338
|
+
row.model_name,
|
|
339
|
+
row.parameters,
|
|
340
|
+
cv_config=self._cfg.get_cv_config(),
|
|
341
|
+
random_state=getattr(self._cfg, "RANDOM_STATE", 42),
|
|
342
|
+
)
|
|
294
343
|
|
|
295
344
|
figures_dir = Path(self._cfg.PATHS["OUTPUT"]) / "figures"
|
|
296
345
|
self._cv_and_plot(model, X, y, row, figures_dir)
|
|
@@ -330,7 +379,12 @@ class Finalizer:
|
|
|
330
379
|
print("[ERR] Invalid row number; please try again.")
|
|
331
380
|
|
|
332
381
|
@staticmethod
|
|
333
|
-
def _build_model(
|
|
382
|
+
def _build_model(
|
|
383
|
+
name: str,
|
|
384
|
+
serialised_params: str,
|
|
385
|
+
cv_config: dict | None = None,
|
|
386
|
+
random_state: int = 42,
|
|
387
|
+
) -> Any:
|
|
334
388
|
params = ast.literal_eval(serialised_params)
|
|
335
389
|
if name == "SVC":
|
|
336
390
|
model = SklearnPipeline([
|
|
@@ -343,6 +397,12 @@ class Finalizer:
|
|
|
343
397
|
}
|
|
344
398
|
svc_params.setdefault("svc__probability", True)
|
|
345
399
|
return model.set_params(**svc_params)
|
|
400
|
+
if name == "StackingClassifier":
|
|
401
|
+
model = _build_stacking_classifier(
|
|
402
|
+
random_state=random_state,
|
|
403
|
+
cv_config=cv_config,
|
|
404
|
+
)
|
|
405
|
+
return model.set_params(**params)
|
|
346
406
|
try:
|
|
347
407
|
return MODEL_MAP[name](**params)
|
|
348
408
|
except KeyError as exc:
|
|
@@ -11,7 +11,8 @@ cross-validation.
|
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
12
|
|
|
13
13
|
import numpy as np
|
|
14
|
-
from sklearn.ensemble import RandomForestClassifier
|
|
14
|
+
from sklearn.ensemble import RandomForestClassifier, StackingClassifier
|
|
15
|
+
from sklearn.linear_model import LogisticRegression
|
|
15
16
|
from sklearn.model_selection import GridSearchCV, RepeatedStratifiedKFold, cross_validate
|
|
16
17
|
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
17
18
|
from sklearn.preprocessing import StandardScaler
|
|
@@ -90,9 +91,45 @@ class MultiModelTrainer(ModelTrainer):
|
|
|
90
91
|
]),
|
|
91
92
|
"rf": lambda: RandomForestClassifier(random_state=rs, n_jobs=-1),
|
|
92
93
|
"xgb": lambda: XGBClassifier(eval_metric="logloss", random_state=rs, n_jobs=-1),
|
|
94
|
+
"stack": lambda: self._build_stacking_classifier(rs),
|
|
93
95
|
}
|
|
94
96
|
self.active_models = self._validate_active_models()
|
|
95
97
|
|
|
98
|
+
def _build_stacking_classifier(self, random_state):
|
|
99
|
+
cv = self._build_cv_strategy()
|
|
100
|
+
# StackingClassifier uses cross_val_predict internally, which requires
|
|
101
|
+
# one partition of the data rather than repeated test assignments.
|
|
102
|
+
stacking_cv = RepeatedStratifiedKFold(
|
|
103
|
+
n_splits=cv.cvargs["n_splits"],
|
|
104
|
+
n_repeats=1,
|
|
105
|
+
random_state=cv.random_state,
|
|
106
|
+
)
|
|
107
|
+
return StackingClassifier(
|
|
108
|
+
estimators=[
|
|
109
|
+
("svc", SklearnPipeline([
|
|
110
|
+
("scaler", StandardScaler()),
|
|
111
|
+
("svc", SVC(probability=True, random_state=random_state)),
|
|
112
|
+
])),
|
|
113
|
+
("rf", RandomForestClassifier(random_state=random_state, n_jobs=-1)),
|
|
114
|
+
(
|
|
115
|
+
"xgb",
|
|
116
|
+
XGBClassifier(
|
|
117
|
+
eval_metric="logloss",
|
|
118
|
+
random_state=random_state,
|
|
119
|
+
n_jobs=-1,
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
final_estimator=LogisticRegression(
|
|
124
|
+
random_state=random_state,
|
|
125
|
+
max_iter=1000,
|
|
126
|
+
),
|
|
127
|
+
cv=stacking_cv,
|
|
128
|
+
stack_method="predict_proba",
|
|
129
|
+
passthrough=False,
|
|
130
|
+
n_jobs=-1,
|
|
131
|
+
)
|
|
132
|
+
|
|
96
133
|
def _validate_active_models(self):
|
|
97
134
|
active_models = getattr(self.config, "ACTIVE_MODELS", list(self.model_builders))
|
|
98
135
|
if not active_models:
|
|
@@ -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.3"
|
|
10
10
|
PROJECT_NAME = "MELITE"
|
|
11
11
|
PROJECT_VERSION = __version__
|
|
12
12
|
PROJECT_STATUS = "alpha"
|
|
@@ -9,9 +9,11 @@ import pytest
|
|
|
9
9
|
|
|
10
10
|
from melite.config import Config
|
|
11
11
|
from melite.export_best_model import Finalizer
|
|
12
|
+
from sklearn.ensemble import RandomForestClassifier, StackingClassifier
|
|
12
13
|
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
13
14
|
from sklearn.preprocessing import StandardScaler
|
|
14
15
|
from sklearn.svm import SVC
|
|
16
|
+
from xgboost import XGBClassifier
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class DummyModel:
|
|
@@ -156,7 +158,9 @@ def test_export_dataset_row_uses_dataset_id_for_artifact(monkeypatch, tmp_path):
|
|
|
156
158
|
"smoke": False,
|
|
157
159
|
},
|
|
158
160
|
)
|
|
159
|
-
monkeypatch.setattr(
|
|
161
|
+
monkeypatch.setattr(
|
|
162
|
+
Finalizer, "_build_model", staticmethod(lambda *_, **__: DummyModel())
|
|
163
|
+
)
|
|
160
164
|
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
161
165
|
|
|
162
166
|
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
@@ -200,7 +204,9 @@ def test_export_dataset_row_uses_strict_load_datasets(monkeypatch, tmp_path):
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
monkeypatch.setattr(export_module, "load_datasets", fake_load_datasets)
|
|
203
|
-
monkeypatch.setattr(
|
|
207
|
+
monkeypatch.setattr(
|
|
208
|
+
Finalizer, "_build_model", staticmethod(lambda *_, **__: DummyModel())
|
|
209
|
+
)
|
|
204
210
|
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
205
211
|
|
|
206
212
|
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
@@ -231,7 +237,9 @@ def test_export_dataset_npz_without_X_fails_clearly(monkeypatch, tmp_path):
|
|
|
231
237
|
"smoke": False,
|
|
232
238
|
},
|
|
233
239
|
)
|
|
234
|
-
monkeypatch.setattr(
|
|
240
|
+
monkeypatch.setattr(
|
|
241
|
+
Finalizer, "_build_model", staticmethod(lambda *_, **__: DummyModel())
|
|
242
|
+
)
|
|
235
243
|
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
236
244
|
|
|
237
245
|
with pytest.raises(ValueError, match="Required key 'X' not found"):
|
|
@@ -260,7 +268,9 @@ def test_export_legacy_row_with_valid_X_and_labels_succeeds(monkeypatch, tmp_pat
|
|
|
260
268
|
"smoke": False,
|
|
261
269
|
},
|
|
262
270
|
)
|
|
263
|
-
monkeypatch.setattr(
|
|
271
|
+
monkeypatch.setattr(
|
|
272
|
+
Finalizer, "_build_model", staticmethod(lambda *_, **__: DummyModel())
|
|
273
|
+
)
|
|
264
274
|
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
265
275
|
|
|
266
276
|
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
@@ -310,6 +320,69 @@ def test_export_svc_accepts_legacy_unprefixed_parameters():
|
|
|
310
320
|
assert model.named_steps["svc"].C == 1
|
|
311
321
|
|
|
312
322
|
|
|
323
|
+
def test_export_builds_stacking_classifier_with_expected_contract():
|
|
324
|
+
cv_config = {"n_splits": 2, "n_repeats": 1, "random_state": 42}
|
|
325
|
+
|
|
326
|
+
model = Finalizer._build_model(
|
|
327
|
+
"StackingClassifier",
|
|
328
|
+
"{}",
|
|
329
|
+
cv_config=cv_config,
|
|
330
|
+
random_state=42,
|
|
331
|
+
)
|
|
332
|
+
estimators = dict(model.estimators)
|
|
333
|
+
svc = estimators["svc"]
|
|
334
|
+
rf = estimators["rf"]
|
|
335
|
+
xgb = estimators["xgb"]
|
|
336
|
+
|
|
337
|
+
assert isinstance(model, StackingClassifier)
|
|
338
|
+
assert model.stack_method == "predict_proba"
|
|
339
|
+
assert model.passthrough is False
|
|
340
|
+
assert model.cv.cvargs["n_splits"] == 2
|
|
341
|
+
assert model.cv.n_repeats == 1
|
|
342
|
+
assert model.cv.random_state == 42
|
|
343
|
+
assert isinstance(svc, SklearnPipeline)
|
|
344
|
+
assert list(svc.named_steps) == ["scaler", "svc"]
|
|
345
|
+
assert isinstance(svc.named_steps["scaler"], StandardScaler)
|
|
346
|
+
assert isinstance(svc.named_steps["svc"], SVC)
|
|
347
|
+
assert svc.named_steps["svc"].probability is True
|
|
348
|
+
assert isinstance(rf, RandomForestClassifier)
|
|
349
|
+
assert isinstance(xgb, XGBClassifier)
|
|
350
|
+
assert not isinstance(rf, SklearnPipeline)
|
|
351
|
+
assert not isinstance(xgb, SklearnPipeline)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_export_can_rebuild_and_save_stacking_model(monkeypatch, tmp_path):
|
|
355
|
+
label_path, y = _write_labels(tmp_path)
|
|
356
|
+
dataset_path = _write_npz(tmp_path, "toy", np.ones((20, 5)), y)
|
|
357
|
+
cfg = _make_config(tmp_path)
|
|
358
|
+
cfg.CV_CONFIG = {"n_splits": 2, "n_repeats": 1, "random_state": 42}
|
|
359
|
+
cfg.DATASETS = {
|
|
360
|
+
"toy": {
|
|
361
|
+
"path": str(dataset_path),
|
|
362
|
+
"label_path": str(label_path),
|
|
363
|
+
"metadata": {"family": "smoke", "method": "toy"},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
csv_path = tmp_path / "output" / "results.csv"
|
|
367
|
+
_write_results_csv(
|
|
368
|
+
csv_path,
|
|
369
|
+
["dataset", "model_name", "parameters", "smoke"],
|
|
370
|
+
{
|
|
371
|
+
"dataset": "toy",
|
|
372
|
+
"model_name": "StackingClassifier",
|
|
373
|
+
"parameters": "{'rf__n_estimators': 2, 'xgb__n_estimators': 2}",
|
|
374
|
+
"smoke": False,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
378
|
+
|
|
379
|
+
Finalizer(csv_path, tmp_path / "output", cfg, row_index=0).run()
|
|
380
|
+
|
|
381
|
+
model = joblib.load(tmp_path / "output" / "Model_StackingClassifier_toy.pkl")
|
|
382
|
+
assert isinstance(model, StackingClassifier)
|
|
383
|
+
assert model.stack_method == "predict_proba"
|
|
384
|
+
|
|
385
|
+
|
|
313
386
|
def test_export_legacy_npz_without_X_does_not_fallback_to_first_key(
|
|
314
387
|
monkeypatch, tmp_path
|
|
315
388
|
):
|
|
@@ -334,7 +407,9 @@ def test_export_legacy_npz_without_X_does_not_fallback_to_first_key(
|
|
|
334
407
|
"smoke": False,
|
|
335
408
|
},
|
|
336
409
|
)
|
|
337
|
-
monkeypatch.setattr(
|
|
410
|
+
monkeypatch.setattr(
|
|
411
|
+
Finalizer, "_build_model", staticmethod(lambda *_, **__: DummyModel())
|
|
412
|
+
)
|
|
338
413
|
monkeypatch.setattr(Finalizer, "_cv_and_plot", lambda *args, **kwargs: None)
|
|
339
414
|
|
|
340
415
|
with pytest.raises(ValueError, match="Required key 'X' not found"):
|
|
@@ -7,11 +7,14 @@ import numpy as np
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from sklearn.ensemble import RandomForestClassifier
|
|
10
|
+
from sklearn.ensemble import StackingClassifier
|
|
11
|
+
from sklearn.model_selection import RepeatedStratifiedKFold
|
|
10
12
|
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
11
13
|
from sklearn.preprocessing import StandardScaler
|
|
12
14
|
from sklearn.svm import SVC
|
|
13
15
|
from xgboost import XGBClassifier
|
|
14
16
|
|
|
17
|
+
import melite.model_training as model_training
|
|
15
18
|
from melite.model_training import MultiModelTrainer
|
|
16
19
|
|
|
17
20
|
|
|
@@ -23,6 +26,7 @@ def _config(active_models):
|
|
|
23
26
|
{"model": ["svc"], "svc__C": [1], "svc__kernel": ["linear"]},
|
|
24
27
|
{"model": ["rf"], "n_estimators": [10]},
|
|
25
28
|
{"model": ["xgb"], "n_estimators": [10]},
|
|
29
|
+
{"model": ["stack"]},
|
|
26
30
|
],
|
|
27
31
|
get_cv_config=lambda: {
|
|
28
32
|
"n_splits": 2,
|
|
@@ -38,6 +42,7 @@ def _trainer_with_fake_training(active_models):
|
|
|
38
42
|
"svc": lambda: "svc-estimator",
|
|
39
43
|
"rf": lambda: "rf-estimator",
|
|
40
44
|
"xgb": lambda: "xgb-estimator",
|
|
45
|
+
"stack": lambda: "stack-estimator",
|
|
41
46
|
}
|
|
42
47
|
calls = []
|
|
43
48
|
|
|
@@ -110,6 +115,40 @@ def test_rf_and_xgb_builders_remain_unscaled_direct_estimators():
|
|
|
110
115
|
assert not isinstance(xgb, SklearnPipeline)
|
|
111
116
|
|
|
112
117
|
|
|
118
|
+
def test_stacking_builder_returns_experimental_stacking_classifier():
|
|
119
|
+
trainer = MultiModelTrainer(_config(["stack"]))
|
|
120
|
+
|
|
121
|
+
model = trainer.model_builders["stack"]()
|
|
122
|
+
estimators = dict(model.estimators)
|
|
123
|
+
svc = estimators["svc"]
|
|
124
|
+
rf = estimators["rf"]
|
|
125
|
+
xgb = estimators["xgb"]
|
|
126
|
+
|
|
127
|
+
assert isinstance(model, StackingClassifier)
|
|
128
|
+
assert model.stack_method == "predict_proba"
|
|
129
|
+
assert model.passthrough is False
|
|
130
|
+
assert isinstance(svc, SklearnPipeline)
|
|
131
|
+
assert list(svc.named_steps) == ["scaler", "svc"]
|
|
132
|
+
assert isinstance(svc.named_steps["scaler"], StandardScaler)
|
|
133
|
+
assert isinstance(svc.named_steps["svc"], SVC)
|
|
134
|
+
assert svc.named_steps["svc"].probability is True
|
|
135
|
+
assert isinstance(rf, RandomForestClassifier)
|
|
136
|
+
assert isinstance(xgb, XGBClassifier)
|
|
137
|
+
assert not isinstance(rf, SklearnPipeline)
|
|
138
|
+
assert not isinstance(xgb, SklearnPipeline)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_stacking_builder_reuses_repeated_stratified_cv_strategy():
|
|
142
|
+
trainer = MultiModelTrainer(_config(["stack"]))
|
|
143
|
+
|
|
144
|
+
model = trainer.model_builders["stack"]()
|
|
145
|
+
|
|
146
|
+
assert isinstance(model.cv, RepeatedStratifiedKFold)
|
|
147
|
+
assert model.cv.cvargs["n_splits"] == 2
|
|
148
|
+
assert model.cv.n_repeats == 1
|
|
149
|
+
assert model.cv.random_state == 42
|
|
150
|
+
|
|
151
|
+
|
|
113
152
|
def test_filter_param_grid_preserves_svc_pipeline_prefixes():
|
|
114
153
|
trainer = MultiModelTrainer(_config(["svc"]))
|
|
115
154
|
|
|
@@ -118,6 +157,48 @@ def test_filter_param_grid_preserves_svc_pipeline_prefixes():
|
|
|
118
157
|
assert grid == [{"svc__C": [1], "svc__kernel": ["linear"]}]
|
|
119
158
|
|
|
120
159
|
|
|
160
|
+
def test_filter_param_grid_supports_minimal_stack_grid():
|
|
161
|
+
trainer = MultiModelTrainer(_config(["stack"]))
|
|
162
|
+
|
|
163
|
+
grid = trainer._filter_param_grid("stack")
|
|
164
|
+
|
|
165
|
+
assert grid == [{}]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_grid_search_uses_f1_macro_scoring(monkeypatch):
|
|
169
|
+
captured = {}
|
|
170
|
+
|
|
171
|
+
class DummyGridSearchCV:
|
|
172
|
+
def __init__(self, model, param_grid, scoring, cv, n_jobs):
|
|
173
|
+
captured.update({
|
|
174
|
+
"model": model,
|
|
175
|
+
"param_grid": param_grid,
|
|
176
|
+
"scoring": scoring,
|
|
177
|
+
"cv": cv,
|
|
178
|
+
"n_jobs": n_jobs,
|
|
179
|
+
})
|
|
180
|
+
self.best_estimator_ = "best-estimator"
|
|
181
|
+
self.best_params_ = {"best": True}
|
|
182
|
+
|
|
183
|
+
def fit(self, X_train, y_train):
|
|
184
|
+
captured["fit_shape"] = X_train.shape
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(model_training, "GridSearchCV", DummyGridSearchCV)
|
|
187
|
+
trainer = MultiModelTrainer(_config(["svc"]))
|
|
188
|
+
|
|
189
|
+
estimator, params = trainer.perform_grid_search(
|
|
190
|
+
"estimator",
|
|
191
|
+
np.ones((4, 2)),
|
|
192
|
+
np.array([0, 1, 0, 1]),
|
|
193
|
+
[{"param": [1]}],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert captured["scoring"] == "f1_macro"
|
|
197
|
+
assert isinstance(captured["cv"], RepeatedStratifiedKFold)
|
|
198
|
+
assert estimator == "best-estimator"
|
|
199
|
+
assert params == {"best": True}
|
|
200
|
+
|
|
201
|
+
|
|
121
202
|
def test_invalid_active_model_raises_clear_error():
|
|
122
203
|
with pytest.raises(ValueError, match="Unknown active model\\(s\\): knn"):
|
|
123
204
|
MultiModelTrainer(_config(["svc", "knn"]))
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pytest
|
|
6
6
|
import joblib
|
|
7
|
+
from melite.export_best_model import Finalizer
|
|
7
8
|
from melite.predict import predict
|
|
8
9
|
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
9
10
|
from sklearn.preprocessing import StandardScaler
|
|
@@ -94,3 +95,22 @@ def test_predict_loads_exported_svc_pipeline(tmp_path):
|
|
|
94
95
|
|
|
95
96
|
assert result["predictions"].shape == (4,)
|
|
96
97
|
assert result["probabilities"].shape == (4, 2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_predict_loads_exported_stacking_classifier(tmp_path):
|
|
101
|
+
X_train = np.random.rand(20, 5).astype(np.float32)
|
|
102
|
+
y_train = np.array([0, 1] * 10, dtype=np.int64)
|
|
103
|
+
model = Finalizer._build_model(
|
|
104
|
+
"StackingClassifier",
|
|
105
|
+
"{'rf__n_estimators': 2, 'xgb__n_estimators': 2}",
|
|
106
|
+
cv_config={"n_splits": 2, "n_repeats": 1, "random_state": 42},
|
|
107
|
+
random_state=42,
|
|
108
|
+
)
|
|
109
|
+
model.fit(X_train, y_train)
|
|
110
|
+
model_path = tmp_path / "Model_StackingClassifier_toy.pkl"
|
|
111
|
+
joblib.dump(model, model_path)
|
|
112
|
+
|
|
113
|
+
result = predict(model_path, np.random.rand(4, 5).astype(np.float32))
|
|
114
|
+
|
|
115
|
+
assert result["predictions"].shape == (4,)
|
|
116
|
+
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
|
|
File without changes
|
|
File without changes
|