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.
Files changed (39) hide show
  1. {melite-0.2.2 → melite-0.2.3}/.gitignore +2 -0
  2. {melite-0.2.2 → melite-0.2.3}/CHANGELOG.md +25 -0
  3. {melite-0.2.2 → melite-0.2.3}/CITATION.cff +1 -1
  4. {melite-0.2.2 → melite-0.2.3}/PKG-INFO +18 -9
  5. {melite-0.2.2 → melite-0.2.3}/README.md +17 -8
  6. {melite-0.2.2 → melite-0.2.3}/melite/config.py +10 -3
  7. {melite-0.2.2 → melite-0.2.3}/melite/config_default.toml +1 -1
  8. {melite-0.2.2 → melite-0.2.3}/melite/export_best_model.py +63 -3
  9. {melite-0.2.2 → melite-0.2.3}/melite/model_training.py +38 -1
  10. {melite-0.2.2 → melite-0.2.3}/melite/version.py +1 -1
  11. {melite-0.2.2 → melite-0.2.3}/tests/test_export.py +80 -5
  12. {melite-0.2.2 → melite-0.2.3}/tests/test_model_training.py +81 -0
  13. {melite-0.2.2 → melite-0.2.3}/tests/test_predict.py +20 -0
  14. {melite-0.2.2 → melite-0.2.3}/COPYING +0 -0
  15. {melite-0.2.2 → melite-0.2.3}/COPYING.LESSER +0 -0
  16. {melite-0.2.2 → melite-0.2.3}/LICENSE +0 -0
  17. {melite-0.2.2 → melite-0.2.3}/environment.yml +0 -0
  18. {melite-0.2.2 → melite-0.2.3}/examples/example_config.toml +0 -0
  19. {melite-0.2.2 → melite-0.2.3}/examples/generate_sample_data.py +0 -0
  20. {melite-0.2.2 → melite-0.2.3}/melite/__init__.py +0 -0
  21. {melite-0.2.2 → melite-0.2.3}/melite/cli.py +0 -0
  22. {melite-0.2.2 → melite-0.2.3}/melite/load_dataset.py +0 -0
  23. {melite-0.2.2 → melite-0.2.3}/melite/main.py +0 -0
  24. {melite-0.2.2 → melite-0.2.3}/melite/plot_metrics.py +0 -0
  25. {melite-0.2.2 → melite-0.2.3}/melite/predict.py +0 -0
  26. {melite-0.2.2 → melite-0.2.3}/melite/result_manager.py +0 -0
  27. {melite-0.2.2 → melite-0.2.3}/pyproject.toml +0 -0
  28. {melite-0.2.2 → melite-0.2.3}/scripts/smoke_install_wheel.py +0 -0
  29. {melite-0.2.2 → melite-0.2.3}/tests/__init__.py +0 -0
  30. {melite-0.2.2 → melite-0.2.3}/tests/conftest.py +0 -0
  31. {melite-0.2.2 → melite-0.2.3}/tests/test_cli.py +0 -0
  32. {melite-0.2.2 → melite-0.2.3}/tests/test_config.py +0 -0
  33. {melite-0.2.2 → melite-0.2.3}/tests/test_examples.py +0 -0
  34. {melite-0.2.2 → melite-0.2.3}/tests/test_load_dataset.py +0 -0
  35. {melite-0.2.2 → melite-0.2.3}/tests/test_main.py +0 -0
  36. {melite-0.2.2 → melite-0.2.3}/tests/test_plot_metrics.py +0 -0
  37. {melite-0.2.2 → melite-0.2.3}/tests/test_public_api.py +0 -0
  38. {melite-0.2.2 → melite-0.2.3}/tests/test_result_manager.py +0 -0
  39. {melite-0.2.2 → melite-0.2.3}/tests/test_version.py +0 -0
@@ -19,3 +19,5 @@ site/
19
19
  .pytest_tmp
20
20
 
21
21
  dist/
22
+
23
+ .tmp_stack_smoke/
@@ -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.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.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
  [![CI](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml/badge.svg)](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
43
43
  [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](LICENSE)
44
- [![Version](https://img.shields.io/badge/version-v0.2.2-blue.svg)]()
44
+ [![Version](https://img.shields.io/badge/version-v0.2.3-blue.svg)]()
45
45
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)]()
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.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 XGBoost classifiers. | Process SMILES. |
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 `xgb`.
185
-
186
- SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn pipeline.
187
- Random Forest and XGBoost are trained as unscaled estimators.
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.2` branch targets:
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
  [![CI](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml/badge.svg)](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
4
4
  [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](LICENSE)
5
- [![Version](https://img.shields.io/badge/version-v0.2.2-blue.svg)]()
5
+ [![Version](https://img.shields.io/badge/version-v0.2.3-blue.svg)]()
6
6
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)]()
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.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 XGBoost classifiers. | Process SMILES. |
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 `xgb`.
146
-
147
- SVC is trained and exported as a `StandardScaler` -> `SVC` sklearn pipeline.
148
- Random Forest and XGBoost are trained as unscaled estimators.
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.2` branch targets:
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 ``"xgb"``.
301
+ Model key. One of ``"svc"``, ``"rf"``, ``"xgb"``, or ``"stack"``.
295
302
 
296
303
  Returns
297
304
  -------
@@ -21,5 +21,5 @@ n_splits = 3
21
21
  n_repeats = 1
22
22
 
23
23
  [models]
24
- # Active models to benchmark. Remove any entry to skip that model.
24
+ # Active models to benchmark. Add "stack" to opt in to experimental stacking.
25
25
  active = ["svc", "rf", "xgb"]
@@ -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(row.model_name, row.parameters)
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(name: str, serialised_params: str) -> Any:
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.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(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
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(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
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(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
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(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
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(Finalizer, "_build_model", staticmethod(lambda *_: DummyModel()))
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