Hyperparameter Tuning

Peshbeen provides two powerful tools for hyperparameter tuning: hyperopt_tune and optuna_tune. These functions allow you to optimize the hyperparameters of your forecasting models using the hyperopt and optuna libraries, respectively. Both functions support cross-validation and can be used with any of the forecasting models available in peshbeen.

hyperopt_tune example for univariate forecasting using machine learning models

from peshbeen.datasets import load_wales_admissions
from peshbeen.metrics import RMSE
from lightgbm import LGBMRegressor
from peshbeen.models import ml_forecaster
from peshbeen.model_selection import hyperopt_tune
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(drop='first', sparse_output=False, handle_unknown="ignore")

wales_admissions = load_wales_admissions()
wales_admissions["day_of_week"] = wales_admissions.index.dayofweek
wales_admissions["month"] = wales_admissions.index.month
# split the data into train and test sets
train = wales_admissions[:-30]
test = wales_admissions[-30:]
cat_variables = ["day_of_week", "month"]
# import linear regression from sklearn
ml_model = ml_forecaster(model=LGBMRegressor(verbose=-1),
              target_col='admissions', lags = 30,
              cat_variables=cat_variables, categorical_encoder=ohe)
ml_model.fit(train)

# Define the hyperparameter search space for LightGBM
from hyperopt import hp
from hyperopt.pyll import scope
lgb_param_space={'learning_rate': hp.uniform('learning_rate', 0.001, 0.6),
            'num_leaves': scope.int(hp.quniform('num_leaves', 10, 200, 1)),
           'max_depth':scope.int(hp.quniform('max_depth', 2, 18, 1)),
            'bagging_fraction': hp.uniform('bagging_fraction', 0.5, 1),
            'feature_fraction': hp.uniform('feature_fraction', 0.5, 1),
            'lambda_l2' : hp.uniform('lambda_l2', 0,10),
           'lambda_l1' : hp.uniform('lambda_l1', 0, 10),
           'top_rate' : hp.quniform('top_rate', 0.05, 0.4, 0.0001),
            'other_rate' : hp.quniform('other_rate', 0.05, 0.3, 0.0001),
           'num_iterations': scope.int(hp.quniform("num_iterations", 30, 700, 1)),
           'lags': hp.choice("lags", [
                                 [1,2,3,4,5],
                                 [1,4,7],
                                 [1,2,3,4,5,6,7],
                                 [1,2,3,4,5,6,7,14],
                                 [1,2,3,4,5,6,7,14,21],
                                 [1,2,3],
                             ]),
                "seed":0,
                "box_cox": hp.uniform("box_cox", 0.0, 4),
                "box_cox_biasadj": hp.choice("box_cox_biasadj", [True, False])}

# Run hyperparameter tuning using hyperopt
best_params, best_lags, other_, _ = hyperopt_tune(model=ml_model, df=train, cv_split=5, step_size=10,
                                        test_size=1, eval_metric=RMSE, eval_num=10,
                                        param_space=lgb_param_space)

print("Best params:", best_params)
print("Best lags:", best_lags)
print("Other info:", other_)
Best params: {'bagging_fraction': 0.8029069013265315, 'feature_fraction': 0.8292550178069497, 'lambda_l1': 4.721562783410925, 'lambda_l2': 3.487114839528168, 'learning_rate': 0.20703078310943773, 'max_depth': 14, 'num_iterations': 156, 'num_leaves': 19, 'other_rate': 0.19820000000000002, 'seed': 0, 'top_rate': 0.2528}
Best lags: (1, 2, 3, 4, 5, 6, 7)
Other info: {'box_cox': 2.285220961797138, 'box_cox_biasadj': False}
# now we can run our model with the best paramaters, best lags and other info such as box_cox and box_cox_biasadj
best_box_cox = other_["box_cox"]
best_box_cox_biasadj = other_["box_cox_biasadj"]

ml_model = ml_forecaster(model=LGBMRegressor(**best_params, verbose=-1),
              target_col='admissions', lags = list(best_lags), box_cox=best_box_cox, box_cox_biasadj=best_box_cox_biasadj,
              cat_variables=cat_variables, categorical_encoder=ohe)
ml_model.fit(train)
ml_forecasts = ml_model.forecast(H=30, exog=test[cat_variables])
ml_forecasts
array([8855.13036881, 8733.07600757, 8722.52605307, 8912.69624595,
       8847.58115698, 8878.79612748, 8859.78546156, 8791.91623046,
       8696.13919639, 8699.80585552, 8783.48217743, 8791.69730896,
       8787.31871756, 8828.93449445, 8795.1160903 , 8667.86888792,
       8707.21137559, 8785.77073674, 8805.78014056, 8803.79672262,
       8828.52391204, 8800.91664807, 8638.72414062, 8654.22374581,
       8759.27983229, 8780.02421217, 8786.46921622, 8823.52907314,
       8811.13143486, 8660.12921383])

optuna_tune example for univariate forecasting

from peshbeen.datasets import load_wales_admissions
from peshbeen.metrics import MAE, RMSE
from lightgbm import LGBMRegressor
from peshbeen.models import ml_forecaster
from peshbeen.model_selection import optuna_tune
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(drop='first', sparse_output=False, handle_unknown="ignore")
wales_admissions = load_wales_admissions()
wales_admissions["day_of_week"] = wales_admissions.index.dayofweek
wales_admissions["month"] = wales_admissions.index.month
# split the data into train and test sets
train = wales_admissions[:-30]
test = wales_admissions[-30:]
cat_variables = ["day_of_week", "month"]
# import linear regression from sklearn
ml_model = ml_forecaster(model=LGBMRegressor(verbose=-1),
              target_col='admissions', lags = 30,
              cat_variables=cat_variables, categorical_encoder=ohe)
ml_model.fit(train)
# ml_model.data_prep(train)
forecasts = ml_model.forecast(H=30, exog=test[cat_variables])
lgb_param_space = {
    "learning_rate":     lambda t: t.suggest_float("learning_rate", 0.001, 0.6),
    "num_leaves":        lambda t: t.suggest_int("num_leaves", 10, 200),
    "max_depth":         lambda t: t.suggest_int("max_depth", 2, 18),
    "bagging_fraction":  lambda t: t.suggest_float("bagging_fraction", 0.5, 1.0),
    "feature_fraction":  lambda t: t.suggest_float("feature_fraction", 0.5, 1.0),
    "lambda_l2":         lambda t: t.suggest_float("lambda_l2", 0.0, 10.0),
    "lambda_l1":         lambda t: t.suggest_float("lambda_l1", 0.0, 10.0),
    "top_rate":          lambda t: t.suggest_float("top_rate", 0.05, 0.4),
    "other_rate":        lambda t: t.suggest_float("other_rate", 0.05, 0.3),
    "num_iterations":    lambda t: t.suggest_int("num_iterations", 30, 700),
    "lags":              lambda t: t.suggest_categorical(
                             "lags", [
                                 [1,2,3,4,5],
                                 [1,4,7],
                                 [1,2,3,4,5,6,7],
                                 [1,2,3,4,5,6,7,14],
                                 [1,2,3,4,5,6,7,14,21],
                                 [1,2,3],
                             ]),
                             
    "seed":              lambda t: 0,   # fixed, not sampled
}

best_params, best_lags, other_, _ = optuna_tune(
    model=ml_model,
    df=train,
    cv_split=10,
    step_size=10,
    test_size=30,
    eval_metric=RMSE,
    eval_num=10,
    param_space=lgb_param_space, verbose=False
)
print("Best params:", best_params)
print("Best lags:", best_lags)
Best params: {'learning_rate': 0.2717528027186224, 'num_leaves': 62, 'max_depth': 10, 'bagging_fraction': 0.8620982901117533, 'feature_fraction': 0.6496040768112121, 'lambda_l2': 5.096339166689986, 'lambda_l1': 3.5749594435457666, 'top_rate': 0.05318048013094324, 'other_rate': 0.08834070754567276, 'num_iterations': 675}
Best lags: [1, 2, 3]

Selecting the best ETS (Error, Trend, Seasonality) model usig optuna_tune or hyperopt_tune

from peshbeen.models import ets

ets_param_space = {
    "smoothing_level":     lambda t: t.suggest_float("smoothing_level", 0.001, 0.99),
    "trend":              lambda t: t.suggest_categorical(
                             "trend", [
                                 "add",
                                 "mul",
                                 None
                             ]),
    "seasonal":           lambda t: t.suggest_categorical(
                             "seasonal", [
                                 "add",
                                 "mul",
                                 None
                             ]),
    "smoothing_trend":    lambda t: t.suggest_float("smoothing_trend", 0.001, 0.99),
    "smoothing_seasonal": lambda t: t.suggest_float("smoothing_seasonal", 0.001, 0.99),
    "smoothing_level":     lambda t: t.suggest_float("smoothing_level", 0.001, 0.99),
    "seasonal_periods":              lambda t: 7,   # fixed, not sampled
        "box_cox":           lambda t: t.suggest_float("box_cox", 0.0, 4),
        "box_cox_biasadj":   lambda t: t.suggest_categorical("box_cox_biasadj", [True, False])
}

ets_model = ets(target_col='admissions')
best_params, _, other_, _ = optuna_tune(
    model=ets_model,
    df=train,
    cv_split=4,
    step_size=1,
    test_size=30,
    eval_metric=RMSE,
    eval_num=100,
    param_space=ets_param_space, verbose=False
)
print("Best params:", best_params)
print("Other info:", other_)
Best params: {'smoothing_level': 0.4503630280037321, 'trend': None, 'seasonal': None, 'smoothing_trend': 0.6228532979304188, 'smoothing_seasonal': 0.706283263294201}
Other info: {'box_cox': 1.7345891506020739, 'box_cox_biasadj': False}
# now forecast wit the best parameters
best_params.update(other_)
ets_model = ets(target_col='admissions', **best_params)
ets_model.fit(train)
ets_forecasts = ets_model.forecast(H=30)
# get cross validation results with the best parameters
cv_df = ets_model.cross_validate(
    df=train,
    cv_split=5,
        step_size=7,
        test_size=30,
        metrics=[RMSE, MAE])
cv_df.head()
cutoff fold_index horizon split y_true y_pred
0 2022-12-06 2022-12-07 1 fold_1 8933 8931.749257
1 2022-12-06 2022-12-08 2 fold_1 9013 8931.749257
2 2022-12-06 2022-12-09 3 fold_1 9000 8931.749257
3 2022-12-06 2022-12-10 4 fold_1 8821 8931.749257
4 2022-12-06 2022-12-11 5 fold_1 8835 8931.749257

Selecting the best orders for an ARIMA model using optuna_tune or hyperopt_tune

from peshbeen.models import arima
from itertools import product
# Define the hyperparameter search space for ARIMA
p_values = [0, 1, 2, 3]
d_values = [1]
q_values = [0, 1, 2, 3]

# create the list of orders using the product of p, d and q values
orders = list(product(p_values, d_values, q_values))

# Define the hyperparameter search space for seasonal ARIMA
P_values = [0, 1, 2, 3]
D_values = [0, 1]
Q_values = [0, 1, 2, 3]

# create the list of seasonal orders using the product of P, D and Q values
seasonal_orders = list(product(P_values, D_values, Q_values))

# let's define the hyperparameter space for arima using hyperopt
arima_param_space = {
    "order": hp.choice("order", orders),
    "seasonal_order": hp.choice("seasonal_order", seasonal_orders),
    "seasonal_length": 7,
    "box_cox": hp.uniform("box_cox", 0.0, 4),
    "box_cox_biasadj": hp.choice("box_cox_biasadj", [True, False])
}

arima_model = arima(target_col='admissions')
best_params, _, other_, _ = hyperopt_tune(
    model=arima_model,
    df=train,
    cv_split=10,
    step_size=10,
    test_size=30,
    eval_metric=RMSE,
    eval_num=5,
    param_space=arima_param_space
)

hyperopt_tune example for multivariate forecasting

from peshbeen.datasets import load_admission_calls
from peshbeen.models import ml_mv_forecaster
from peshbeen.metrics import RMSE
from peshbeen.model_selection import mv_hyperopt_tune
from lightgbm import LGBMRegressor
admission_calls = load_admission_calls()

admission_calls["day_of_week"] = admission_calls.index.dayofweek
admission_calls["month"] = admission_calls.index.month
train = admission_calls[:-30]
test = admission_calls[-30:]

cat_variables = ["day_of_week", "month"]
mv_model = ml_mv_forecaster(model=LGBMRegressor(verbose=-1),
              target_cols=['admissions', "calls"], lags = {"admissions": 7, "calls": 7},
                cat_variables=cat_variables,
                difference={"admissions": 1, "calls": 1}, categorical_encoder=ohe)
lgb_param_space={'learning_rate': hp.uniform('learning_rate', 0.001, 0.6),
            'num_leaves': scope.int(hp.quniform('num_leaves', 10, 200, 1)),
           'max_depth':scope.int(hp.quniform('max_depth', 2, 18, 1)),
            'bagging_fraction': hp.uniform('bagging_fraction', 0.5, 1),
            'feature_fraction': hp.uniform('feature_fraction', 0.5, 1),
           'min_data_in_leaf': scope.int(hp.quniform ('min_data_in_leaf', 5, 100, 1)), 
            'lambda_l2' : hp.uniform('lambda_l2', 0,10),
           'lambda_l1' : hp.uniform('lambda_l1', 0, 10),
            'other_rate' : hp.quniform('other_rate', 0.05, 0.3, 0.0001),
           'num_iterations': scope.int(hp.quniform("num_iterations", 30, 700, 1)),
           'top_k': scope.int(hp.quniform('top_k', 8, 30, 1)),
                "seed":0, 'lags': hp.choice("lags", [
                                 [1,2,3,4,5],
                                 [1,4,7],
                                 [1,2,3,4,5,6,7],
                                 [1,2,3,4,5,6,7,14]])}
best_params, best_lags = mv_hyperopt_tune(model=mv_model, df=train, target_col= "admissions", cv_split=5, step_size=10,
                                        test_size=30, eval_metric=RMSE, eval_num=4,
                                        param_space=lgb_param_space)
print("Best params:", best_params)
print("Best lags:", best_lags)
100%|██████████| 4/4 [00:09<00:00,  2.46s/trial, best loss: 180.0532724171985]
Best params: {'bagging_fraction': 0.6213230979478799, 'feature_fraction': 0.8897549089982713, 'lambda_l1': 3.265987911998489, 'lambda_l2': 5.160975155054177, 'learning_rate': 0.26366408249170853, 'max_depth': 2, 'min_data_in_leaf': 56, 'num_iterations': 652, 'num_leaves': 190, 'other_rate': 0.1782, 'seed': 0, 'top_k': 29}
Best lags: {'admissions': [1, 2, 3, 4, 5, 6, 7, 14], 'calls': [1, 2, 3, 4, 5, 6, 7, 14]}
# forecast with the best parameters and best lags
mv_model = ml_mv_forecaster(model=LGBMRegressor(**best_params, verbose=-1),
              target_cols=['admissions', "calls"], lags = best_lags,
                cat_variables=cat_variables, categorical_encoder=ohe,
                difference={"admissions": 1, "calls": 1})
mv_model.fit(train)
mv_forecasts = mv_model.forecast(H=30, exog=test[cat_variables])
mv_forecasts
{'admissions': array([8039.6158385 , 8108.70795898, 8162.68325686, 8184.07811967,
        8225.88388844, 8176.56859442, 8174.42805125, 8129.42225885,
        8164.07978815, 8195.42205271, 8183.6436037 , 8213.63875254,
        8242.03415742, 8178.3399244 , 8176.42406381, 8168.42020957,
        8179.3477697 , 8090.31018162, 8082.40554509, 8167.04601928,
        8211.39040301, 8281.53616261, 8282.70296943, 8296.63071512,
        8227.18274168, 8147.94509113, 8152.0467618 , 8109.03319195,
        8213.60715237, 8278.87852721]),
 'calls': array([1259.52354131, 1310.23671599, 1291.29605278, 1273.12702382,
        1291.3684447 , 1321.3544564 , 1299.31861103, 1302.53930359,
        1288.87885645, 1336.81840327, 1324.02288501, 1330.60205826,
        1359.84800694, 1330.76276137, 1363.12226535, 1445.2510038 ,
        1384.60460934, 1431.27572759, 1416.9237716 , 1433.81409078,
        1418.47443686, 1435.83681191, 1441.14472583, 1498.50538295,
        1434.96026007, 1411.32010457, 1514.45256916, 1504.92434484,
        1551.3831213 , 1434.17561727])}

optuna_tune example for multivariate forecasting

from peshbeen.model_selection import mv_optuna_tune
lgb_param_space = {
    "learning_rate":     lambda t: t.suggest_float("learning_rate", 0.001, 0.6),
    "num_leaves":        lambda t: t.suggest_int("num_leaves", 10, 200),
    "max_depth":         lambda t: t.suggest_int("max_depth", 2, 18),
    "bagging_fraction":  lambda t: t.suggest_float("bagging_fraction", 0.5, 1.0),
    "feature_fraction":  lambda t: t.suggest_float("feature_fraction", 0.5, 1.0),
    "min_data_in_leaf":  lambda t: t.suggest_int("min_data_in_leaf", 5, 100),
    "lambda_l2":         lambda t: t.suggest_float("lambda_l2", 0.0, 10.0),
    "lambda_l1":         lambda t: t.suggest_float("lambda_l1", 0.0, 10.0),
    "other_rate":        lambda t: t.suggest_float("other_rate", 0.05, 0.3),
    "num_iterations":    lambda t: t.suggest_int("num_iterations", 30, 700),
    "top_k":             lambda t: t.suggest_int("top_k", 8, 30),
    "seed":              lambda t: 0,   # fixed, not sampled
    'lags': lambda t: t.suggest_categorical(
                             "lags", [1,2,3,4,5,
                                [1,2,3,4,5],
                                 [1,4,7],
                                 [1,2,3,4,5,6,7],
                                 [1,2,3,4,5,6,7,14]
                             ]),    
}

mv_model = ml_mv_forecaster(model=LGBMRegressor(verbose=-1),
              target_cols=['admissions', "calls"], lags = {"admissions": 7, "calls": 7},
                cat_variables=cat_variables, categorical_encoder=ohe,
                difference={"admissions": 1, "calls": 1})

best_params, best_lags = mv_optuna_tune(model=mv_model, df=train, target_col= "admissions", cv_split=5, step_size=10,
                                        test_size=30, eval_metric=RMSE, eval_num=4,
                                        param_space=lgb_param_space)
# forecast with the best parameters and best lags from optuna
mv_model = ml_mv_forecaster(model=LGBMRegressor(**best_params, verbose=-1),
              target_cols=['admissions', "calls"], lags = best_lags,
                cat_variables=cat_variables, categorical_encoder=ohe,
                difference={"admissions": 1, "calls": 1})
mv_model.fit(train)
mv_forecasts = mv_model.forecast(H=30, exog=test[cat_variables])
mv_forecasts
{'admissions': array([8035.11967773, 8126.32945435, 8140.32058397, 8122.91356893,
        8088.99239775, 8039.31436022, 7968.40235679, 8018.03830206,
        8065.20053162, 8073.70612499, 8064.09214596, 8036.09829765,
        7986.75958962, 7910.33057779, 7962.57796495, 8007.24368166,
        8015.74927503, 8006.13529601, 7978.1414477 , 7928.80273966,
        7852.37372783, 7904.62111499, 7949.2868317 , 7957.79242507,
        7950.95014038, 7925.7279864 , 7879.1609727 , 7785.9284582 ,
        7836.67556001, 7950.32939464]),
 'calls': array([1224.24325953, 1267.57752732, 1220.26772179, 1252.83655411,
        1231.87832253, 1255.52345121, 1244.18833943, 1233.93915455,
        1263.34167196, 1227.72281467, 1262.19434983, 1238.6916911 ,
        1262.33681978, 1251.00170799, 1237.53625977, 1266.93877718,
        1230.06108528, 1264.53262044, 1241.0299617 , 1264.67509038,
        1253.3399786 , 1239.87453037, 1269.27704779, 1230.82700649,
        1262.12873257, 1250.9883988 , 1265.81546084, 1260.97803501,
        1241.18048455, 1266.7151208 ])}

Forward and backward feature selection

Hyperparameter tuning searches over model settings; feature selection searches over which lags, exogenous columns, and lag-transforms to feed the model. peshbeen provides forward_feature_selection (start empty, greedily add the candidate that most improves cross-validation) and backward_feature_selection (start from the full set, greedily drop). Both take a configured but unfitted ml_forecaster, the forecast horizon H, a list of metrics (selection is driven by the first), and candidate pools via lags_to_consider, candidate_features, and transformations. Each returns a dict with best_lags, best_exogs, and best_transforms.

from peshbeen.model_selection import forward_feature_selection, backward_feature_selection
from peshbeen.metrics import MAE, RMSE
from sklearn.linear_model import LinearRegression

# A configured-but-unfitted ml_forecaster; the selectors work on deep copies and never mutate it.
fs_model = ml_forecaster(model=LinearRegression(),
                         target_col='admissions', lags=30, difference=1,
                         cat_variables=cat_variables, categorical_encoder=ohe)

# Forward selection: consider lags 1..7 and the calendar columns as exogenous candidates.
fwd = forward_feature_selection(
    model=fs_model, df=train, cv_split=5, H=30,
    metric=MAE,
    lags_to_consider=15,
    transformations=None,
    verbose=True,
)
print("Forward-selected:", fwd)
Forward-selected: {'best_lags': [], 'best_exogs': [], 'best_transforms': []}

The metrics module

All tuning and selection functions accept any metric from peshbeen.metrics. Most are two-argument functions metric(y_true, y_pred):

The scaled metrics are three-argumentmetric(y_true, y_pred, y_train) — because they normalise the error by the in-sample scale of the training series:

  • MASE — mean absolute scaled error
  • RMSSE — root mean squared scaled error
  • SRMSE — scaled RMSE

When you call a scaled metric directly you must pass the training target as the third argument; for example MASE(y_true, y_pred, y_train=train['admissions']).