Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bb7d41c
feat : Implement a unified hyperparameter tuning interface decoupled …
Muhammad-Rebaal Jul 3, 2026
0c691db
feat : Implement a unified hyperparameter tuning interface decoupled …
Muhammad-Rebaal Jul 7, 2026
772fa91
fix pytest
Muhammad-Rebaal Jul 7, 2026
8301098
feat: Implemented Design wrapper similar to sktime
Muhammad-Rebaal Jul 12, 2026
5133352
Merge branch 'main' into optimization
Muhammad-Rebaal Jul 14, 2026
bac8e87
passed the cfgs to class,added test and adjust the param_grid
Muhammad-Rebaal Jul 15, 2026
8a8f97b
Merge branch 'main' into optimization
Muhammad-Rebaal Jul 21, 2026
4dcd61c
ENH: Tags are taken _BasePtForecasterV2, lightening tuner functions a…
Muhammad-Rebaal Jul 22, 2026
cdd7f85
Merge branch 'optimization' of https://github.com/Muhammad-Rebaal/pyt…
Muhammad-Rebaal Jul 22, 2026
39d5122
ENH: Tags are taken _BasePtForecasterV2, lightening tuner functions a…
Muhammad-Rebaal Jul 22, 2026
928b8a4
Merge branch 'main' of https://github.com/Muhammad-Rebaal/pytorch-for…
Muhammad-Rebaal Aug 12, 2026
b6bf90a
Merge branch 'main' of https://github.com/Muhammad-Rebaal/pytorch-for…
Muhammad-Rebaal Aug 22, 2026
4ff83c2
Merge branch 'main' into optimization
Muhammad-Rebaal Aug 22, 2026
f126396
code cleaned
Muhammad-Rebaal Aug 22, 2026
5788e74
Merge branch 'optimization' of https://github.com/Muhammad-Rebaal/pyt…
Muhammad-Rebaal Aug 22, 2026
761d72e
Merge branch 'main' into optimization
Muhammad-Rebaal Aug 22, 2026
bed2247
HyperparameterTuner Feature Implemented
Muhammad-Rebaal Aug 26, 2026
3c8d8d1
Fix Code Quality
Muhammad-Rebaal Aug 26, 2026
786ac9a
Fix Soft Depency issue
Muhammad-Rebaal Aug 26, 2026
52b28ab
Merge branch 'main' into optimization
Muhammad-Rebaal Aug 26, 2026
1470d12
HyperparameterTuner implemented without the need of datamodule_cfg an…
Muhammad-Rebaal Aug 30, 2026
c341a12
Merge branch 'main' into optimization
Muhammad-Rebaal Aug 31, 2026
ee7f848
Added an edge case
Muhammad-Rebaal Aug 31, 2026
d02820a
Merge branch 'optimization' of https://github.com/Muhammad-Rebaal/pyt…
Muhammad-Rebaal Aug 31, 2026
7d03d39
datamodule_cls variable added into the HyperparameterTuner Class
Muhammad-Rebaal Sep 8, 2026
c8f13d5
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 8, 2026
a2a2a7e
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 10, 2026
2eaa29c
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 11, 2026
87f018e
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 14, 2026
f7662c3
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 16, 2026
300ec2b
cleaned up the test file and added the example
Muhammad-Rebaal Sep 16, 2026
f86629c
Merge branch 'optimization' of https://github.com/Muhammad-Rebaal/pyt…
Muhammad-Rebaal Sep 16, 2026
51e328c
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 18, 2026
51fe98f
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 25, 2026
78fd85c
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 28, 2026
f838efd
Merge branch 'main' into optimization
Muhammad-Rebaal Sep 29, 2026
076338c
Merge branch 'main' into optimization
Muhammad-Rebaal Oct 10, 2026
7a10b01
Added Hparam tuning for b_model params
Muhammad-Rebaal Oct 11, 2026
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
3 changes: 2 additions & 1 deletion pytorch_forecasting/tuning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tuning utilities for PyTorch Forecasting."""

from pytorch_forecasting.tuning.hyperparameter_tuner import HyperparameterTuner
from pytorch_forecasting.tuning.tuner import Tuner

__all__ = ["Tuner"]
__all__ = ["Tuner", "HyperparameterTuner"]
268 changes: 268 additions & 0 deletions pytorch_forecasting/tuning/hyperparameter_tuner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
"""Hyperparameter tuning for v2 forecasters"""

import inspect

from lightning.pytorch.core.datamodule import LightningDataModule
import torch.nn as nn

from pytorch_forecasting.data import TimeSeries


class HyperparameterTuner:
def __init__(
self,
model_cls,
data,
datamodule_cls: type[LightningDataModule] | None = None,
**fixed_hparams,
):
"""Set up the tuner, build the datamodule once, and validate inputs.

Parameters
----------
model_cls : type
The model class directly (e.g., DLinear, TFT).
data : TimeSeries or LightningDataModule
Dataset to split and reuse across all trials. If passing a
TimeSeries, ``datamodule_cls`` must be provided.
datamodule_cls : type[LightningDataModule], optional
The DataModule class to construct when ``data`` is a TimeSeries,
or against which to validate when ``data`` is a prebuilt DataModule.
**fixed_hparams
Any model parameter that should stay constant, e.g.
``hidden_size=128``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add some examples here to how to use this

"""
self.model_cls = model_cls
self.fixed_hparams = fixed_hparams
self._validate_fixed_hparams()

# 1. Validate datamodule_cls if provided
if datamodule_cls is not None:
if not (
isinstance(datamodule_cls, type)
and issubclass(datamodule_cls, LightningDataModule)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this validation? if we add type hints etc, it would be understood that it is a LightningDataModule no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok sure

):
raise TypeError(
f"datamodule_cls must be a subclass of LightningDataModule, "
f"got {datamodule_cls}."
)

# 2. Handle data input
if isinstance(data, TimeSeries):
if datamodule_cls is None:
raise ValueError(
"When passing a TimeSeries dataset, 'datamodule_cls' must "
"be specified: "
"(e.g., datamodule_cls=EncoderDecoderTimeSeriesDataModule)."
)
self.datamodule = datamodule_cls(data)
self.datamodule.setup(stage="fit")

elif isinstance(data, LightningDataModule):
if datamodule_cls is not None and not isinstance(data, datamodule_cls):
raise TypeError(
f"Expected datamodule of type {datamodule_cls.__name__}, "
f"got {type(data).__name__}."
)
data.setup("fit")
self.datamodule = data

else:
raise TypeError(
f"data must be a TimeSeries dataset or LightningDataModule, "
f"got {type(data).__name__}."
)

self._metadata = self.datamodule.metadata

def _validate_fixed_hparams(self):
"""Raise early if the user passed a parameter name
that the model doesn't accept.
"""
sig = inspect.signature(self.model_cls.__init__)
valid_param_names = sig.parameters.keys()

for param_name in self.fixed_hparams:
if param_name not in valid_param_names and param_name != "self":
raise ValueError(
f"'{param_name}' is not a valid parameter of "
f"{self.model_cls.__name__}. "
f"Valid parameters: {list(valid_param_names)}"
)

def _discover_hyperparameters(self, trial, custom_ranges=None):
"""Build a sampled config dict for one Optuna trial.

Reads the model's ``__init__`` signature, skips base-class and
user-fixed params, then picks values using ``custom_ranges`` when
given or simple heuristics based on the default value's type.

Parameters
----------
trial : optuna.Trial
Current trial whose ``suggest_*`` methods are called.
custom_ranges : dict, optional
Per-param overrides, e.g. ``{"dropout": (0.1, 0.5)}`` or
``{"hidden_size": [32, 64, 128]}``.

Returns
-------
dict
Sampled hyperparameters for this trial.
"""
import typing

from pytorch_forecasting.models.base._base_model_v2 import BaseModel

base_params = set(inspect.signature(BaseModel.__init__).parameters.keys())
sig = inspect.signature(self.model_cls.__init__)

sampled_cfg = {}
custom_ranges = custom_ranges or {}

for param_name, param_obj in sig.parameters.items():
if param_name in base_params or param_name == "self":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here what will happen to the params which are sent to theBaseModel? Like Samformer sends optimizer etc directly to base model:

super().__init__(
loss=loss,
logging_metrics=logging_metrics,
optimizer=optimizer,
optimizer_params=optimizer_params,
lr_scheduler=lr_scheduler,
lr_scheduler_params=lr_scheduler_params,
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it gonna skip, in my mind we're tuning the model specific ones. I think skipping self and logging_metrics instead of whole base_model param would be a cleaner fix

@phoeenniixx phoeenniixx Sep 28, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but should we also skip optimizer, lr_scheduler etc?

@Muhammad-Rebaal Muhammad-Rebaal Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For optimizer_params , lr_scheduler , these 2 have default none it would just auto skip as we've a logic:

            if (
                default is inspect.Parameter.empty
                or default is None
                or isinstance(default, (dict, list))
            ):
                continue
  • For optimizer and loss, I think if we skip the users would lost the ability to pass their custom ranges ?

  • Optuna can't tune dictionaries like optimizer_params directly anyway. Since the learning rate is universally critical I think we just inject it directly inside the _objective function (just like the old TFT tuning.py did):

if "optimizer_params" not in self.fixed_hparams:
    lr = trial.suggest_float("lr", 1e-5, 1e-1, log=True)
    model_cfg["optimizer_params"] = {"lr": lr}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think i dont understand the issue - i think we need to optimise some of the params atleast - like lr_scheduler, no? So, how should we do that?

Can you please elaborate on this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought above you're telling me not to tune lr_scheduler that's why I said we already have a logic to skip and suggested what we can do with other params.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My ques is that how are these params optimized here? Are we optimizing lr_scheduler? And come to think of it, for lr, we can also use tuner from lightning, no? That would be better?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, for not being clear :)
I just want to know how are the params that we are passing to base_model directly being optimized?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • In the latest push, I just added handling for optimizer, optimizer_param, lr_scheduler and
    lr_scheduler_params, it samples the optimizer from BaseModel._OPTIMIZER_REGISTRY, tunes the learning rate on a log scale under optimizer_params, and even dynamically tunes the schdular specifc params like patience , gamma, etc, all of these would be passed to base model for each trail. Before this push it would just skip.

  • Lightening Tuner is just an lr_find heuristic. Tuning with Optuna would be better because, it would perform Bayesian optimization tuning the learning rate jointly with the optimizer and scheduler dynamics. In contrast, Lightning Tuner estimates the LR in isolation without considering schedulers, adds extra warm-up overhead to every single trial, and is often noisy/unstable on time-series loss curves.

continue

if param_name in self.fixed_hparams:
continue

if param_name in custom_ranges:
r = custom_ranges[param_name]
if isinstance(r, list):
sampled_cfg[param_name] = trial.suggest_categorical(param_name, r)
else:
low, high = r
if (
isinstance(param_obj.default, int)
and type(param_obj.default) is not bool
):
sampled_cfg[param_name] = trial.suggest_int(
param_name, low, high
)
else:
sampled_cfg[param_name] = trial.suggest_float(
param_name, low, high
)
continue

default = param_obj.default

if (
default is inspect.Parameter.empty
or default is None
or isinstance(default, (dict, list))
):
continue

if type(default) is bool:
sampled_cfg[param_name] = trial.suggest_categorical(
param_name, [True, False]
)

elif isinstance(default, int):
if default <= 8:
low = max(1, default - 2)
high = default + 2
sampled_cfg[param_name] = trial.suggest_int(param_name, low, high)
else:
low = max(1, default // 4)
high = default * 4
sampled_cfg[param_name] = trial.suggest_int(
param_name, low, high, log=True
)

elif isinstance(default, float):
if default <= 1.0:
low = max(0.0, default / 2)
high = min(1.0, default * 3) if default > 0.0 else 0.1
sampled_cfg[param_name] = trial.suggest_float(param_name, low, high)
else:
low = default / 4
high = default * 4
sampled_cfg[param_name] = trial.suggest_float(
param_name, low, high, log=True
)

elif typing.get_origin(param_obj.annotation) is typing.Literal:
choices = list(typing.get_args(param_obj.annotation))
sampled_cfg[param_name] = trial.suggest_categorical(param_name, choices)

return sampled_cfg

def optimize(
self,
n_trials=100,
timeout=3600 * 8,
max_epochs=20,
custom_ranges=None,
study=None,
direction="minimize",
):
"""Run the Optuna study.

Parameters
----------
n_trials : int
How many trials to run.
timeout : float
Wall-clock budget in seconds (default 8 h).
max_epochs : int
Training epochs per trial.
custom_ranges : dict, optional
Per-param search ranges forwarded to
:meth:`_discover_hyperparameters`.
study : optuna.Study, optional
Existing study to resume; a new one is created if ``None``.
direction : str
``"minimize"`` or ``"maximize"``.

Returns
-------
optuna.Study
Completed study with all trial results.
"""
from skbase.utils.dependencies import _safe_import

optuna = _safe_import("optuna")

def _objective(trial):
import copy

import lightning.pytorch as pl

sampled_cfg = self._discover_hyperparameters(trial, custom_ranges)

model_cfg = {**sampled_cfg, **self.fixed_hparams}

if "loss" not in model_cfg:
model_cfg["loss"] = nn.MSELoss()
elif hasattr(model_cfg["loss"], "__deepcopy__"):
model_cfg["loss"] = copy.deepcopy(model_cfg["loss"])

model = self.model_cls(**model_cfg, metadata=self._metadata)

trainer = pl.Trainer(
max_epochs=max_epochs,
accelerator="auto",
enable_progress_bar=False,
enable_model_summary=False,
)

trainer.fit(model, datamodule=self.datamodule)

metrics = trainer.callback_metrics
for key in ("val_loss", "train_loss_epoch", "train_loss"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we optimize for train losses? that might lead to overfitting?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, you're right over here

if key in metrics:
return metrics[key].item()
raise RuntimeError(
"No loss metric found in trainer.callback_metrics. "
f"Available keys: {list(metrics.keys())}"
)

if study is None:
study = optuna.create_study(direction=direction)

study.optimize(_objective, n_trials=n_trials, timeout=timeout)

return study
Loading
Loading