"""
wESN -- a :class:`~memory_esn.double.DoubleReservoirESN` whose second
reservoir sees a *wavelet-transformed then fractionally differenced* view of the input.
Pipeline for reservoir 2::
raw X -> MODWT decomposition -> select component(s) -> fractional differencing
Reservoir 1 still receives the raw series. By default the second reservoir sees
the level-``wavelet_level`` smooth (approximation) coefficients -- a multi-scale
low-frequency trend. Via ``wavelet_components`` you may instead (or additionally)
select individual detail levels; selecting several stacks them into a
**multivariate** input for the second reservoir.
"""
from __future__ import annotations
from typing import List, Optional, Tuple, Union
import numpy as np
from ._speedups import fractional_diff as _fractional_diff
from ._speedups import modwt as _modwt
from .double import DoubleReservoirESN, _resolve_d
Pair = Union[Tuple, list]
# PyWavelets short family names that are *continuous* wavelets (no decomposition
# filters -> unusable by the MODWT). Support for these is planned but not built.
_CONTINUOUS_FAMILIES = {"cgau", "cmor", "fbsp", "gaus", "mexh", "morl", "shan"}
# Aliases accepted for the smooth / approximation component.
_SMOOTH_ALIASES = {"smooth", "approx", "approximation", "a"}
def _family_of(name: str) -> str:
"""Leading alphabetic token of a wavelet name, e.g. 'gaus3' -> 'gaus'."""
i = 0
while i < len(name) and name[i].isalpha():
i += 1
return name[:i].lower()
def _validate_wavelet(name: str) -> None:
"""Raise a helpful error unless ``name`` is a discrete wavelet usable by MODWT.
Continuous wavelets raise :class:`NotImplementedError` (planned for a future
release); unknown names raise :class:`ValueError`.
"""
try:
import pywt
except ImportError as exc: # pragma: no cover
raise ImportError(
"wESN requires PyWavelets (`pip install pywavelets`)."
) from exc
try:
pywt.Wavelet(name) # succeeds only for discrete wavelets with dec filters
return
except (ValueError, Exception): # noqa: BLE001
pass
if _family_of(name) in _CONTINUOUS_FAMILIES or name in set(
pywt.wavelist(kind="continuous")
):
raise NotImplementedError(
f"Continuous wavelet '{name}' is not supported yet -- support for "
"continuous wavelets is planned for a future release. For now use a "
"discrete wavelet: haar, db*, sym*, coif*, bior*, rbio*, dmey "
"(see pywt.wavelist(kind='discrete'))."
)
raise ValueError(
f"Unknown wavelet '{name}'. Choose a discrete wavelet from the families "
"haar, db, sym, coif, bior, rbio, dmey "
"(full list: pywt.wavelist(kind='discrete'))."
)
[docs]
class wESN(DoubleReservoirESN):
"""Wavelet Echo State Network: dual-reservoir ESN with a MODWT +
fractional-differencing memory branch.
Parameters
----------
n_reservoir, spectral_radius, input_scaling, input_init, reservoir_init, bias_init, leaky, activation, bias_scaling, noise, sparsity :
Scalar or length-2 pair (reservoir 1 = raw, reservoir 2 = processed).
d : float or list of float, default=0.25
Fractional differencing order(s) applied to the selected wavelet
component(s). A single value (default) is univariate; a list applies
each order and stacks the results, so the second reservoir's input is the
**cross-product** of components and orders
(``n_features x n_components x n_d`` channels).
K : int, default=100
Number of lags in the fractional-difference filter.
wavelet : str, default='haar'
Discrete wavelet understood by PyWavelets (e.g. 'haar', 'db4', 'sym4').
Continuous wavelets ('mexh', 'morl', ...) are rejected -- see
:func:`_validate_wavelet`.
wavelet_level : int, default=2
MODWT decomposition depth ``J`` (number of detail levels produced).
wavelet_components : default='smooth'
Which MODWT component(s) feed reservoir 2. One of:
* ``'smooth'`` / ``'approx'`` -- the level-``J`` approximation (default,
i.e. the classic smooth trend).
* an int ``k`` in ``1..J`` -- detail coefficients at level ``k``.
* ``'details'`` -- all detail levels ``D_1..D_J``.
* ``'all'`` -- all details plus the smooth.
* a list mixing ints and ``'smooth'``, e.g. ``[1, 2, 'smooth']``.
Selecting more than one component makes the second reservoir's input
**multivariate** (one column per selected component, per input feature).
wavelet_norm : {'modwt', 'classic'}, default='modwt'
MODWT filter normalisation. 'modwt' uses the standard Percival-Walden
rescaling (Haar smooth filter [1/2, 1/2]); 'classic' uses the DWT
orthonormal filters ([1/sqrt2, 1/sqrt2]). The two differ only by a
per-level constant that the random memory-input weights absorb, so they
are model-equivalent; 'classic' reproduces the paper's example equation
literally.
skip_initial : bool, default=True
If True, drop the first ``K`` timesteps of the differenced branch.
random_state, alphas, ridge_cv_params, concatenate_inputs, verbose :
See :class:`~memory_esn.multi.MultiESN`.
Examples
--------
>>> # classic smooth trend (backward-compatible default)
>>> wesn = wESN(n_reservoir=(200, 150), wavelet='db4', wavelet_level=2)
>>> # multivariate: levels 1 & 2 detail plus the smooth
>>> wesn = wESN(wavelet='db4', wavelet_level=3,
... wavelet_components=[1, 2, 'smooth'])
"""
def __init__(
self,
n_reservoir: Union[int, Pair] = 100,
spectral_radius: Union[float, Pair] = 0.9,
input_scaling: Union[float, Pair] = 0.5,
input_init: Union[str, Pair] = "uniform",
reservoir_init: Union[str, Pair] = "uniform",
bias_init: Union[str, Pair] = "uniform",
leaky: Union[float, Pair] = 1.0,
activation: Union[str, Pair] = "tanh",
bias_scaling: Union[float, Pair] = 0.0,
noise: Union[float, Pair] = 0.0,
sparsity: Union[float, Pair] = 0.9,
d: float = 0.25,
K: int = 100,
wavelet: str = "haar",
wavelet_level: int = 2,
wavelet_components: Union[str, int, list, tuple] = "smooth",
wavelet_norm: str = "modwt",
skip_initial: bool = True,
random_state: Union[int, Pair, None] = None,
alphas: Tuple[float, ...] = (0.01, 0.1, 1.0, 10.0),
ridge_cv_params: Optional[dict] = None,
concatenate_inputs: bool = True,
verbose: bool = False,
):
if wavelet_level < 1:
raise ValueError(f"wavelet_level must be >= 1, got {wavelet_level}")
_validate_wavelet(wavelet)
if wavelet_norm not in ("modwt", "classic"):
raise ValueError(
f"wavelet_norm must be 'modwt' or 'classic', got '{wavelet_norm}'"
)
super().__init__(
n_reservoir=n_reservoir,
spectral_radius=spectral_radius,
input_scaling=input_scaling,
input_init=input_init,
reservoir_init=reservoir_init,
bias_init=bias_init,
leaky=leaky,
activation=activation,
bias_scaling=bias_scaling,
noise=noise,
sparsity=sparsity,
random_state=random_state,
alphas=alphas,
ridge_cv_params=ridge_cv_params,
concatenate_inputs=concatenate_inputs,
verbose=verbose,
)
self.d_ = d
self._d_list_ = _resolve_d(d) # normalized list of orders (cross with components)
self.K_ = K
self.wavelet_ = wavelet
self.wavelet_level_ = wavelet_level
self.wavelet_components = wavelet_components
self.wavelet_norm = wavelet_norm
# MODWT filter scale: 1/sqrt(2) = standard (Percival-Walden);
# 1.0 = classic DWT filters ([1/sqrt2, 1/sqrt2] Haar smooth).
self._norm_factor_ = 1.0 / np.sqrt(2.0) if wavelet_norm == "modwt" else 1.0
self.skip_initial_ = skip_initial
# Resolve the requested components to row indices in the (J+1, N) MODWT
# coefficient array: rows 0..J-1 are details D_1..D_J, row J is smooth A_J.
self._component_rows_, self._component_labels_ = self._resolve_components(
wavelet_components, wavelet_level
)
# History for boundary-free smoothing and fracdiff continuation
self.X_train_raw_: Optional[np.ndarray] = None
self._last_K_wav_inputs: Optional[np.ndarray] = None
# ---------------------------------------------------- component selection
@staticmethod
def _resolve_components(spec, J: int) -> Tuple[List[int], List[str]]:
"""Map a ``wavelet_components`` spec to (row indices, human labels)."""
def one(item) -> Tuple[int, str]:
if isinstance(item, str):
if item.lower() in _SMOOTH_ALIASES:
return J, f"A{J}" # smooth / approximation row
raise ValueError(
f"Unknown wavelet component '{item}'. Use an int level 1..{J} "
"or 'smooth'."
)
if isinstance(item, (int, np.integer)):
k = int(item)
if not (1 <= k <= J):
raise ValueError(
f"Detail level {k} is out of range 1..{J} "
f"(wavelet_level={J})."
)
return k - 1, f"D{k}" # detail level k -> row k-1
raise TypeError(
f"wavelet_components entries must be int or str, got {type(item).__name__}"
)
# String shortcuts that expand to several components
if isinstance(spec, str) and spec.lower() == "all":
rows = list(range(J + 1))
return rows, [f"D{k}" for k in range(1, J + 1)] + [f"A{J}"]
if isinstance(spec, str) and spec.lower() == "details":
return list(range(J)), [f"D{k}" for k in range(1, J + 1)]
# Single value or explicit list/tuple
items = spec if isinstance(spec, (list, tuple)) else [spec]
if len(items) == 0:
raise ValueError("wavelet_components must select at least one component.")
rows, labels = [], []
for it in items:
r, lbl = one(it)
rows.append(r)
labels.append(lbl)
return rows, labels
# ---------------------------------------------------------- transforms
@staticmethod
def _as_2d(X: np.ndarray) -> np.ndarray:
# 1-D univariate series -> (N, 1), not (1, N).
X = np.asarray(X)
if X.ndim == 1:
X = X.reshape(-1, 1)
return X
def _wavelet_features(self, X: np.ndarray) -> np.ndarray:
"""Return the selected MODWT component(s) as a (N, F * n_components) matrix.
For each input feature the MODWT is computed once and the requested rows
are extracted; multiple components stack as extra columns (multivariate).
"""
X = self._as_2d(X)
n_samples, n_features = X.shape
columns: List[np.ndarray] = []
for i in range(n_features):
coeffs = _modwt(
np.ascontiguousarray(X[:, i], dtype=np.float64),
self.wavelet_,
self.wavelet_level_,
self._norm_factor_,
) # shape (wavelet_level + 1, n_samples)
for row in self._component_rows_:
columns.append(coeffs[row, :])
return np.column_stack(columns) # (n_samples, n_features * n_components)
def _apply_fractional_diff(self, X: np.ndarray, continuation: bool) -> np.ndarray:
"""Fractionally difference the wavelet features at each order in ``d``.
With several orders the results are stacked, so the second reservoir sees
``n_wavelet_channels x n_d`` columns -- the cross-product of the selected
wavelet components and the differencing orders. History is prepended once
and shared by all orders so their output rows stay aligned.
"""
X = self._as_2d(X)
if continuation and self._last_K_wav_inputs is not None:
X_ext = np.vstack([self._last_K_wav_inputs, X])
else:
X_ext = X
blocks = []
for d in self._d_list_:
# Pure-memory filter ((1-B)^d - 1) applied to the smoothed signal s(t):
# drop the present (zero-lag) term -> f(t) = sum_{k>=1} omega_k s(t-k).
X_diff = _fractional_diff(
X_ext, d=d, K=self.K_, skip_initial=self.skip_initial_,
include_zero_lag=False,
)
if continuation and self._last_K_wav_inputs is not None and not self.skip_initial_:
X_diff = X_diff[self._last_K_wav_inputs.shape[0]:]
blocks.append(X_diff)
return blocks[0] if len(blocks) == 1 else np.hstack(blocks)
def _store_wav_history(self, X_wav: np.ndarray) -> None:
if X_wav.shape[0] >= self.K_:
self._last_K_wav_inputs = X_wav[-self.K_:, :].copy()
else:
pad = np.zeros((self.K_ - X_wav.shape[0], X_wav.shape[1]))
self._last_K_wav_inputs = np.vstack([pad, X_wav])
# ----------------------------------------------------------------- API
[docs]
def fit(
self,
X: np.ndarray,
y: np.ndarray,
washout: int = 0,
x0_list: Optional[list] = None,
) -> "wESN":
"""Fit on a single raw series ``X`` (wavelet -> fracdiff for reservoir 2)."""
X = self._as_2d(X)
y = self._as_2d(y)
self.X_train_raw_ = X.copy()
X_wav = self._wavelet_features(X)
X_frac = self._apply_fractional_diff(X_wav, continuation=False)
self._store_wav_history(X_wav)
if self.skip_initial_:
X_raw_aligned = X[self.K_:, :]
y_aligned = y[self.K_:, :]
else:
X_raw_aligned = X
y_aligned = y
super().fit([X_raw_aligned, X_frac], y_aligned, washout=washout, x0_list=x0_list)
return self
[docs]
def predict(
self,
X: np.ndarray,
continuation: bool = True,
x0_list: Optional[list] = None,
) -> np.ndarray:
"""Predict from a single raw series ``X``.
With ``continuation=True`` (default) the training history is prepended so
the wavelet transform is boundary-free and the output aligns with ``X``.
"""
if not self._is_fitted:
raise RuntimeError("Model must be fitted before prediction. Call fit() first.")
X = self._as_2d(X)
# 1. Wavelet features (use full history in continuation to avoid boundary effects)
if continuation and self.X_train_raw_ is not None:
X_full = np.vstack([self.X_train_raw_, X])
X_wav = self._wavelet_features(X_full)[-len(X):, :]
else:
X_wav = self._wavelet_features(X)
# 2. Fractional differencing (continuation prepends stored history)
X_frac = self._apply_fractional_diff(X_wav, continuation=continuation)
# 3. Align raw branch
if not continuation and self.skip_initial_:
X_raw_aligned = X[self.K_:, :]
else:
X_raw_aligned = X
return super().predict([X_raw_aligned, X_frac], x0_list=x0_list, continuation=continuation)
[docs]
def reset_states(self) -> None:
"""Reset reservoir states and the wavelet/fracdiff history buffers."""
super().reset_states()
self._last_K_wav_inputs = None
def __repr__(self) -> str:
d_repr = self._d_list_[0] if len(self._d_list_) == 1 else self._d_list_
return (
f"wESN(sizes={[r.n_reservoir for r in self.reservoirs_]}, "
f"d={d_repr}, K={self.K_}, wavelet='{self.wavelet_}', "
f"level={self.wavelet_level_}, components={self._component_labels_})"
)
# Backward-compatible alias (the class is now named ``wESN``)
WaveletESN = wESN