Skip to content

[ENH] Implement a unified hyperparameter tuning interface decoupled from model classes for v2 - #2335

Open
Muhammad-Rebaal wants to merge 37 commits into
sktime:mainfrom
Muhammad-Rebaal:optimization
Open

Muhammad-Rebaal wants to merge 37 commits into
sktime:mainfrom
Muhammad-Rebaal:optimization

Conversation

@Muhammad-Rebaal

Copy link
Copy Markdown
Member

Fixes : #2332

This PR implements the hyperparameter optimization interface for v2 models.

Implemented Architecture

  1. SearchRange & Global Registry (tuning/search_range.py, tuning/global_registry.py):

    • A typed container for search spaces and a global dictionary defining standard ranges for common parameters (e.g., hidden_size, dropout, n_heads).
  2. Auto-Discovery Hook (models/base/_base_model_v2.py):

    • A get_tuneable_hyperparameters() class method on BaseModel that uses inspect.signature(cls.__init__) to detect what parameters the subclass accepts and maps them to the global registry.
  3. HyperparameterTuner (tuning/hyperparameter_tuner.py):

    • A standalone optimizer living outside model modules. It queries the base hook, merges user overrides, and runs sequential Optuna trials using Base_pkg.fit().

User Flow:

from pytorch_forecasting.models.timexer import TimeXer_pkg_v2
from pytorch_forecasting.models.dlinear import DLinear_pkg_v2
from pytorch_forecasting.tuning.hyperparameter_tuner import HyperparameterTuner
from pytorch_forecasting.tuning.search_range import SearchRange

tuner = HyperparameterTuner(
    pkg_cls=TimeXer_pkg_v2,
    data=my_timeseries_data,
    base_datamodule_cfg={"context_length": 96, "prediction_length": 24},
)
study = tuner.optimize(n_trials=50, max_epochs=10)
print(f"Best params: {study.best_params}")
print(f"Best val_loss: {study.best_value}")

@codecov

codecov Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.27451% with 14 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@0acb5a0). Learn more about missing BASE report.

Files with missing lines Patch % Lines
pytorch_forecasting/tuning/hyperparameter_tuner.py 86.00% 14 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2335   +/-   ##
=======================================
  Coverage        ?   88.85%           
=======================================
  Files           ?      219           
  Lines           ?    11772           
  Branches        ?        0           
=======================================
  Hits            ?    10460           
  Misses          ?     1312           
  Partials        ?        0           
Flag Coverage Δ
cpu 88.85% <86.27%> (?)
pytest 88.85% <86.27%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@phoeenniixx phoeenniixx left a comment

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.

Why aren't we using optuna here? Also, in the param grid the values are hard coded

@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

Why aren't we using optuna here?

Because of these 2 reason :

User Experience: It's much easier for a user to inspect, modify, and pass a simple SearchRange dataclass in custom_ranges than it is to write and pass custom Optuna lambda functions.

Decoupling: By keeping Optuna logic out of the base model layer and registry, the models themselves remain completely framework-agnostic. The SearchRange dataclass acts as a clean bridge.

param grid the values are hard coded

In v1's optimize_hyperparameters for TFT, the default search spaces were hardcoded as tuple arguments (e.g., hidden_size_range=(16, 265), dropout_range=(0.1, 0.3)). The global_registry simply takes those v1 hardcoded defaults and standardizes them centrally so they can apply to all models automatically. However, they aren't strictly locked users can completely override these default "hardcoded" values by passing their own custom_ranges to the HyperparameterTuner.

@fkiraly fkiraly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hm, ok start. I would make all classes and variables private for now, so we can later change. This increases separation of concerns which is good.

Some questions in the design:

  • how would this get used?
  • This designs custom search range classes. Do we want to do that, instead of relying on one of the standards? (e.g., even optuna itself)
  • optuna is one choice of what we could do. Have a look at the hyperactive tuning class for neural networks, or the OptCV class in sktime. Could this be useful?

@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

Thanks for the review.

Hm, ok start. I would make all classes and variables private for now, so we can later change. This increases separation of concerns which is good.

I've implemented this.

  • how would this get used?
from pytorch_forecasting.tuning import ForecastingSearchCV, _SearchRange
from pytorch_forecasting.models.dlinear import DLinear_pkg_v2
from pytorch_forecasting.metrics import SMAPE


datasets = DLinear_pkg_v2.get_test_dataset_from()

search = ForecastingSearchCV(
    pkg_cls=DLinear_pkg_v2,
    param_grid={
        "moving_avg": _SearchRange(param_type="categorical", choices=[3, 5, 7, 11]),
        "dropout": _SearchRange(param_type="float", low=0.1, high=0.5)
    },
    n_trials=3,
    base_model_cfg={"loss": SMAPE()},  
    base_datamodule_cfg={
        "context_length": 8,        
        "prediction_length": 2,
        "batch_size": 2,                  
        "train_val_test_split": (0.5, 0.5)
    },  
    base_trainer_cfg={
        "max_epochs": 2,
        "limit_train_batches": 2,         
        "limit_val_batches": 1            
    },      
)

search.fit(datasets["train"])

predictions = search.predict(datasets["predict"])

print("Best parameters found:", search.best_params_)
  • This designs custom search range classes. Do we want to do that, instead of relying on one of the standards? (e.g., even optuna itself)

_SearchRange is intentionally a thin adapter that maps to Optuna internally see suggest() method. If we ever want to support a second backend, e.g., Ray Tune, we only change suggest() no user-facing code changes. Using raw optuna.distributions would hard-couple every model registry entry to Optuna.

  • optuna is one choice of what we could do. Have a look at the hyperactive tuning class for neural networks, or the OptCV class in sktime. Could this be useful?

Yes, it is preciously useful, helped me a lot and implemented the wrapper class ForecastingSearchCV like it is in sktime. Also with the help of the param_grid we don't have to create hooks in the base_model class as well.

@phoeenniixx phoeenniixx left a comment •

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.

Comment on lines +94 to +98
try:
import optuna
except ImportError:
raise ImportError(
"Optuna is required for hyperparameter tuning. "

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.

why aren't we using _safe_import or _check_soft_dependencies here?

custom_ranges : dict[str, SearchRange], optional
Override or add to auto-discovered ranges.
study : optuna.Study, optional
Existing study to resume.

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.

what if there is no existing study and the user wants to create a new study here?

if custom_ranges:
search_ranges.update(custom_ranges)

def objective(trial):

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 this be a private method?

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 am talking about the method not the file?
And this is not even the file that I have added this comment on?

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 just got a little confused, thanks. I'll make objective method private as its an internal one.

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 dont use AI blindly, this creates a lot of confusion and wastes time for all of us

@phoeenniixx

phoeenniixx commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

from pytorch_forecasting.tuning import ForecastingSearchCV, _SearchRange
from pytorch_forecasting.models.dlinear import DLinear_pkg_v2
from pytorch_forecasting.metrics import SMAPE

datasets = DLinear_pkg_v2.get_test_dataset_from()

search = ForecastingSearchCV(
pkg_cls=DLinear_pkg_v2,
param_grid={
"moving_avg": _SearchRange(param_type="categorical", choices=[3, 5, 7, 11]),
"dropout": _SearchRange(param_type="float", low=0.1, high=0.5)
},
n_trials=3,
base_model_cfg={"loss": SMAPE()},
base_datamodule_cfg={
"context_length": 8,
"prediction_length": 2,
"batch_size": 2,
"train_val_test_split": (0.5, 0.5)
},
base_trainer_cfg={
"max_epochs": 2,
"limit_train_batches": 2,
"limit_val_batches": 1
},
)

search.fit(datasets["train"])

predictions = search.predict(datasets["predict"])

print("Best parameters found:", search.best_params_)

based on this vignette:
Why the user has to pass a private _SearchRange class? Why dont we create this inside the wrapper itself and the user passes the params more organically?

Also, why do we need to pass the cfgs again to this class, I thought we could simply pass the pkg class and it would handle the params itself?

@phoeenniixx phoeenniixx changed the title Feat : Implement a unified hyperparameter tuning interface decoupled from model classes for v2 [ENH] Implement a unified hyperparameter tuning interface decoupled from model classes for v2 Jul 14, 2026
@phoeenniixx phoeenniixx added enhancement New feature or request module:models ptf-v2 Related to `pytorch-forecasting` v2 labels Jul 14, 2026
@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

from pytorch_forecasting.tuning import ForecastingSearchCV, _SearchRange
from pytorch_forecasting.models.dlinear import DLinear_pkg_v2
from pytorch_forecasting.metrics import SMAPE
datasets = DLinear_pkg_v2.get_test_dataset_from()
search = ForecastingSearchCV(
pkg_cls=DLinear_pkg_v2,
param_grid={
"moving_avg": _SearchRange(param_type="categorical", choices=[3, 5, 7, 11]),
"dropout": SearchRange(param_type="float", low=0.1, high=0.5)
},
n_trials=3,
base_model_cfg={"loss": SMAPE()},
base_datamodule_cfg={
"context_length": 8,
"prediction_length": 2,
"batch_size": 2,
"train_val_test_split": (0.5, 0.5)
},
base_trainer_cfg={
"max_epochs": 2,
"limit_train_batches": 2,
"limit_val_batches": 1
},
)
search.fit(datasets["train"])
predictions = search.predict(datasets["predict"])
print("Best parameters found:", search.best_params
)

based on this vignette: Why the user has to pass a private _SearchRange class? Why dont we create this inside the wrapper itself and the user passes the params more organically?

Also, why do we need to pass the cfgs again to this class, I thought we could simply pass the pkg class and it would handle the params itself?

Nice Idea I'll try to implement that.

@Muhammad-Rebaal

Muhammad-Rebaal commented Jul 15, 2026 •

Copy link
Copy Markdown
Member Author
  • I think lightning.tuner can also be very helpful here, no?

Yes,
What I found is :

These are the methods I found that can be helpful. Any suggestions from your side.. @phoeenniixx

@Muhammad-Rebaal
Muhammad-Rebaal marked this pull request as ready for review August 26, 2026 15:26
@Muhammad-Rebaal

Muhammad-Rebaal commented Aug 26, 2026 •

Copy link
Copy Markdown
Member Author

Hi @phoeenniixx,

Now the Hyperparameters can be tuned for any v2 model like this :

tuner = HyperparameterTuner(
    model="TFT",
    data=my_datamodule, 
)

Although the user can provide their trainer_cfg, and datamodule_cfg if passed the timeseries data.

@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

Hi @phoeenniixx,
I've made the following changes:

  • Removed datamodule_cfg, trainer_cfg.

The user can now pass raw timeseries and datamodule as well if passed raw timeseries datamodule would be made to processed and if lightening datamodule is passed then the user can also specify the train-test-split that we are working on another PR.

Lastly, every model has a get_datamodule_cls method implemented in their respective pkg coming from base_pkg, and since we are moving towards deprecation of the base_pkg. I've moved the get_datamodule_cls in their respective model_cls such as

  • BaseModel has EncoderDecoderTimeSeriesDataModule
  • TslibBaseModel override the BaseModel get_datamodule_cls method TslibDataModule

The purpose of this is that the user don't have to specify which datamodule is for which model and converts their data into the specific datamodule to be processed also added the edge case if the user passes the wrong datamodule it would raise a TypeError

@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

Hi @phoeenniixx,
I've made the following change:

  • Added a variable datamodule_cls, now the user have to define the datamodule he has to use for the model

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

@phoeenniixx phoeenniixx left a comment

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.

Thanks
Can you please clean up the test file? it has a lot of "param checks" like if i pass a, it is collected in x.param etc. It checks if something is implemented or not - we should only test if the things are working correctly... Not that if the tests accpet some param or not, it is understood that the class will accept datamodule and assign it to tuner.datamoudle.
(There are multiple tests like that here)

Also, pls add some examples (maybe even an example nb!)

@Muhammad-Rebaal

Copy link
Copy Markdown
Member Author

Hi @phoeenniixx,
I've cleaned up the test file and also added an example.

@phoeenniixx phoeenniixx left a comment

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.

Thanks!
Will this work for a model checkpoint etc as well?
Also, should we also add a nb etc - as rn we cant verify that the complete workflow works as expected

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

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

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request module:models ptf-v2 Related to `pytorch-forecasting` v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ENH] Implement a unified hyperparameter tuning interface decoupled from model classes for v2

3 participants