Source code for memory_esn.base

"""
BaseESN -- a single-reservoir Echo State Network.

The reservoir (fixed, random recurrent layer) transforms the input sequence into
a high-dimensional state trajectory; only a linear ridge readout is trained.
State updates use the BLAS-backed Cython kernel when available.
"""

from __future__ import annotations

from typing import List, Optional, Tuple, Union

import numpy as np
import scipy.sparse as _sp
from sklearn.linear_model import RidgeCV

from ._speedups import reservoir_update as _reservoir_update
from ._speedups import reservoir_update_sparse as _reservoir_update_sparse
from .weights import (
    init_bias,
    init_input_weights,
    init_reservoir_weights,
    validate_distribution,
)


class PersistenceMixin:
    """Adds ``save`` / ``load`` (joblib) to any model in the hierarchy."""

    def save(self, filepath: str) -> None:
        """Serialize the fitted model to ``filepath`` with joblib."""
        import joblib

        joblib.dump(self, filepath)

    @classmethod
    def load(cls, filepath: str):
        """Load a model previously written with :meth:`save`."""
        import joblib

        return joblib.load(filepath)


[docs] class BaseESN(PersistenceMixin): """Single-reservoir Echo State Network. Parameters ---------- n_reservoir : int, default=100 Number of reservoir neurons. spectral_radius : float, default=0.9 Target spectral radius of the reservoir weight matrix (controls memory / stability; keep < 1 for the echo-state property). input_scaling : float, default=0.5 Scale of the random input weights. input_init : str, default='uniform' Distribution for the input weights: 'uniform', 'gaussian'/'normal', 'bernoulli'/'binary', or 'laplace'. See :mod:`memory_esn.weights`. (Uniform is the paper's initialisation.) reservoir_init : str, default='uniform' Distribution for the reservoir weights (same options). The matrix is still rescaled to ``spectral_radius`` afterwards. bias_init : str, default='uniform' Distribution for the neuron bias (same options; only used when ``bias_scaling > 0``). leaky : float, default=1.0 Leaky-integration rate (1.0 = no leak). activation : str, default='tanh' Reservoir nonlinearity. One of: 'tanh', 'sigmoid', 'relu', 'leaky_relu', 'elu', 'selu', 'softplus', 'swish'/'silu', 'gelu', 'hard_tanh', 'softsign', 'mish', 'hard_sigmoid', 'relu6', 'tanhshrink', 'sin', 'identity'/'linear'. bias_scaling : float, default=0.0 Scale of the random neuron bias (0.0 = no bias). noise : float, default=0.0 Std-dev sigma of isotropic additive state noise eta(t) ~ N(0, sigma^2 I) injected into the pre-activation each timestep (0.0 = no noise). Drawn from the reservoir RNG, so results are reproducible for a fixed seed. sparsity : float, default=0.9 Fraction of reservoir connections forced to zero (0.0 = dense, 0.9 = 90% zeros). Matches the paper's sparsification proportion phi. random_state : int or None, default=None Seed for reproducibility. alphas : tuple of float, default=(0.01, 0.1, 1.0, 10.0) Candidate ridge penalties for :class:`~sklearn.linear_model.RidgeCV`. ridge_cv_params : dict, optional Extra keyword arguments forwarded to ``RidgeCV``. concatenate_input : bool, default=True If True, append the raw input to the reservoir states before the readout. Examples -------- >>> esn = BaseESN(n_reservoir=200, spectral_radius=0.95, random_state=0) >>> esn.fit(X_train, y_train, washout=100) >>> y_pred = esn.predict(X_test) """ def __init__( self, n_reservoir: int = 100, spectral_radius: float = 0.9, input_scaling: float = 0.5, input_init: str = "uniform", reservoir_init: str = "uniform", bias_init: str = "uniform", leaky: float = 1.0, activation: str = "tanh", bias_scaling: float = 0.0, noise: float = 0.0, sparsity: float = 0.9, random_state: Optional[int] = None, alphas: Tuple[float, ...] = (0.01, 0.1, 1.0, 10.0), ridge_cv_params: Optional[dict] = None, concatenate_input: bool = True, ): validate_distribution(input_init, "input_init") validate_distribution(reservoir_init, "reservoir_init") validate_distribution(bias_init, "bias_init") if noise < 0: raise ValueError(f"noise (std-dev) must be >= 0, got {noise}") self.n_reservoir = n_reservoir self.spectral_radius = spectral_radius self.input_scaling = input_scaling self.input_init = input_init self.reservoir_init = reservoir_init self.bias_init = bias_init self.leaky = leaky self.activation = activation self.bias_scaling = bias_scaling self.noise = noise self.sparsity = sparsity self.random_state = random_state self.alphas = alphas self.ridge_cv_params = ridge_cv_params or {} self.concatenate_input = concatenate_input # Learned / initialised during fit self.W_in_ = None self.W_reservoir_ = None self.bias_ = None self.readout_ = None self.n_inputs_ = None self.n_outputs_ = None self._is_fitted = False # Last reservoir state, for prediction continuation self.last_state_ = None self.rng_ = np.random.RandomState(random_state) # ------------------------------------------------------------------ setup def _initialize_weights(self, n_inputs: int) -> None: """Draw input/reservoir/bias weights and rescale W to the target radius. Draw order (input -> reservoir -> bias) is preserved, so the default 'gaussian' settings reproduce earlier results bit-for-bit given a seed. """ self.W_in_ = init_input_weights( self.rng_, self.n_reservoir, n_inputs, scaling=self.input_scaling, distribution=self.input_init, ) self.W_reservoir_ = init_reservoir_weights( self.rng_, self.n_reservoir, spectral_radius=self.spectral_radius, distribution=self.reservoir_init, sparsity=self.sparsity, ) self.bias_ = init_bias( self.rng_, self.n_reservoir, scaling=self.bias_scaling, distribution=self.bias_init, ) self.n_inputs_ = n_inputs def _compute_states( self, X: np.ndarray, x0: Optional[np.ndarray] = None, continuation: bool = False, ) -> np.ndarray: """Run the reservoir over ``X`` and return the state trajectory. The final state is cached in ``last_state_`` for continuation. """ if continuation and self.last_state_ is not None: initial_state = self.last_state_.copy() elif x0 is not None: initial_state = x0 else: initial_state = None # Isotropic additive state noise eta(t) ~ N(0, noise^2 I), drawn from the # reservoir RNG (reproducible). None when noise == 0 (default), so the # RNG stream is untouched and default runs stay bit-identical. if self.noise > 0: noise_sequence = self.rng_.randn(X.shape[0], self.n_reservoir) * self.noise else: noise_sequence = None # Large, highly-sparse reservoirs store W as a scipy CSR matrix; use the # sparse matvec path for them and the dense BLAS dgemv kernel otherwise. update = ( _reservoir_update_sparse if _sp.issparse(self.W_reservoir_) else _reservoir_update ) states = update( input_sequence=np.ascontiguousarray(X, dtype=np.float64), W_in=self.W_in_, W_reservoir=self.W_reservoir_, activation=self.activation, leaky=self.leaky, bias=self.bias_, x0=initial_state, noise_sequence=noise_sequence, ) self.last_state_ = states[-1].copy() return states @staticmethod def _as_2d(a: np.ndarray) -> np.ndarray: # A 1-D series of length N is a univariate time series -> (N, 1). # (np.atleast_2d would wrongly make it (1, N): 1 timestep, N features.) a = np.asarray(a) if a.ndim == 1: a = a.reshape(-1, 1) return a # ------------------------------------------------------------------- API
[docs] def fit( self, X: np.ndarray, y: np.ndarray, washout: int = 0, x0: Optional[np.ndarray] = None, ) -> "BaseESN": """Fit the reservoir readout. Parameters ---------- X : ndarray, shape (n_timesteps, n_inputs) y : ndarray, shape (n_timesteps, n_outputs) washout : int, default=0 Number of initial timesteps to discard before training the readout. x0 : ndarray, optional Initial reservoir state. """ X = self._as_2d(X) y = self._as_2d(y) n_timesteps, n_inputs = X.shape self.n_outputs_ = y.shape[1] if self.W_in_ is None: self._initialize_weights(n_inputs) states = self._compute_states(X, x0=x0) extended = np.hstack([states, X]) if self.concatenate_input else states if washout > 0: extended = extended[washout:] y = y[washout:] self.readout_ = RidgeCV(alphas=self.alphas, **self.ridge_cv_params) self.readout_.fit(extended, y) self._is_fitted = True return self
[docs] def predict( self, X: np.ndarray, x0: Optional[np.ndarray] = None, continuation: bool = False, ) -> np.ndarray: """Predict outputs for ``X`` (optionally continuing from the last state).""" if not self._is_fitted: raise RuntimeError("Model must be fitted before prediction. Call fit() first.") X = self._as_2d(X) states = self._compute_states(X, x0=x0, continuation=continuation) extended = np.hstack([states, X]) if self.concatenate_input else states pred = self.readout_.predict(extended) if pred.ndim == 1: pred = pred.reshape(-1, self.n_outputs_) return pred
[docs] def get_states( self, X: np.ndarray, x0: Optional[np.ndarray] = None, continuation: bool = False, ) -> np.ndarray: """Return reservoir states for ``X`` (weights must already be initialised).""" if self.W_in_ is None: raise RuntimeError("Weights not initialized. Call fit() first.") return self._compute_states(self._as_2d(X), x0=x0, continuation=continuation)
[docs] def reset_state(self) -> None: """Forget the cached reservoir state used for continuation.""" self.last_state_ = None
def __repr__(self) -> str: return ( f"BaseESN(n_reservoir={self.n_reservoir}, " f"spectral_radius={self.spectral_radius}, activation='{self.activation}')" )