Source code for memory_esn.dataset

"""
TimeSeriesDataset -- sliding-window train/val/test splits for univariate,
non-exogenous forecasting.

Guarantees temporal ordering, computes scaling parameters on the train split
only (no leakage), and can pad the test targets with NaN for iterative /
autoregressive forecasting.
"""

from __future__ import annotations

from typing import Literal, Optional, Tuple

import numpy as np


[docs] class TimeSeriesDataset: """Create lookback/lookahead windows for forecasting. Parameters ---------- series : ndarray, shape (n_timesteps,) Univariate time series. lookback : int Number of past steps used as input X. lookahead : int Number of future steps to predict (Y). test_size : int Number of final timesteps reserved for testing. val_size : int, optional Validation length; defaults to ``2 * test_size``. scaling : {'none', 'minmax', 'standard', 'log'}, default='none' Scaling fitted on the train split only. """ def __init__( self, series: np.ndarray, lookback: int, lookahead: int, test_size: int, val_size: Optional[int] = None, scaling: Literal["none", "minmax", "standard", "log"] = "none", ): series = np.asarray(series).flatten() if series.ndim != 1: raise ValueError("series must be 1-dimensional") if lookback < 1: raise ValueError("lookback must be >= 1") if lookahead < 1: raise ValueError("lookahead must be >= 1") if test_size < 1: raise ValueError("test_size must be >= 1") if val_size is None: val_size = 2 * test_size if val_size < 1: raise ValueError("val_size must be >= 1") if scaling not in ("none", "minmax", "standard", "log"): raise ValueError( f"scaling must be one of ['none','minmax','standard','log'], got '{scaling}'" ) if scaling == "log" and np.any(series <= 0): raise ValueError("Log scaling requires all data to be positive (> 0)") min_required = lookback + lookahead + test_size + val_size if len(series) < min_required: raise ValueError( f"Series too short. Need at least {min_required} timesteps, got {len(series)}" ) self.series_ = series self.scaling_ = scaling self.lookback_ = lookback self.lookahead_ = lookahead self.test_size_ = test_size self.val_size_ = val_size # Split BEFORE scaling so scaling params come from train data only test_series = series[-test_size:] train_val_series = series[:-test_size] val_series = train_val_series[-val_size:] train_series = train_val_series[:-val_size] self._compute_scaling_params(train_series) self.train_series_ = self._apply_scaling(train_series) self.val_series_ = self._apply_scaling(val_series) self.test_series_ = self._apply_scaling(test_series) self.full_train_series_ = self._apply_scaling(train_val_series) self.series_scaled_ = self._apply_scaling(series) self.train_end_idx_ = len(self.train_series_) self.val_start_idx_ = self.train_end_idx_ self.val_end_idx_ = self.val_start_idx_ + len(self.val_series_) self.test_start_idx_ = self.val_end_idx_ # ------------------------------------------------------------- scaling def _compute_scaling_params(self, train_data: np.ndarray) -> None: if self.scaling_ == "minmax": self.scale_min_ = np.min(train_data) self.scale_max_ = np.max(train_data) if self.scale_max_ == self.scale_min_: raise ValueError("Cannot minmax-scale: all training values identical") elif self.scaling_ == "standard": self.scale_mean_ = np.mean(train_data) self.scale_std_ = np.std(train_data) if self.scale_std_ == 0: raise ValueError("Cannot standard-scale: training data has zero variance") def _apply_scaling(self, data: np.ndarray) -> np.ndarray: if self.scaling_ == "minmax": return (data - self.scale_min_) / (self.scale_max_ - self.scale_min_) if self.scaling_ == "standard": return (data - self.scale_mean_) / self.scale_std_ if self.scaling_ == "log": return np.log(data) return data.copy()
[docs] def inverse_scaling(self, data: np.ndarray) -> np.ndarray: """Map scaled values (e.g. predictions) back to the original scale.""" if self.scaling_ == "minmax": return data * (self.scale_max_ - self.scale_min_) + self.scale_min_ if self.scaling_ == "standard": return data * self.scale_std_ + self.scale_mean_ if self.scaling_ == "log": return np.exp(data) return data.copy()
# ------------------------------------------------------------- windows def _create_sliding_windows(self, data: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: X_list, y_list = [], [] for i in range(len(data) - self.lookback_ - self.lookahead_ + 1): X_list.append(data[i : i + self.lookback_]) y_list.append(data[i + self.lookback_ : i + self.lookback_ + self.lookahead_]) if not X_list: return ( np.array([]).reshape(0, self.lookback_), np.array([]).reshape(0, self.lookahead_), ) return np.array(X_list), np.array(y_list)
[docs] def get_train_data(self) -> Tuple[np.ndarray, np.ndarray]: """Windows over the train split (for hyperparameter tuning).""" return self._create_sliding_windows(self.train_series_)
[docs] def get_val_data(self) -> Tuple[np.ndarray, np.ndarray]: """Windows over the validation split, with lookback context from train.""" context_size = self.lookback_ if len(self.train_series_) >= context_size: combined = np.concatenate([self.train_series_[-context_size:], self.val_series_]) else: combined = self.val_series_ X, y = self._create_sliding_windows(combined) n_context_windows = max(0, context_size - self.lookahead_) if n_context_windows > 0 and len(X) > n_context_windows: X, y = X[n_context_windows:], y[n_context_windows:] return X, y
[docs] def get_full_train_data(self) -> Tuple[np.ndarray, np.ndarray]: """Windows over train+val (for final model training).""" return self._create_sliding_windows(self.full_train_series_)
[docs] def get_test_data(self, use_predictions: bool = False) -> Tuple[np.ndarray, np.ndarray]: """Windows over the test split, with train+val context. Targets that run past the end of the series are padded with NaN so callers can implement iterative forecasting. """ context_size = self.lookback_ if len(self.full_train_series_) >= context_size: combined = np.concatenate([self.full_train_series_[-context_size:], self.test_series_]) else: combined = self.test_series_ X_list, y_list = [], [] max_start = len(combined) - self.lookback_ for i in range(max_start + 1): X = combined[i : i + self.lookback_] y_start = i + self.lookback_ y_end = y_start + self.lookahead_ if y_end <= len(combined): y = combined[y_start:y_end] else: available = combined[y_start : len(combined)] padding = np.full(y_end - len(combined), np.nan) y = np.concatenate([available, padding]) X_list.append(X) y_list.append(y) if not X_list: return ( np.array([]).reshape(0, self.lookback_), np.array([]).reshape(0, self.lookahead_), ) X, y = np.array(X_list), np.array(y_list) n_context_windows = max(0, context_size - self.lookahead_) if n_context_windows > 0 and len(X) > n_context_windows: X, y = X[n_context_windows:], y[n_context_windows:] return X, y
[docs] def get_info(self) -> dict: """Summary of split sizes, ranges and scaling parameters.""" info = { "series_length": len(self.series_), "lookback": self.lookback_, "lookahead": self.lookahead_, "test_size": self.test_size_, "val_size": self.val_size_, "train_length": len(self.train_series_), "val_length": len(self.val_series_), "full_train_length": len(self.full_train_series_), "scaling": self.scaling_, } if self.scaling_ == "minmax": info["scale_min"] = self.scale_min_ info["scale_max"] = self.scale_max_ elif self.scaling_ == "standard": info["scale_mean"] = self.scale_mean_ info["scale_std"] = self.scale_std_ return info
def __repr__(self) -> str: info = self.get_info() return ( f"TimeSeriesDataset(series_length={info['series_length']}, " f"lookback={info['lookback']}, lookahead={info['lookahead']}, " f"train={info['train_length']}, val={info['val_length']}, " f"test={info['test_size']})" )