Custom Models & Extension Points#
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:
Navigate the Twiga model registry and understand how models are discovered
Implement
BaseRegressorwithfit(),predict(), andget_params()methodsDefine a typed
SearchSpacefor Optuna to tune your custom model’s hyperparametersRegister your custom model with
register_model()and verify it works withTwigaForecasterDecide 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 |
|---|---|---|
|
|
All ML (tree + linear) models |
|
|
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 callTwigaForecaster(model_params=[my_config]), the forecaster readsmy_config.name, looks it up in_model_cache, and instantiatesModelClass(my_config).This decoupling matters: the forecaster never imports your model class directly. Any object that satisfies the
BaseRegressororBaseNeuralForecastinterface and has an entry in the registry will work transparently withfit(),evaluate_point_forecast(),tune(), andbacktesting()- 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()})"
)
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 |
|---|---|
|
Store config and assign |
|
Re-instantiate |
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
BaseRegressorinherits from sklearn’sBaseEstimatorandRegressorMixin. 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 toself.model.fitby default.
predict(x) → np.ndarray- return predictions for the given input; delegates toself.model.predict.For a custom model you only need to override
__init__(assignself.model) andupdate(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__setsself.model_configand assigns a concrete sklearn estimator toself.model.update(trial)callsself.model_config.get_optuna_params(trial)and re-instantiatesself.modelwith the sampled values.No
fitorpredictoverride - those are fully handled byBaseRegressor.The sklearn estimator is wrapped in
MultiOutputRegressorso it handles the multi-step horizon; forRidgewe 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:
Subclass
BaseModelConfig- declare your hyperparameters as Pydantic fields and optionally add aBaseSearchSpacefor HPO.Subclass
BaseRegressor- implement__init__(assignself.model) andupdate(trial).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:
RidgeConfig- aBaseModelConfigsubclass that declares thealphahyperparameter and exposes it throughget_optuna_paramsfor HPO.RidgeModel- aBaseRegressorsubclass that wraps sklearn’sRidgeinsideMultiOutputRegressor(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
RidgeConfigas aBaseModelConfigsubclass with Pydantic fields and an Optuna search spaceImplemented
RidgeModelas aBaseRegressorsubclass with__init__andupdateRegistered the custom model in
_model_cacheand trained it throughTwigaForecasterCompared 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
The model registry decouples config names from implementation classes - the forecaster never imports your model directly.
Implementing a custom ML model requires overriding only two methods:
__init__andupdate.A
BaseSearchSpacein your config is all that’s needed to unlock Optuna HPO - no changes to the forecaster or training loop.Wrapping any sklearn-compatible estimator in
MultiOutputRegressorgives you full multi-step horizon support for free.The same
TwigaForecasterAPI 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 loggingdata_embending.py: exploration of embedding strategies for time-series featuresbenchmark_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>'
)