Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
## [7.0.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v6.1.0...v7.0.0-rc.1) (2026-06-09)

### ⚠ BREAKING CHANGES

* spotforecast2 now requires spotoptim>=1.0 and
spotforecast2-safe>=21. The n_jobs_spotoptim config field is gone and SpotOptim
tuning is sequential-only; backtesting parallelism (n_jobs) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

### Features

* require spotoptim 1.0 (sequential) + sf2-safe 21, drop n_jobs_spotoptim ([ce81d8f](https://github.com/sequential-parameter-optimization/spotforecast2/commit/ce81d8f9190524b437844416875bf4b858988135))

## [6.1.0](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v6.0.0...v6.1.0) (2026-06-08)

### Features
Expand Down
8 changes: 3 additions & 5 deletions docs/model_selection/spotoptim_intro.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ tensorboard --logdir runs

TensorBoard shows `y_values/min`, `y_values/last`, `X_best/<param>` and
`success_rate` advancing as evaluations complete, plus one HParams entry
per evaluated configuration. This also works with parallel tuning
(`n_jobs_spotoptim != 1` / `kwargs_spotoptim={"n_jobs": -1}`):
evaluations run in worker processes and are logged parent-side as their
results arrive (requires `spotoptim >= 0.12.8`; older versions log only
the initial design in parallel mode).
per evaluated configuration. SpotOptim runs the tuning sequentially
(spotoptim &ge; 1.0 is sequential-only), so every evaluation is logged as
soon as it finishes.
22 changes: 15 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "spotforecast2"
version = "6.1.0"
version = "7.0.0-rc.1"
description = "Forecasting with spot"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
Expand Down Expand Up @@ -31,9 +31,17 @@ dependencies = [
"ruff>=0.15.6",
"scikit-learn>=1.8.0",
"shap>=0.49.1",
"spotforecast2-safe>=19.0.0,<21",
"spotoptim>=0.12.9",
"spotforecast2-safe>=21.0.0,<22",
# spotoptim 1.0 is sequential-only and lean: torch/tensorboard moved to its
# ``[torch]`` extra. sf2 forwards tensorboard_* kwargs into SpotOptim, so we
# pin the extra to keep the TensorBoard tuning dashboards working (they were
# always available via spotoptim's old hard torch dependency).
"spotoptim[torch]>=1.0.0,<2",
"tqdm>=4.67.2",
# Directly imported by spotforecast2.tasks.task_entsoe (and the xgb
# forecaster model). Previously satisfied transitively; declared explicitly
# now that spotoptim 1.0 no longer pulls it in.
"xgboost>=3.2.0",
]

[build-system]
Expand Down Expand Up @@ -75,10 +83,10 @@ spotforecast-n2o1-cov-df = "spotforecast2.tasks.task_n_to_1_with_covariates_and_
# pre-release marker. Re-add an rc marker to the spotforecast2-safe specifier
# (e.g. >=15.8.0rc1) to let sf2's CI integrate against an sf2-safe release
# candidate without waiting for the develop→main promotion; every other
# dependency stays on stable. The current floor tracks the stable release that
# added the holiday-adjacency feature surface — get_holiday_adjacency_features
# and the include_holiday_adjacency_features config flag
# (spotforecast2-safe>=15.9.0), which multitask.base imports unconditionally.
# dependency stays on stable. The current floor tracks the breaking
# spotforecast2-safe 21.0.0 release, which dropped the now-dead
# n_jobs_spotoptim config field in lockstep with spotoptim 1.0
# (sequential-only); sf2 no longer reads or forwards it.
prerelease = "explicit"

[tool.pytest.ini_options]
Expand Down
107 changes: 8 additions & 99 deletions src/spotforecast2/model_selection/spotoptim_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import ast
import logging
import multiprocessing
import warnings
from copy import deepcopy
from typing import Any, Callable, Dict
Expand Down Expand Up @@ -288,8 +287,6 @@ def spotoptim_objective(
all_lags: list,
all_params: list[dict],
n_trials: int | None = None,
config_counter: object | None = None,
config_counter_lock: object | None = None,
) -> np.ndarray:
"""SpotOptim objective function to evaluate hyperparameter sets.

Expand Down Expand Up @@ -319,17 +316,6 @@ def spotoptim_objective(
shown as a prefix on each per-fold progress bar (when
``show_progress`` is ``True``). When ``None``, the label omits the
total and reads "config k". Does not affect the optimisation.
config_counter: Optional shared running count of started config
evaluations (a `multiprocessing.Manager().Value("i", 0)` proxy).
With SpotOptim ``n_jobs > 1`` each evaluation runs in a worker
process holding its own (empty) copy of ``all_params``, so
``len(all_params) + 1`` is stuck at 1 there; the manager-backed
counter is the only cross-process source for the "config k/N"
label. When ``None``, the label falls back to
``len(all_params) + 1`` (correct in sequential mode).
config_counter_lock: Lock proxy (`multiprocessing.Manager().Lock()`)
guarding ``config_counter`` increments. Required together with
``config_counter``.

Returns:
np.ndarray: 1D array of results for the primary metric.
Expand Down Expand Up @@ -374,19 +360,12 @@ def set_lags(self, lags): pass

if cv_name == "TimeSeriesFold":
# Coarse-grained progress: prefix the per-fold bar with the running
# count of evaluated candidate configurations. With a shared
# ``config_counter`` (parallel SpotOptim) the count is incremented
# atomically across worker processes; otherwise ``all_params`` has
# count of evaluated candidate configurations. ``all_params`` has
# one entry per already-completed candidate, so this candidate is
# number ``len(all_params) + 1``. Built only when the bar is shown.
progress_desc = None
if show_progress:
if config_counter is not None and config_counter_lock is not None:
with config_counter_lock:
config_counter.value += 1
config_idx = config_counter.value
else:
config_idx = len(all_params) + 1
config_idx = len(all_params) + 1
progress_desc = (
f"config {config_idx}/{n_trials}"
if n_trials is not None
Expand Down Expand Up @@ -595,41 +574,14 @@ def spotoptim_search(
all_lags: list = []
all_params: list[dict] = []

# With SpotOptim ``n_jobs != 1`` every objective evaluation runs in a
# worker process that receives a dill copy of the optimizer — including
# this closure and its accumulator lists — so parent-side state never
# reaches the workers and worker-side appends never come back. Two
# consequences for progress display:
# * the "config k/N" label cannot be derived from ``len(all_params)``
# (stuck at 1 in every worker); a manager-backed shared counter is
# the only cross-process source for k.
# * a parent-side trial bar would never be updated (its ``update()``
# runs on worker copies), so it is created in sequential mode only.
spotoptim_n_jobs = kwargs_spotoptim_.get("n_jobs", 1)
parallel_eval = isinstance(spotoptim_n_jobs, int) and spotoptim_n_jobs != 1

config_counter = None
config_counter_lock = None
counter_manager = None
# NOT gated on ``show_progress``: the per-config fold bars are always on
# (``_objective_wrapper`` passes ``show_progress=True`` to the objective
# below), so the label needs the shared counter in every parallel run —
# the MultiTask pipeline reaches this with ``show_progress=False`` and
# would otherwise show frozen "config 1/N" labels again.
if parallel_eval:
counter_manager = multiprocessing.Manager()
config_counter = counter_manager.Value("i", 0)
config_counter_lock = counter_manager.Lock()

# Single trial-level progress bar (sequential mode only, see above).
# Each entry in ``X`` is one trial (initial design point or sequential
# proposal), so we advance by ``len(X)`` per objective call.
# ``n_trials`` (== SpotOptim's ``max_iter``) is the total budget — it
# already includes the ``n_initial`` design points, so the total bar
# length is ``n_trials``.
# Single trial-level progress bar. Each entry in ``X`` is one trial
# (initial design point or sequential proposal), so we advance by
# ``len(X)`` per objective call. ``n_trials`` (== SpotOptim's
# ``max_iter``) is the total budget — it already includes the
# ``n_initial`` design points, so the total bar length is ``n_trials``.
trial_bar = (
tqdm(total=n_trials, desc="SpotOptim trials", leave=True)
if show_progress and not parallel_eval
if show_progress
else None
)

Expand All @@ -654,8 +606,6 @@ def _objective_wrapper(X: np.ndarray) -> np.ndarray:
all_lags=all_lags,
all_params=all_params,
n_trials=n_trials,
config_counter=config_counter,
config_counter_lock=config_counter_lock,
)
if trial_bar is not None:
trial_bar.update(len(X))
Expand All @@ -680,47 +630,6 @@ def _objective_wrapper(X: np.ndarray) -> np.ndarray:
finally:
if trial_bar is not None:
trial_bar.close()
if counter_manager is not None:
counter_manager.shutdown()

# --- Parallel-safe result recovery ------------------------------------
# With ``n_jobs > 1`` SpotOptim evaluates the objective in worker
# processes (ProcessPoolExecutor on a GIL build), so the side-effect
# accumulators above are populated only in the workers and stay empty in
# this (parent) process. Rebuild them from the optimizer's own evaluation
# history, which *is* retained parent-side (``optimizer.X_`` / ``y_``).
# Decoding mirrors the objective: ``array_to_params`` handles both the
# string-label and integer-code factor encodings, and ``X_`` is stored in
# natural scale. Only the primary metric is recoverable this way -- the
# optimizer minimises a single scalar -- so any secondary metrics are
# filled with NaN.
if (
not all_lags
and getattr(optimizer, "X_", None) is not None
and len(optimizer.X_) > 0
):
if len(metric) > 1:
warnings.warn(
"Parallel SpotOptim (n_jobs > 1) records only the primary "
f"metric ('{metric[0] if isinstance(metric[0], str) else metric[0].__name__}'); "
"secondary metrics are reported as NaN.",
IgnoredArgumentWarning,
)
y_hist = np.asarray(optimizer.y_, dtype=float).ravel()
for row, y_val in zip(np.asarray(optimizer.X_, dtype=object), y_hist):
params_dict = array_to_params(np.asarray(row), var_name, var_type, bounds)
sample_params = {k: v for k, v in params_dict.items() if k != "lags"}
lags_val = params_dict.get(
"lags",
forecaster_search.lags if hasattr(forecaster_search, "lags") else None,
)
if isinstance(lags_val, str):
lags_val = parse_lags_from_strings(lags_val)
all_lags.append(lags_val)
all_params.append(sample_params)
all_metric_values.append(
[float(y_val)] + [float("nan")] * (len(metric) - 1)
)

# --- Build results DataFrame ------------------------------------------
lags_list = [
Expand Down
18 changes: 4 additions & 14 deletions src/spotforecast2/multitask/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,16 @@ def prepare_forecaster(
is wiped at search start); set ``cfg.tensorboard_clean = False``
to keep accumulating event files instead — do that, or give each
task its own ``tensorboard_path``, when several tasks share one
log directory. Live per-eval scalars under parallel tuning
(``n_jobs_spotoptim != 1``) require ``spotoptim >= 0.12.8``.
log directory.

Args:
task: A `BaseTask` (or compatible) instance that supplies ``cv_ts``,
``config``, ``logger``, ``save_tuning_results``, and
``create_forecaster``. The config must expose
``n_trials_spotoptim``, ``n_initial_spotoptim``,
``random_state``, ``warm_start_lags``, and optionally
``lags_consider``, ``n_jobs_spotoptim``, and the
TensorBoard knobs described above.
``lags_consider`` and the TensorBoard knobs described
above.
target: Target column name; forwarded to ``task.create_forecaster``
and ``task.save_tuning_results``.
forecaster: An unfitted forecaster instance used as the search
Expand Down Expand Up @@ -283,7 +282,6 @@ def prepare_forecaster(
n_initial_spotoptim=3,
random_state=0,
warm_start_lags=False,
n_jobs_spotoptim=None,
)
task = types.SimpleNamespace(
config=cfg,
Expand Down Expand Up @@ -334,19 +332,11 @@ def prepare_forecaster(
kwargs_spotoptim["x0"] = x0
task.logger.info(" Warm-start lags seeded: %s", seed_str)

# Parallel evaluation: forward the configured worker count straight to
# SpotOptim (``kwargs_spotoptim`` is spread into its constructor).
n_jobs_spotoptim = getattr(task.config, "n_jobs_spotoptim", None)
if n_jobs_spotoptim is not None:
kwargs_spotoptim["n_jobs"] = n_jobs_spotoptim
task.logger.info(" SpotOptim n_jobs: %s", n_jobs_spotoptim)

# Tuning visualization: forward TensorBoard knobs to SpotOptim. Users
# set these as plain attributes on the config (the safe-package config
# classes carry no __slots__, so no spotforecast2_safe change is
# needed). Unset attributes are skipped so SpotOptim's own defaults
# (tensorboard_log=False) stay in charge. Requires spotoptim >= 0.12.8
# for live per-eval logging under n_jobs != 1.
# (tensorboard_log=False) stay in charge.
tb_kwargs = {
key: getattr(task.config, key)
for key in ("tensorboard_log", "tensorboard_path", "tensorboard_clean")
Expand Down
3 changes: 1 addition & 2 deletions tests/test_multitask_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ def _make_fake_task(**config_extra):
n_initial_spotoptim=1,
random_state=0,
warm_start_lags=False,
n_jobs_spotoptim=None,
**config_extra,
)
return types.SimpleNamespace(
Expand Down Expand Up @@ -203,5 +202,5 @@ def fake_search_forecaster(*args, **kwargs):
task, "A", _FakeForecaster(), y_train=None
)

# n_jobs_spotoptim=None and no tensorboard attrs -> empty dict -> None.
# No tensorboard attrs -> empty kwargs dict -> None passed through.
assert captured["kwargs_spotoptim"] is None
Loading