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.
Files changed (39) hide show
  1. {melite-0.2.0 → melite-0.2.2}/CHANGELOG.md +38 -0
  2. {melite-0.2.0 → melite-0.2.2}/CITATION.cff +2 -2
  3. {melite-0.2.0 → melite-0.2.2}/PKG-INFO +24 -15
  4. {melite-0.2.0 → melite-0.2.2}/README.md +22 -14
  5. {melite-0.2.0 → melite-0.2.2}/melite/config.py +15 -10
  6. {melite-0.2.0 → melite-0.2.2}/melite/export_best_model.py +45 -10
  7. {melite-0.2.0 → melite-0.2.2}/melite/main.py +9 -1
  8. {melite-0.2.0 → melite-0.2.2}/melite/model_training.py +25 -3
  9. {melite-0.2.0 → melite-0.2.2}/melite/version.py +1 -1
  10. {melite-0.2.0 → melite-0.2.2}/pyproject.toml +1 -0
  11. melite-0.2.2/scripts/smoke_install_wheel.py +221 -0
  12. {melite-0.2.0 → melite-0.2.2}/tests/test_config.py +45 -0
  13. {melite-0.2.0 → melite-0.2.2}/tests/test_export.py +163 -4
  14. melite-0.2.2/tests/test_model_training.py +128 -0
  15. {melite-0.2.0 → melite-0.2.2}/tests/test_predict.py +21 -0
  16. {melite-0.2.0 → melite-0.2.2}/.gitignore +0 -0
  17. {melite-0.2.0 → melite-0.2.2}/COPYING +0 -0
  18. {melite-0.2.0 → melite-0.2.2}/COPYING.LESSER +0 -0
  19. {melite-0.2.0 → melite-0.2.2}/LICENSE +0 -0
  20. {melite-0.2.0 → melite-0.2.2}/environment.yml +0 -0
  21. {melite-0.2.0 → melite-0.2.2}/examples/example_config.toml +0 -0
  22. {melite-0.2.0 → melite-0.2.2}/examples/generate_sample_data.py +0 -0
  23. {melite-0.2.0 → melite-0.2.2}/melite/__init__.py +0 -0
  24. {melite-0.2.0 → melite-0.2.2}/melite/cli.py +0 -0
  25. {melite-0.2.0 → melite-0.2.2}/melite/config_default.toml +0 -0
  26. {melite-0.2.0 → melite-0.2.2}/melite/load_dataset.py +0 -0
  27. {melite-0.2.0 → melite-0.2.2}/melite/plot_metrics.py +0 -0
  28. {melite-0.2.0 → melite-0.2.2}/melite/predict.py +0 -0
  29. {melite-0.2.0 → melite-0.2.2}/melite/result_manager.py +0 -0
  30. {melite-0.2.0 → melite-0.2.2}/tests/__init__.py +0 -0
  31. {melite-0.2.0 → melite-0.2.2}/tests/conftest.py +0 -0
  32. {melite-0.2.0 → melite-0.2.2}/tests/test_cli.py +0 -0
  33. {melite-0.2.0 → melite-0.2.2}/tests/test_examples.py +0 -0
  34. {melite-0.2.0 → melite-0.2.2}/tests/test_load_dataset.py +0 -0
  35. {melite-0.2.0 → melite-0.2.2}/tests/test_main.py +0 -0
  36. {melite-0.2.0 → melite-0.2.2}/tests/test_plot_metrics.py +0 -0
  37. {melite-0.2.0 → melite-0.2.2}/tests/test_public_api.py +0 -0
  38. {melite-0.2.0 → melite-0.2.2}/tests/test_result_manager.py +0 -0
  39. {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.0"
6
- date-released: "2026-05-26"
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.0
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
  [![CI](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml/badge.svg)](https://github.com/NanoBiostructuresRG/melite/actions/workflows/ci.yml)
42
43
  [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](LICENSE)
43
- [![Version](https://img.shields.io/badge/version-v0.1.11-blue.svg)]()
44
+ [![Version](https://img.shields.io/badge/version-v0.2.2-blue.svg)]()
44
45
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)]()
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.0
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 0.2.0.
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.0` branch targets:
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
  [![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.1.11-blue.svg)]()
5
+ [![Version](https://img.shields.io/badge/version-v0.2.2-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.0
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 0.2.0.
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.0` branch targets:
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
- "kernel": ["linear"],
201
- "C": [1],
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
- "kernel": ["poly"],
227
- "C": [0.01, 0.1, 1, 10],
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
- "kernel": ["rbf"],
235
- "C": [0.01, 0.02, 0.1, 0.2, 1, 2, 10, 20],
236
- "gamma": [0.001, 0.002, 0.004, 0.008, 0.01, 0.02, 0.04, 0.08, 0.1, 0.2],
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
- New v0.2.0 result rows are resolved by their ``dataset`` id in
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
- spec = self._cfg.DATASETS[dataset_id]
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
- arr = np.load(fp)
133
- self._ensure_labels()
134
- return arr["X"] if "X" in arr.files else arr[arr.files[0]]
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
- return arr[key]
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
- params = {**params, "probability": True}
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.__class__.__name__
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: SVC(probability=True, random_state=rs),
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 in ``model_builders``, runs grid search followed by
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.model_builders:
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.0"
9
+ __version__ = "0.2.2"
10
10
  PROJECT_NAME = "MELITE"
11
11
  PROJECT_VERSION = __version__
12
12
  PROJECT_STATUS = "alpha"
@@ -50,6 +50,7 @@ dependencies = [
50
50
  dev = [
51
51
  "pytest>=7.0",
52
52
  "build>=1.0",
53
+ "hatchling>=1.18",
53
54
  "twine>=4.0",
54
55
  ]
55
56
  docs = [
@@ -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": "{'kernel': 'linear', 'C': 1}",
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 test_export_legacy_row_falls_back_to_reduction_and_level(monkeypatch, tmp_path):
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": "{'kernel': 'linear', 'C': 1}",
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": "{'kernel': 'linear', 'C': 1}",
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