Custom Models & Extension Points#

Level Python Twiga Time


What you’ll build

A custom sklearn-compatible regressor registered in the Twiga model registry - usable with TwigaForecaster.fit(), cross-validation, Optuna HPO, and ensembles - without modifying any library source code.

Prerequisites

  • 05 - ML Point Forecasting (model registry, BaseRegressor interface)

  • 11 - Hyperparameter Tuning (SearchSpace, Optuna integration)

  • 12 - Ensemble Strategies (ensembling custom models)

  • Python: class inheritance, sklearn estimator interface

Learning objectives

By the end of this notebook you will be able to:

  1. Navigate the Twiga model registry and understand how models are discovered

  2. Implement BaseRegressor with fit(), predict(), and get_params() methods

  3. Define a typed SearchSpace for Optuna to tune your custom model’s hyperparameters

  4. Register your custom model with register_model() and verify it works with TwigaForecaster

  5. Decide when to write a custom model vs. using the built-in registry

1. Motivation#

Twiga ships a curated set of ML and NN models (LightGBM, XGBoost, CatBoost, MLPF, MLPGAM, …), but the interfaces are deliberately designed for extension.

Two abstract contracts govern the whole model zoo:

Interface

Location

Used by

BaseRegressor

twiga/models/ml/core/base_regressor.py

All ML (tree + linear) models

BaseNeuralForecast

twiga/models/nn/core/base.py

All PyTorch/Lightning NN models

Both are wired into TwigaForecaster via the config domain field ("ml" or "nn"). As long as your custom class satisfies the contract, the full Twiga pipeline (feature engineering, backtesting, HPO, ensemble) works without any changes.

This notebook shows how to plug in your own ML model end-to-end, inspect the NN interface conceptually, and run the forecaster on a second target variable to confirm portability.

Key concept - the model registry

Twiga uses a lazy registry that maps a model name string to a (ModelClass, ConfigClass) pair. When you call TwigaForecaster(model_params=[my_config]), the forecaster reads my_config.name, looks it up in _model_cache, and instantiates ModelClass(my_config).

This decoupling matters: the forecaster never imports your model class directly. Any object that satisfies the BaseRegressor or BaseNeuralForecast interface and has an entry in the registry will work transparently with fit(), evaluate_point_forecast(), tune(), and backtesting() - without touching a single line of Twiga core code.

2. Setup#

import warnings

from great_tables import GT
from lets_plot import LetsPlot
import pandas as pd

LetsPlot.setup_html()

from twiga.core.plot import plot_forecast_grid
from twiga.core.utils import configure, get_logger

warnings.filterwarnings("ignore")

configure()
log = get_logger("tutorials")

Load data#

The dataset covers Madeira, Portugal (32.37°N, 16.27°W) at 30-minute resolution. We load only the three columns used throughout this series.

data = pd.read_parquet("../data/MLVS-PT.parquet")
data = data[["timestamp", "NetLoad(kW)", "Ghi", "Temperature"]]
data["timestamp"] = pd.to_datetime(data["timestamp"])
data = data.drop_duplicates(subset="timestamp").reset_index(drop=True)
# Restrict to 2019-2020 to keep tutorial execution fast
data = data[(data["timestamp"] >= "2019-01-01") & (data["timestamp"] <= "2020-12-31")].reset_index(drop=True)

log.info("Shape: %s", data.shape)
GT(data.head())
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[2], line 1
----> 1 data = pd.read_parquet("../data/MLVS-PT.parquet")
      2 data = data[["timestamp", "NetLoad(kW)", "Ghi", "Temperature"]]
      3 data["timestamp"] = pd.to_datetime(data["timestamp"])
      4 data = data.drop_duplicates(subset="timestamp").reset_index(drop=True)

File ~/work/twiga-forecast/twiga-forecast/.venv/lib/python3.12/site-packages/pandas/io/parquet.py:669, in read_parquet(path, engine, columns, storage_options, use_nullable_dtypes, dtype_backend, filesystem, filters, **kwargs)
    666     use_nullable_dtypes = False
    667 check_dtype_backend(dtype_backend)
--> 669 return impl.read(
    670     path,
    671     columns=columns,
    672     filters=filters,
    673     storage_options=storage_options,
    674     use_nullable_dtypes=use_nullable_dtypes,
    675     dtype_backend=dtype_backend,
    676     filesystem=filesystem,
    677     **kwargs,
    678 )

File ~/work/twiga-forecast/twiga-forecast/.venv/lib/python3.12/site-packages/pandas/io/parquet.py:258, in PyArrowImpl.read(self, path, columns, filters, use_nullable_dtypes, dtype_backend, storage_options, filesystem, **kwargs)
    256 if manager == "array":
    257     to_pandas_kwargs["split_blocks"] = True
--> 258 path_or_handle, handles, filesystem = _get_path_or_handle(
    259     path,
    260     filesystem,
    261     storage_options=storage_options,
    262     mode="rb",
    263 )
    264 try:
    265     pa_table = self.api.parquet.read_table(
    266         path_or_handle,
    267         columns=columns,
   (...)    270         **kwargs,
    271     )

File ~/work/twiga-forecast/twiga-forecast/.venv/lib/python3.12/site-packages/pandas/io/parquet.py:141, in _get_path_or_handle(path, fs, storage_options, mode, is_dir)
    131 handles = None
    132 if (
    133     not fs
    134     and not is_dir
   (...)    139     # fsspec resources can also point to directories
    140     # this branch is used for example when reading from non-fsspec URLs
--> 141     handles = get_handle(
    142         path_or_handle, mode, is_text=False, storage_options=storage_options
    143     )
    144     fs = None
    145     path_or_handle = handles.handle

File ~/work/twiga-forecast/twiga-forecast/.venv/lib/python3.12/site-packages/pandas/io/common.py:882, in get_handle(path_or_buf, mode, encoding, compression, memory_map, is_text, errors, storage_options)
    873         handle = open(
    874             handle,
    875             ioargs.mode,
   (...)    878             newline="",
    879         )
    880     else:
    881         # Binary mode
--> 882         handle = open(handle, ioargs.mode)
    883     handles.append(handle)
    885 # Convert BytesIO or file objects passed with an encoding

FileNotFoundError: [Errno 2] No such file or directory: '../data/MLVS-PT.parquet'

Train / val / test splits#

from great_tables import GT, md

from twiga.core.plot.gt import twiga_gt

splits_df = pd.DataFrame(
    {
        "Split": ["train", "val", "test"],
        "Period": ["before 2020-01-01", "2020-01-01 – 2020-06-30", "2020-07-01 onwards"],
        "Purpose": ["Model learning", "Early-stopping / overfitting guard", "Final honest evaluation"],
    }
)

twiga_gt(
    GT(splits_df)
    .tab_header(title=md("**Train / Validation / Test Splits**"), subtitle="Chronological — no shuffling, no overlap")
    .cols_label(**{c: md(f"**{c}**") for c in splits_df.columns})
    .tab_source_note("MLVS-PT dataset · Madeira, Portugal · 30-min resolution"),
    n_rows=len(splits_df),
)
train_df = data[data["timestamp"] < "2020-01-01"].reset_index(drop=True)
val_df = data[(data["timestamp"] >= "2020-01-01") & (data["timestamp"] < "2020-07-01")].reset_index(drop=True)
test_df = data[data["timestamp"] >= "2020-07-01"].reset_index(drop=True)

log.info(
    f"train : {train_df.shape[0]:,} rows  "
    f"({train_df['timestamp'].min().date()} -> {train_df['timestamp'].max().date()})"
)
log.info(
    f"val   : {val_df.shape[0]:,} rows  ({val_df['timestamp'].min().date()} -> {val_df['timestamp'].max().date()})"
)
log.info(
    f"test  : {test_df.shape[0]:,} rows  ({test_df['timestamp'].min().date()} -> {test_df['timestamp'].max().date()})"
)

Shared data config#

from sklearn.preprocessing import RobustScaler, StandardScaler

from twiga.core.config import DataPipelineConfig, ForecasterConfig

data_config = DataPipelineConfig(
    target_feature="NetLoad(kW)",
    period="30min",
    latitude=32.371666,
    longitude=-16.274998,
    calendar_features=["hour", "day_night"],
    exogenous_features=["Ghi"],
    forecast_horizon=48,
    lookback_window_size=96,
    input_scaler=StandardScaler(),
    target_scaler=RobustScaler(),
)

train_config = ForecasterConfig(
    split_freq="months",
    train_size=3,
    test_size=1,
)

data_config

3. The ML model interface: BaseRegressor#

BaseRegressor (in twiga/models/ml/core/base_regressor.py) inherits from sklearn.base.BaseEstimator and RegressorMixin.

The class does not declare abstract methods - instead it ships a default implementation that delegates to a self.model attribute. The minimum contract for a custom ML model is:

What to provide

Why

__init__(self, model_config)

Store config and assign self.model (any sklearn-compatible estimator)

update(self, trial)

Re-instantiate self.model with Optuna-sampled parameters (needed for HPO)

The base class handles fit, predict, forecast, format_features (flattens 3-D arrays to 2-D), and get_params / set_params.

The cell below prints the source of LINEAREGModel as a concrete reference before we implement our own.

Key concept - BaseRegressor interface

BaseRegressor inherits from sklearn’s BaseEstimator and RegressorMixin. Twiga calls two methods on every ML model during a training run:

  • fit(X, y, verbose=False) - train on the feature matrix and target array; delegates to self.model.fit by default.

  • predict(x) np.ndarray - return predictions for the given input; delegates to self.model.predict.

For a custom model you only need to override __init__ (assign self.model) and update(trial) (resample hyperparameters for HPO). Everything else - feature formatting, scaling inversion, multi-output handling - is provided by the base class. Full scikit-learn compatibility (get_params, set_params, clone) is inherited automatically.

import inspect

from twiga.models.ml import LINEAREGModel

src = inspect.getsource(LINEAREGModel)
print(src)

Key observations from LINEAREGModel:

  • __init__ sets self.model_config and assigns a concrete sklearn estimator to self.model.

  • update(trial) calls self.model_config.get_optuna_params(trial) and re-instantiates self.model with the sampled values.

  • No fit or predict override - those are fully handled by BaseRegressor.

  • The sklearn estimator is wrapped in MultiOutputRegressor so it handles the multi-step horizon; for Ridge we can do the same.

Now we build our own.

Key concept - when to write a custom model

If the model you want to use isn’t in Twiga’s built-in zoo, wrapping it is typically 20-30 lines of code:

  1. Subclass BaseModelConfig - declare your hyperparameters as Pydantic fields and optionally add a BaseSearchSpace for HPO.

  2. Subclass BaseRegressor - implement __init__ (assign self.model) and update(trial).

  3. Add one dict to _model_cache - the registry entry that links your config name to your classes.

After that, your model is a first-class Twiga citizen: it participates in tune(), backtesting(), ensembles, and any future Twiga feature without additional work.

4. Implementing a custom Ridge model#

We need two classes:

  1. RidgeConfig - a BaseModelConfig subclass that declares the alpha hyperparameter and exposes it through get_optuna_params for HPO.

  2. RidgeModel - a BaseRegressor subclass that wraps sklearn’s Ridge inside MultiOutputRegressor (so it handles all 48 horizon steps at once).

from typing import Literal

import optuna
from pydantic import Field
from sklearn.linear_model import Ridge
from sklearn.multioutput import MultiOutputRegressor

from twiga.core.config import BaseModelConfig, BaseSearchSpace


class RidgeConfig(BaseModelConfig):
    """Configuration for a custom Ridge regression model.

    Inherits the Optuna wiring from BaseModelConfig.  The search_space field
    teaches the framework how to sample `alpha` during HPO; fixed runs simply
    use the default value of 1.0.
    """

    name: Literal["ridge_regression"] = Field(
        default="ridge_regression",
        alias="model_name",
        exclude=True,
        description="Fixed model identifier.",
    )
    domain: Literal["ml"] = Field(
        default="ml",
        alias="model_type",
        exclude=True,
        description="ML domain — model is serialised as a pickle.",
    )
    alpha: float = Field(default=1.0, description="Regularisation strength (L2 penalty).")
    fit_intercept: bool = Field(default=True, description="Whether to fit the intercept.")

    search_space: BaseSearchSpace = Field(
        default=BaseSearchSpace(
            alpha=(0.01, 100.0),  # sampled on log scale (ratio >= 10)
            fit_intercept=[True, False],
        ),
        exclude=True,
        description="Optuna search space for HPO.",
    )


RidgeConfig()
from twiga.models.ml.core.base_regressor import BaseRegressor


class RidgeModel(BaseRegressor):
    """Custom Ridge regression wrapper compatible with TwigaForecaster.

    Wraps sklearn Ridge inside MultiOutputRegressor so the model handles all
    forecast horizon steps simultaneously.  The BaseRegressor base class
    provides fit, predict, forecast, format_features, get_params, and set_params
    — we only need to implement __init__ and update.
    """

    def __init__(self, model_config: RidgeConfig | None = None):
        super().__init__()
        self.model_config = model_config or RidgeConfig()
        self.model = MultiOutputRegressor(
            Ridge(
                alpha=self.model_config.alpha,
                fit_intercept=self.model_config.fit_intercept,
            )
        )

    def update(self, trial: optuna.Trial) -> None:
        """Re-instantiate with Optuna-sampled hyperparameters.

        Called once per HPO trial.  Samples alpha and fit_intercept from the
        search space defined in RidgeConfig and rebuilds self.model.
        """
        params = self.model_config.get_optuna_params(trial=trial)
        self.model_config = RidgeConfig(**params)
        self.model = MultiOutputRegressor(
            Ridge(
                alpha=self.model_config.alpha,
                fit_intercept=self.model_config.fit_intercept,
            )
        )


# Quick sanity check — instantiate and inspect
ridge_model = RidgeModel()
log.info("model_config : %s", ridge_model.model_config)
log.info("inner model  : %s", ridge_model.model)

5. Registering and using the custom model#

TwigaForecaster resolves models from a lazy registry that maps name (ModelClass, ConfigClass). To slot in a custom model, add one entry to the registry cache before constructing the forecaster.

from twiga.forecaster.registry import _model_cache
_model_cache["ridge_regression"] = {"model": RidgeModel, "config": RidgeConfig}

Once registered, pass the config object (not a model instance) to model_params exactly as you would for any built-in model. The forecaster instantiates RidgeModel(ridge_config) internally via the same path it uses for LightGBM, XGBoost, and the rest of the model zoo.

# Register the custom model in the Twiga registry
from twiga.forecaster.registry import _model_cache

_model_cache["ridge_regression"] = {"model": RidgeModel, "config": RidgeConfig}
log.info("'ridge_regression' registered in the model cache.")
log.info("Registry keys (ml models): %s", list(_model_cache))
from IPython.display import clear_output

from twiga import TwigaForecaster

ridge_config = RidgeConfig()

forecaster_ridge = TwigaForecaster(
    data_params=data_config,
    model_params=[ridge_config],  # pass config object — registry maps it to RidgeModel
    train_params=train_config,
)
forecaster_ridge.fit(train_df=train_df, val_df=val_df)
clear_output()
log.info("Ridge training complete.")
pred_ridge, metric_ridge = forecaster_ridge.evaluate_point_forecast(test_df=test_df)
clear_output()
log.info("Ridge — mean metrics across folds:")
log.info("\n%s", metric_ridge[["mae", "rmse", "corr"]].mean().round(3).to_string())
# Compare custom Ridge vs built-in LinearReg
from twiga.models.ml import LINEAREGConfig

linear_instance = LINEAREGConfig()
forecaster_linear = TwigaForecaster(
    data_params=data_config,
    model_params=[linear_instance],
    train_params=train_config,
)
forecaster_linear.fit(train_df=train_df, val_df=val_df)
clear_output()
_, metric_linear = forecaster_linear.evaluate_point_forecast(test_df=test_df)
clear_output()

log.info("LinearReg (built-in) — mean metrics:")
log.info("\n%s", metric_linear[["mae", "rmse", "corr"]].mean().round(3).to_string())
log.info("\nRidge (custom)       — mean metrics:")
log.info("\n%s", metric_ridge[["mae", "rmse", "corr"]].mean().round(3).to_string())

Visualise Ridge predictions#

p = plot_forecast_grid(
    pred_ridge.iloc[: 7 * 48].assign(Model="Ridge"),
    actual_col="Actual",
    forecast_col="forecast",
    model_col="Model",
    n_samples_per_model=7 * 48,
    y_label="Net Load (kW)",
    title="Custom Ridge model — first 7 days of test set",
)
p

6. Inspecting the actual base class interface#

Printing the full BaseRegressor source reveals every hook the framework provides. This is the most reliable reference when implementing a custom model, because it reflects the exact version installed in your environment.

import inspect

from twiga.models.ml.core.base_regressor import BaseRegressor

src = inspect.getsource(BaseRegressor)
print(src)

Summary of the BaseRegressor API

from great_tables import GT, md
import pandas as pd

from twiga.core.plot.gt import twiga_gt

base_regressor_df = pd.DataFrame(
    {
        "Method": [
            "`__init__`",
            "`format_features`",
            "`fit`",
            "`predict`",
            "`forecast`",
            "`update`",
            "`get_params` / `set_params`",
        ],
        "Signature": [
            "`(model_config=None)`",
            "`(x) -> np.ndarray`",
            "`(X, y, verbose=False) -> self`",
            "`(x) -> np.ndarray`",
            "`(x) -> np.ndarray`",
            "`(trial)`",
            "sklearn compat",
        ],
        "Override needed?": [
            "Yes — set `self.model`",
            "No — flattens 3-D to 2-D",
            "No — delegates to `self.model.fit`",
            "No — delegates to `self.model.predict`",
            "No — alias for `predict`",
            "Yes — re-instantiate `self.model` with HPO params",
            "No — delegates to `self.model`",
        ],
    }
)

twiga_gt(
    GT(base_regressor_df)
    .tab_header(
        title=md("**BaseRegressor API**"), subtitle="Methods provided by the base class — override only what you need"
    )
    .cols_label(**{c: md(f"**{c}**") for c in base_regressor_df.columns})
    .tab_source_note("twiga/models/ml/core/base_regressor.py"),
    n_rows=len(base_regressor_df),
)

7. NN model interface: BaseNeuralForecast (conceptual)#

Implementing a full custom NN model requires PyTorch Lightning and is outside the scope of this tutorial. Instead, we inspect MLPFModel to understand the structural contract every NN model must satisfy.

import inspect

from twiga.models.nn import MLPFModel

src = inspect.getsource(MLPFModel)
print(src[:2500])

Key methods in any BaseNeuralForecast subclass

The base class manages PyTorch Lightning Trainer construction (early stopping, LR monitoring, checkpointing), TimeseriesDataModule for batching, fit / predict / forecast, and Optuna pruning callbacks. Your custom network only needs to be a valid lightning.pytorch.LightningModule with a forward(x_past, x_fut, x_cal) signature.

from great_tables import GT, md
import pandas as pd

from twiga.core.plot.gt import twiga_gt

nn_methods_df = pd.DataFrame(
    {
        "Method": ["`__init__(model_config)`", "`_init_model()`", "`update(trial)`", "`load_checkpoint()`"],
        "Purpose": [
            "Call `super().__init__(...)` with Lightning trainer params, store config, call `_init_model()`",
            "Instantiate the Lightning module (`self.model = MyNetwork(...)`) from config fields",
            "Sample new hyperparameters with `get_optuna_params(trial)`, rebuild config and call `_init_model()` again",
            "Call `self._load_checkpoints(model_instance=MyNetwork)` then `self.model.eval()`",
        ],
    }
)

twiga_gt(
    GT(nn_methods_df)
    .tab_header(
        title=md("**BaseNeuralForecast — Required Method Overrides**"),
        subtitle="Minimum contract for a custom NN model",
    )
    .cols_label(**{c: md(f"**{c}**") for c in nn_methods_df.columns})
    .tab_source_note("twiga/models/nn/core/base.py"),
    n_rows=len(nn_methods_df),
)

8. BaseModelConfig with search spaces#

BaseModelConfig uses BaseSearchSpace to declare an Optuna search space alongside fixed default parameters. The same config object works for both fixed runs (returns defaults) and HPO runs (samples declared ranges and merges them over the fixed defaults).

from great_tables import GT, md
import pandas as pd

from twiga.core.plot.gt import twiga_gt

search_space_df = pd.DataFrame(
    {
        "Field value": ["`(low, high)` with floats", "`(low, high)` with ints", "`[v1, v2, ...]`"],
        "Interpretation": ["Continuous range", "Integer range", "Categorical choices"],
        "Optuna sampler": [
            "`suggest_float` (log scale if `high/low >= 10`)",
            "`suggest_int`",
            "`suggest_categorical`",
        ],
    }
)

twiga_gt(
    GT(search_space_df)
    .tab_header(
        title=md("**BaseSearchSpace — Field Types**"), subtitle="How Twiga maps Python values to Optuna samplers"
    )
    .cols_label(**{c: md(f"**{c}**") for c in search_space_df.columns})
    .tab_source_note("twiga/core/config/base_model_config.py"),
    n_rows=len(search_space_df),
)
from typing import Literal

import optuna
from pydantic import Field

from twiga.core.config import BaseModelConfig, BaseSearchSpace


class MyModelConfig(BaseModelConfig):
    """Example config showing how to declare a search space for HPO."""

    name: Literal["my_model"] = Field(default="my_model", alias="model_name", exclude=True)
    domain: Literal["ml"] = Field(default="ml", alias="model_type", exclude=True)

    # Fixed parameters with sensible defaults
    n_estimators: int = Field(default=100, description="Number of trees.")
    learning_rate: float = Field(default=0.1, description="Step size shrinkage.")
    max_depth: int = Field(default=6, description="Maximum tree depth.")

    # Optuna search space — overrides fixed params during HPO
    search_space: BaseSearchSpace = Field(
        default=BaseSearchSpace(
            n_estimators=(50, 500),  # int range
            learning_rate=(0.01, 0.3),  # float range (log scale: 0.3/0.01 = 30 >= 10)
            max_depth=(3, 8),  # int range
        ),
        exclude=True,
    )


# Demonstrate fixed vs. sampled params
cfg = MyModelConfig()
log.info("Fixed params: %s", cfg.model_dump())

study = optuna.create_study(direction="minimize")
trial = study.ask()
log.info("Sampled params: %s", cfg.get_optuna_params(trial))

Connecting RidgeConfig to the HPO loop#

The same pattern is already wired into RidgeConfig. Calling forecaster.tune() would trigger ridge_instance.update(trial) on every trial, which calls ridge_config.get_optuna_params(trial) to sample alpha from the (0.01, 100.0) log range and fit_intercept categorically.

No changes to the forecaster or data pipeline are needed - HPO is handled transparently.

# Quick demonstration of RidgeConfig search space sampling
ridge_cfg = RidgeConfig()
study = optuna.create_study(direction="minimize")
trial = study.ask()
sampled = ridge_cfg.get_optuna_params(trial)
log.info("Sampled Ridge params for one trial: %s", sampled)

9. Framework portability: second dataset (PV proxy)#

To verify that the framework generalises beyond net-load forecasting, we reuse the same parquet file but target Ghi (global horizontal irradiance) as a proxy for a photovoltaic generation dataset. The only changes are the target column and the exogenous feature.

This mirrors a real-world scenario where you would have a separate PV dataset with the same temporal resolution and location.

from sklearn.preprocessing import RobustScaler, StandardScaler

from twiga import TwigaForecaster
from twiga.core.config import DataPipelineConfig, ForecasterConfig
from twiga.models.ml import LIGHTGBMConfig

# Reuse the same parquet — rename Ghi -> PV_proxy to simulate a PV dataset
data_pv = data[["timestamp", "Ghi", "Temperature"]].copy()
data_pv = data_pv.rename(columns={"Ghi": "PV_proxy"})

data_config_pv = DataPipelineConfig(
    target_feature="PV_proxy",
    period="30min",
    latitude=32.371666,
    longitude=-16.274998,
    calendar_features=["hour", "day_night"],
    exogenous_features=["Temperature"],
    forecast_horizon=48,
    lookback_window_size=96,
    input_scaler=StandardScaler(),
    target_scaler=RobustScaler(),
)

train_pv = data_pv[data_pv["timestamp"] < "2020-01-01"].reset_index(drop=True)
val_pv = data_pv[(data_pv["timestamp"] >= "2020-01-01") & (data_pv["timestamp"] < "2020-07-01")].reset_index(drop=True)
test_pv = data_pv[data_pv["timestamp"] >= "2020-07-01"].reset_index(drop=True)

forecaster_pv = TwigaForecaster(
    data_params=data_config_pv,
    model_params=[LIGHTGBMConfig()],
    train_params=ForecasterConfig(split_freq="months", train_size=3, test_size=1),
)
forecaster_pv.fit(train_df=train_pv, val_df=val_pv)
clear_output()
log.info("PV proxy (LightGBM) training complete.")
pred_pv, metric_pv = forecaster_pv.evaluate_point_forecast(test_df=test_pv)
clear_output()
log.info("PV proxy — mean metrics across folds:")
log.info("\n%s", metric_pv[["mae", "rmse", "corr"]].mean().round(3).to_string())
p = plot_forecast_grid(
    pred_pv.iloc[: 7 * 48].assign(Model="LightGBM"),
    actual_col="Actual",
    forecast_col="forecast",
    model_col="Model",
    n_samples_per_model=7 * 48,
    y_label="GHI proxy (W/m²)",
    title="PV proxy forecast — first 7 days of test set (LightGBM)",
)
p

10. What you’ve mastered: complete series summary#

from great_tables import GT, md
import pandas as pd

from twiga.core.plot.gt import twiga_gt

series_summary_df = pd.DataFrame(
    {
        "Notebook": ["NB01", "NB02", "NB03", "NB04", "NB05", "NB06", "NB07", "NB08", "NB09", "NB10", "NB11", "NB12"],
        "Title": [
            "Getting Started",
            "Forecastability Analysis",
            "Feature Engineering",
            "ML Point Forecasting",
            "Backtesting & Evaluation",
            "Neural Networks",
            "Probabilistic ML Forecasting",
            "Probabilistic NN Forecasting",
            "Conformal Prediction",
            "Hyperparameter Tuning",
            "Ensembles",
            "Custom Models & Extension Points",
        ],
        "Key concept introduced": [
            "End-to-end forecasting loop",
            "Spectral & statistical feasibility checks",
            "Temporal, calendar, and lag features",
            "Tree-based & linear point models",
            "Walk-forward cross-validation, metrics",
            "MLP-based NN architectures",
            "Quantile regression for ML models",
            "Parametric & quantile NN heads",
            "Distribution-free post-hoc calibration",
            "Optuna HPO with Twiga",
            "Multi-model combination strategies",
            "Extending the framework with custom models",
        ],
        "Main API exposed": [
            "`TwigaForecaster`, `DataPipelineConfig`, `ForecasterConfig`",
            "Forecastability utilities, ACF/PACF inspection",
            "`DataPipelineConfig` (`calendar_features`, `lags`, `windows`)",
            "`LINEAREGConfig`, `LIGHTGBMConfig`, `XGBOOSTConfig`, `CATBOOSTConfig`",
            "`backtesting()`, `evaluate_point_forecast()`, point metrics",
            "`MLPFConfig`, `MLPGAMConfig`, `MLPGAFConfig`, `BaseMLPConfig`",
            "`QRLIGHTGBMConfig`, `QRXGBOOSTConfig`, `QRCATBOOSTConfig`",
            "`MLPFQRConfig`, `MLPFNormalConfig`, `MLPFGammaConfig`, etc.",
            "`ConformalConfig`, `calibrate()`, CQR / CRC methods",
            "`tune()`, `BaseSearchSpace`, SQLite study persistence",
            "`ensemble_strategy` in `evaluate_point_forecast()`, `predict_interval()`",
            "`BaseRegressor`, `BaseNeuralForecast`, `BaseModelConfig`",
        ],
    }
)

twiga_gt(
    GT(series_summary_df)
    .tab_header(
        title=md("**Twiga Tutorial Series — Complete Overview**"),
        subtitle="12 notebooks from raw data to custom model extension",
    )
    .cols_label(**{c: md(f"**{c}**") for c in series_summary_df.columns})
    .tab_source_note("Twiga Forecast"),
    n_rows=len(series_summary_df),
)

Wrapping up#

What you did

  • Inspected the Twiga model registry and understood how name-to-class resolution works

  • Implemented RidgeConfig as a BaseModelConfig subclass with Pydantic fields and an Optuna search space

  • Implemented RidgeModel as a BaseRegressor subclass with __init__ and update

  • Registered the custom model in _model_cache and trained it through TwigaForecaster

  • Compared custom Ridge against the built-in LinearReg on net-load forecasting

  • Demonstrated framework portability by forecasting a second target (PV proxy) with no code changes

Key takeaways

  1. The model registry decouples config names from implementation classes - the forecaster never imports your model directly.

  2. Implementing a custom ML model requires overriding only two methods: __init__ and update.

  3. A BaseSearchSpace in your config is all that’s needed to unlock Optuna HPO - no changes to the forecaster or training loop.

  4. Wrapping any sklearn-compatible estimator in MultiOutputRegressor gives you full multi-step horizon support for free.

  5. The same TwigaForecaster API works unchanged across different targets and datasets - portability is built in.


What’s next?#

You’ve completed the core Twiga tutorial series. For research-grade experiments, see the examples/ scripts:

  • experiment.py: full multi-model benchmark pipeline with logging

  • data_embending.py: exploration of embedding strategies for time-series features

  • benchmark_experiment.py: large-scale comparison across datasets and model families

These scripts exercise the same TwigaForecaster API at scale and serve as production-ready templates for your own research workflows.

# ruff: noqa: E501, E701, E702
from IPython.display import HTML

_TEAL = "#107591"
_TEAL_MID = "#069fac"
_TEAL_LIGHT = "#e8f5f8"
_TEAL_BEST = "#d0ecf1"
_TEXT_DARK = "#2d3748"
_TEXT_MUTED = "#718096"
_WHITE = "#ffffff"

steps = [
    {
        "num": "05",
        "title": "ML Point Forecasting",
        "desc": "CatBoost · XGBoost · LightGBM — model registry basics",
        "tags": ["catboost", "xgboost", "registry"],
        "active": False,
    },
    {
        "num": "11",
        "title": "Hyperparameter Tuning",
        "desc": "Optuna TPE · typed search spaces · resumable SQLite",
        "tags": ["optuna", "HPO", "SearchSpace"],
        "active": False,
    },
    {
        "num": "12",
        "title": "Ensemble Strategies",
        "desc": "Mean · median · weighted-mean — multi-model aggregation",
        "tags": ["ensemble", "weighted"],
        "active": False,
    },
    {
        "num": "13",
        "title": "Custom Models",
        "desc": "Register your own model class in the Twiga model registry",
        "tags": ["custom", "registry", "BaseRegressor"],
        "active": True,
    },
    {
        "num": "16",
        "title": "SHAP Feature Attribution",
        "desc": "TreeExplainer · feature ranking · timestep attribution",
        "tags": ["SHAP", "explainability", "attribution"],
        "active": False,
    },
]
track_name = "Advanced Track"
footer = 'Next: explain your custom model\'s predictions with <span style="color:#107591;font-weight:600;">SHAP Feature Attribution</span> (16).'


def _b(t, bg, fg):
    return f'<span style="display:inline-block;background:{bg};color:{fg};font-size:10px;font-weight:600;padding:2px 7px;border-radius:10px;margin:2px 2px 0 0;">{t}</span>'


ch = ""
for i, s in enumerate(steps):
    a = s["active"]
    cb = _TEAL if a else _WHITE
    cbo = _TEAL if a else "#d1ecf1"
    nb = _TEAL_MID if a else _TEAL_LIGHT
    nf = _WHITE if a else _TEAL
    tf = _WHITE if a else _TEXT_DARK
    df = "#cce8ef" if a else _TEXT_MUTED
    bb = "#0d5f75" if a else _TEAL_BEST
    bf = "#b8e4ed" if a else _TEAL
    yh = (
        f'<span style="float:right;background:{_TEAL_MID};color:{_WHITE};font-size:10px;font-weight:700;padding:2px 10px;border-radius:12px;">★ you are here</span>'
        if a
        else ""
    )
    bdg = "".join(_b(t, bb, bf) for t in s["tags"])
    ch += f'<div style="background:{cb};border:2px solid {cbo};border-radius:12px;padding:16px 20px;display:flex;align-items:flex-start;gap:16px;box-shadow:{"0 4px 14px rgba(16,117,145,.25)" if a else "0 1px 4px rgba(0,0,0,.06)"};"><div style="min-width:44px;height:44px;background:{nb};color:{nf};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800;flex-shrink:0;">{s["num"]}</div><div style="flex:1;"><div style="font-size:15px;font-weight:700;color:{tf};margin-bottom:4px;">{s["title"]}{yh}</div><div style="font-size:12.5px;color:{df};margin-bottom:8px;line-height:1.5;">{s["desc"]}</div><div>{bdg}</div></div></div>'
    if i < len(steps) - 1:
        ch += f'<div style="display:flex;justify-content:center;height:32px;"><svg width="24" height="32" viewBox="0 0 24 32" fill="none"><line x1="12" y1="0" x2="12" y2="24" stroke="{_TEAL_MID}" stroke-width="2" stroke-dasharray="4 3"/><polygon points="6,20 18,20 12,30" fill="{_TEAL_MID}"/></svg></div>'

HTML(
    f'<div style="font-family:Inter,\'Segoe UI\',sans-serif;max-width:640px;margin:8px 0;"><div style="background:linear-gradient(135deg,{_TEAL} 0%,{_TEAL_MID} 100%);border-radius:12px 12px 0 0;padding:14px 20px;display:flex;align-items:center;gap:10px;"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="{_WHITE}" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg><span style="color:{_WHITE};font-size:14px;font-weight:700;">Twiga Learning Path — {track_name}</span></div><div style="border:2px solid {_TEAL_LIGHT};border-top:none;border-radius:0 0 12px 12px;padding:20px 20px 16px;background:#f9fdfe;display:flex;flex-direction:column;">{ch}<div style="margin-top:16px;font-size:11.5px;color:{_TEXT_MUTED};text-align:center;border-top:1px solid {_TEAL_LIGHT};padding-top:12px;">{footer}</div></div></div>'
)