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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## [5.1.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v5.0.0...v5.1.0-rc.1) (2026-06-07)

### Features

* **multitask:** expose TensorBoard tuning visualization via SpotOptimStrategy ([3f5770c](https://github.com/sequential-parameter-optimization/spotforecast2/commit/3f5770c012b3c0ea10b66fecab2fd3fbbe872cc1))

### Bug Fixes

* **model_selection:** show real config k/N progress labels in parallel SpotOptim search ([9fb8ced](https://github.com/sequential-parameter-optimization/spotforecast2/commit/9fb8ced39e0f6694a43e329876898cefde11776f))
* **plots:** make prediction figures safe for direct fig.write_image() ([2c9328f](https://github.com/sequential-parameter-optimization/spotforecast2/commit/2c9328fda577ed269f46c8b62c711235b0999f86))

### Documentation

* add live {python} Examples to all public symbols missing them ([eabfd98](https://github.com/sequential-parameter-optimization/spotforecast2/commit/eabfd9867617218e5071609338d6e0dddfb6c70e))

## [5.0.0](https://github.com/sequential-parameter-optimization/spotforecast2/compare/v4.0.0...v5.0.0) (2026-06-06)

### ⚠ BREAKING CHANGES
Expand Down
38 changes: 38 additions & 0 deletions docs/model_selection/spotoptim_intro.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,41 @@ print(results_adv[["n_estimators", "max_depth", "lags", "mean_absolute_error"]].
```

This advanced setup illustrates how `spotoptim_search_forecaster` acts as an extremely flexible orchestration bridge, allowing you to efficiently explore your state space while capturing comprehensive performance metadata.

## Live Tuning Visualization with TensorBoard

SpotOptim can stream the ongoing search to TensorBoard: pass the knobs
through `kwargs_spotoptim`, or — when tuning via the `MultiTask` pipeline —
set them as plain attributes on the config (the `SpotOptimStrategy`
forwards them automatically; no config-class change is needed):

```python
# Direct search call
results, optimizer = spotoptim_search_forecaster(
...,
kwargs_spotoptim={
"tensorboard_log": True,
"tensorboard_path": "runs/my_tuning_run",
},
)

# MultiTask pipeline
cfg = ConfigEntsoe(...)
cfg.tensorboard_log = True
cfg.tensorboard_path = "runs/my_tuning_run" # optional; defaults to runs/<stamp>
cfg.tensorboard_clean = False # True wipes old runs/ subdirs first
```

Then watch the run live:

```bash
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).
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "spotforecast2"
version = "5.0.0"
version = "5.1.0-rc.1"
description = "Forecasting with spot"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
Expand Down Expand Up @@ -33,7 +33,7 @@ dependencies = [
"scikit-learn>=1.8.0",
"shap>=0.49.1",
"spotforecast2-safe>=18.0.0,<19",
"spotoptim>=0.12.3",
"spotoptim>=0.12.8",
"tqdm>=4.67.2",
]

Expand Down Expand Up @@ -98,5 +98,6 @@ line-length = 88
target-version = "py313"

# [tool.uv.sources]
# Local editable installuses ../spotforecast2-safe instead of PyPI:
# Local editable installsuse the sibling checkouts instead of PyPI:
# spotforecast2-safe = { path = "../spotforecast2-safe", editable = true }
# spotoptim = { path = "../spotoptim", editable = true }
70 changes: 57 additions & 13 deletions src/spotforecast2/model_selection/spotoptim_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import ast
import logging
import multiprocessing
import warnings
from copy import deepcopy
from typing import Any, Callable, Dict
Expand Down Expand Up @@ -287,6 +288,8 @@ 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 @@ -316,6 +319,17 @@ 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 @@ -360,12 +374,19 @@ 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. ``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.
# count of evaluated candidate configurations. With a shared
# ``config_counter`` (parallel SpotOptim) the count is incremented
# atomically across worker processes; otherwise ``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:
config_idx = len(all_params) + 1
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
progress_desc = (
f"config {config_idx}/{n_trials}"
if n_trials is not None
Expand Down Expand Up @@ -574,17 +595,36 @@ def spotoptim_search(
all_lags: list = []
all_params: list[dict] = []

# 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``.
# The inner backtesting fold loop is forced silent below to avoid the
# per-trial bar spam that used to stack dozens of fast "100% 35/35"
# bars in notebook output.
# 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
if show_progress and 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``.
trial_bar = (
tqdm(total=n_trials, desc="SpotOptim trials", leave=True)
if show_progress
if show_progress and not parallel_eval
else None
)

Expand All @@ -609,6 +649,8 @@ 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 @@ -633,6 +675,8 @@ 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
Expand Down
28 changes: 27 additions & 1 deletion src/spotforecast2/multitask/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,24 @@ def prepare_forecaster(
) -> Any:
"""Run SpotOptim surrogate search and return a forecaster with best params.

Live tuning visualization: when the config carries the optional
attributes ``tensorboard_log`` (bool), ``tensorboard_path`` (str)
or ``tensorboard_clean`` (bool), they are forwarded to the
SpotOptim constructor and the ongoing search can be watched with
``tensorboard --logdir <path>`` (defaults to ``runs/``). Set them
as plain attributes, e.g. ``cfg.tensorboard_log = True`` — no
config-class change is required. Live per-eval scalars under
parallel tuning (``n_jobs_spotoptim != 1``) require
``spotoptim >= 0.12.8``.

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`` and ``n_jobs_spotoptim``.
``lags_consider``, ``n_jobs_spotoptim``, 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 @@ -325,6 +336,21 @@ def prepare_forecaster(
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.
tb_kwargs = {
key: getattr(task.config, key)
for key in ("tensorboard_log", "tensorboard_path", "tensorboard_clean")
if getattr(task.config, key, None) is not None
}
if tb_kwargs:
kwargs_spotoptim.update(tb_kwargs)
task.logger.info(" SpotOptim TensorBoard: %s", tb_kwargs)

tuning_results, _ = spotoptim_search_forecaster(
forecaster=forecaster,
y=y_train,
Expand Down
37 changes: 25 additions & 12 deletions src/spotforecast2/plots/plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,25 @@

import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from spotforecast2_safe.data.fetch_data import get_data_home

logger = logging.getLogger(__name__)


def _iso_x(index: pd.DatetimeIndex) -> list[str]:
"""ISO-8601 strings for a Plotly date axis.

kaleido v1 serialises figures via orjson, which rejects the raw pandas
Timestamps a tz-aware ``DatetimeIndex`` leaves in trace x arrays (object
dtype) — direct ``fig.write_image()`` then fails with "Type is not JSON
serializable: Timestamp". Plotly date axes accept ISO-8601 strings
natively, so coercing at trace-build time keeps the figure JSON-safe
without changing how it renders.
"""
return [ts.isoformat() for ts in index]


class PredictionFigure:
"""
Encapsulates the generation of an interactive Plotly figure for predictions.
Expand Down Expand Up @@ -185,15 +199,15 @@ def make_plot(self) -> go.Figure:
# --- Traces ---
self.fig.add_trace(
go.Scatter(
x=y_actual_visible.index,
x=_iso_x(y_actual_visible.index),
y=y_actual_visible,
mode="lines+markers",
name=actual_legend,
)
)
self.fig.add_trace(
go.Scatter(
x=y_pred_visible.index,
x=_iso_x(y_pred_visible.index),
y=y_pred_visible,
mode="lines+markers",
name=pred_legend,
Expand All @@ -210,7 +224,7 @@ def make_plot(self) -> go.Figure:
)
self.fig.add_trace(
go.Scatter(
x=y_forecast.index,
x=_iso_x(y_forecast.index),
y=y_forecast,
mode="lines+markers",
name=forecast_legend,
Expand All @@ -220,7 +234,7 @@ def make_plot(self) -> go.Figure:
if test_actual is not None and len(test_actual) > 0:
self.fig.add_trace(
go.Scatter(
x=test_actual.index,
x=_iso_x(test_actual.index),
y=test_actual,
mode="lines+markers",
name="Actual (test / ground truth)",
Expand All @@ -231,7 +245,7 @@ def make_plot(self) -> go.Figure:

self.fig.add_trace(
go.Scatter(
x=y_last_week_visible.index,
x=_iso_x(y_last_week_visible.index),
y=y_last_week_visible,
mode="lines+markers",
line=dict(dash="dash"),
Expand Down Expand Up @@ -260,18 +274,18 @@ def make_plot(self) -> go.Figure:
xaxis=dict(title="Time (UTC)"),
yaxis=dict(title="Load"),
)
self.fig.update_xaxes(range=[min_range, max_range])
self.fig.update_xaxes(range=[min_range.isoformat(), max_range.isoformat()])
self.fig.update_yaxes(range=[y_min - y_margin, y_max + y_margin])

# Vertical marker at end of training
self.fig.add_vline(
x=end_training,
x=end_training.isoformat(),
line_width=2,
line_color="black",
line_dash="dash",
)
self.fig.add_annotation(
x=end_training,
x=end_training.isoformat(),
text="End of Training",
showarrow=False,
yref="paper",
Expand Down Expand Up @@ -336,10 +350,9 @@ def save_to_file(
if not path.parent.exists():
raise FileNotFoundError(f"Parent directory does not exist: {path.parent}")
# kaleido v1 cannot serialise pandas Timestamp axis values directly
# (orjson rejects them). Round-trip through Plotly's own JSON
# encoder, which coerces Timestamps to ISO strings.
import plotly.io as pio

# (orjson rejects them). make_plot() already returns a JSON-safe
# figure; the round-trip here keeps the method safe for traces
# added to self.fig after make_plot().
pio.from_json(self.fig.to_json()).write_image(str(path), **kwargs)
return path

Expand Down
Loading