Repository: reservoirpy/reservoirpy

Issue:
Inconsistent use of noise seed
I noticed that in `rpy.utils.random.noise` a new random number generator is created in each function call.  As such, if we call the function multiple times in a row with the same seed value, we get the same output:
```
>>> import reservoirpy as rpy
>>> rpy.utils.random.noise(seed=42, shape=3)
array([ 0.30471708, -1.03998411,  0.7504512 ])
>>> rpy.utils.random.noise(seed=42, shape=3)
array([ 0.30471708, -1.03998411,  0.7504512 ])
```
In the `Reservoir` class, the noise generator is set as `partial(noise, seed=seed)`, so I think each time it is called, e.g., in `rpy.base.reservoir_kernel`, it will produce the same values. 
 
Perhaps this is the intended behavior.  I know I've needed to do so in other code I've written.  However, I would point out that in `rpy.base.reservoir_kernel`, the input noise term in the computation of `pre_s` directly uses the noise function `rpy.utils.random.noise`, which means it passes in the default seed value `None` and produces different values each function call.  In contrast, the noise term in the feedback computation uses `noise_gen`, which is the reservoir's noise generation method, and has the seed frozen.  This is inconsistent, and I'm not sure if it is intended.  Note that both `rpy.base.forward_internal` and `rpy.base.forward_external` also use the `noise_gen` method (with fixed seed).

Discussion:

Patch:
diff --git a/README.md b/README.md
index b7da636..98264bc 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
 [![Testing](https://github.com/reservoirpy/reservoirpy/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/reservoirpy/reservoirpy/actions/workflows/test.yml)
 [![codecov](https://codecov.io/gh/reservoirpy/reservoirpy/branch/master/graph/badge.svg?token=JC8R1PB5EO)](https://codecov.io/gh/reservoirpy/reservoirpy)
 
-# ReservoirPy (v0.3.6) 🌀🧠
+# ReservoirPy (v0.3.7) 🌀🧠
 **Simple and flexible code for Reservoir Computing architectures like Echo State Networks (ESN).**
 
 [![Binder](https://mybinder.org/badge.svg)](https://mybinder.org/v2/gh/reservoirpy/reservoirpy/HEAD)
diff --git a/reservoirpy/_base.py b/reservoirpy/_base.py
index 1c38670..4af4520 100644
--- a/reservoirpy/_base.py
+++ b/reservoirpy/_base.py
@@ -620,7 +620,7 @@ class _Node(ABC):
         elif item in self._hypers:
             return self._hypers.get(item)
         else:
-            raise AttributeError(f"{self.name} has no attribute '{str(item)}'")
+            raise AttributeError(f"'{str(item)}'")
 
     def __call__(self, *args, **kwargs) -> np.ndarray:
         return self.call(*args, **kwargs)
diff --git a/reservoirpy/_version.py b/reservoirpy/_version.py
index d7b30e1..8879c6c 100644
--- a/reservoirpy/_version.py
+++ b/reservoirpy/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.3.6"
+__version__ = "0.3.7"
diff --git a/reservoirpy/datasets/_chaos.py b/reservoirpy/datasets/_chaos.py
index 5609f14..6b67fd5 100644
--- a/reservoirpy/datasets/_chaos.py
+++ b/reservoirpy/datasets/_chaos.py
@@ -291,7 +291,7 @@ def mackey_glass(
             xtau = history.popleft()
             history.append(xt)
 
-        xth = _mg_rk4(xt, xtau, a=a, b=b, n=n)
+        xth = _mg_rk4(xt, xtau, a=a, b=b, n=n, h=h)
 
         xt = xth
 
diff --git a/reservoirpy/nodes/reservoirs/base.py b/reservoirpy/nodes/reservoirs/base.py
index 41de49a..5e48d99 100644
--- a/reservoirpy/nodes/reservoirs/base.py
+++ b/reservoirpy/nodes/reservoirs/base.py
@@ -4,7 +4,6 @@
 import numpy as np
 
 from ...mat_gen import zeros
-from ...utils.random import noise
 from ...utils.validation import is_array
 
 
@@ -21,7 +20,7 @@ def reservoir_kernel(reservoir, u, r):
     dist = reservoir.noise_type
     noise_gen = reservoir.noise_generator
 
-    pre_s = W @ r + Win @ (u + noise(dist, u.shape, g_in)) + bias
+    pre_s = W @ r + Win @ (u + noise_gen(dist=dist, shape=u.shape, gain=g_in)) + bias
 
     if reservoir.has_feedback:
         Wfb = reservoir.Wfb
@@ -29,7 +28,7 @@ def reservoir_kernel(reservoir, u, r):
         h = reservoir.fb_activation
 
         y = reservoir.feedback().reshape(-1, 1)
-        y = h(y) + noise_gen(dist, y.shape, g_fb)
+        y = h(y) + noise_gen(dist=dist, shape=y.shape, gain=g_fb)
 
         pre_s += Wfb @ y
 
@@ -58,7 +57,7 @@ def forward_internal(reservoir, x: np.ndarray) -> np.ndarray:
     s_next = (
         (1 - lr) * r
         + lr * f(reservoir_kernel(reservoir, u, r))
-        + noise_gen(dist, r.shape, g_rc)
+        + noise_gen(dist=dist, shape=r.shape, gain=g_rc)
     )
 
     return s_next.T
@@ -90,7 +89,7 @@ def forward_external(reservoir, x: np.ndarray) -> np.ndarray:
     s_next = (
         (1 - lr) * s
         + lr * reservoir_kernel(reservoir, u, r)
-        + noise_gen(dist, r.shape, g_rc)
+        + noise_gen(dist=dist, shape=r.shape, gain=g_rc)
     )
 
     reservoir.set_param("internal_state", s_next.T)
diff --git a/reservoirpy/nodes/reservoirs/intrinsic_plasticity.py b/reservoirpy/nodes/reservoirs/intrinsic_plasticity.py
index 5d5f211..d32dadd 100644
--- a/reservoirpy/nodes/reservoirs/intrinsic_plasticity.py
+++ b/reservoirpy/nodes/reservoirs/intrinsic_plasticity.py
@@ -9,7 +9,7 @@ else:
     from typing import Literal
 
 from functools import partial
-from typing import Callable, Optional, Sequence, Union
+from typing import Callable, Optional, Sequence, Union, Dict
 
 import numpy as np
 
@@ -18,7 +18,7 @@ from ...activationsfunc import get_function, identity
 from ...mat_gen import bernoulli, uniform
 from ...node import Unsupervised, _init_with_sequences
 from ...type import Weights
-from ...utils.random import noise
+from ...utils.random import noise, rand_generator
 from ...utils.validation import is_array
 from .base import forward_external
 from .base import initialize as initialize_base
@@ -211,6 +211,9 @@ class IPReservoir(Unsupervised):
     noise_type : str, default to "normal"
         Distribution of noise. Must be a Numpy random variable generator
         distribution (see :py:class:`numpy.random.Generator`).
+    noise_kwargs : dict, optional
+        Keyword arguments to pass to the noise generator, such as `low` and `high`
+        values of uniform distribution.
     input_scaling : float or array-like of shape (features,), default to 1.0.
         Input gain. An array of the same dimension as the inputs can be used to
         set up different input scaling for each feature.
@@ -318,6 +321,7 @@ class IPReservoir(Unsupervised):
         noise_in: float = 0.0,
         noise_fb: float = 0.0,
         noise_type: str = "normal",
+        noise_kwargs: Dict = None,
         input_scaling: Union[float, Sequence] = 1.0,
         bias_scaling: float = 1.0,
         fb_scaling: Union[float, Sequence] = 1.0,
@@ -347,6 +351,9 @@ class IPReservoir(Unsupervised):
                 "appliying intrinsic plasticity."
             )
 
+        rng = rand_generator(seed=seed)
+        noise_kwargs = dict() if noise_kwargs is None else noise_kwargs
+
         super(IPReservoir, self).__init__(
             fb_initializer=partial(
                 initialize_feedback,
@@ -387,7 +394,7 @@ class IPReservoir(Unsupervised):
                 ),
                 "fb_activation": fb_activation,
                 "units": units,
-                "noise_generator": partial(noise, seed=seed),
+                "noise_generator": partial(noise, rng=rng, **noise_kwargs),
             },
             forward=forward_external,
             initializer=partial(
diff --git a/reservoirpy/nodes/reservoirs/reservoir.py b/reservoirpy/nodes/reservoirs/reservoir.py
index d5db945..6c5e0ca 100644
--- a/reservoirpy/nodes/reservoirs/reservoir.py
+++ b/reservoirpy/nodes/reservoirs/reservoir.py
@@ -9,13 +9,13 @@ else:
     from typing import Literal
 
 from functools import partial
-from typing import Callable, Optional, Sequence, Union
+from typing import Callable, Optional, Sequence, Union, Dict
 
 from ...activationsfunc import get_function, identity, tanh
 from ...mat_gen import bernoulli, normal
 from ...node import Node
 from ...type import Weights
-from ...utils.random import noise
+from ...utils.random import noise, rand_generator
 from ...utils.validation import is_array
 from .base import forward_external, forward_internal, initialize, initialize_feedback
 
@@ -110,6 +110,9 @@ class Reservoir(Node):
     noise_type : str, default to "normal"
         Distribution of noise. Must be a Numpy random variable generator
         distribution (see :py:class:`numpy.random.Generator`).
+    noise_kwargs : dict, optional
+        Keyword arguments to pass to the noise generator, such as `low` and `high`
+        values of uniform distribution.
     input_scaling : float or array-like of shape (features,), default to 1.0.
         Input gain. An array of the same dimension as the inputs can be used to
         set up different input scaling for each feature.
@@ -221,6 +224,7 @@ class Reservoir(Node):
         noise_in: float = 0.0,
         noise_fb: float = 0.0,
         noise_type: str = "normal",
+        noise_kwargs: Dict = None,
         input_scaling: Union[float, Sequence] = 1.0,
         bias_scaling: float = 1.0,
         fb_scaling: Union[float, Sequence] = 1.0,
@@ -259,6 +263,10 @@ class Reservoir(Node):
         if type(fb_activation) is str:
             fb_activation = get_function(fb_activation)
 
+        rng = rand_generator(seed)
+
+        noise_kwargs = dict() if noise_kwargs is None else noise_kwargs
+
         super(Reservoir, self).__init__(
             fb_initializer=partial(
                 initialize_feedback,
@@ -290,7 +298,7 @@ class Reservoir(Node):
                 "activation": activation,
                 "fb_activation": fb_activation,
                 "units": units,
-                "noise_generator": partial(noise, seed=seed),
+                "noise_generator": partial(noise, rng=rng, **noise_kwargs),
             },
             forward=forward,
             initializer=partial(
diff --git a/reservoirpy/utils/random.py b/reservoirpy/utils/random.py
index 5ab6fc9..067301c 100644
--- a/reservoirpy/utils/random.py
+++ b/reservoirpy/utils/random.py
@@ -11,6 +11,12 @@ __global_rg = default_rng()
 
 
 def set_seed(seed):
+    """Set random state seed globally.
+
+    Parameters
+    ----------
+        seed : int
+    """
     global __SEED
     global __global_rg
     if type(seed) is int:
@@ -39,9 +45,32 @@ def rand_generator(seed: Union[int, Generator, RandomState] = None) -> Generator
         return default_rng(seed)
 
 
-def noise(dist="normal", shape=1, gain=1.0, seed=None, **kwargs):
+def noise(rng, dist="normal", shape=1, gain=1.0, **kwargs):
+    """Generate noise from a given distribution, and apply a gain factor.
+
+    Parameters
+    ----------
+        rng : numpy.random.Generator
+            A random number generator.
+        dist : str, default to 'normal'
+            A random variable distribution.
+        shape : int or tuple of ints, default to 1
+            Shape of the noise vector.
+        gain : float, default to 1.0
+            Gain factor applied to noise.
+        **kwargs
+            Any other parameters of the noise distribution.
+
+    Returns
+    -------
+        np.ndarray
+            A noise vector.
+
+    Note
+    ----
+        If `gain` is 0, then noise vector is null.
+    """
     if abs(gain) > 0.0:
-        rng = rand_generator(seed)
         return gain * getattr(rng, dist)(**kwargs, size=shape)
     else:
         return np.zeros(shape)


Test patch:
diff --git a/reservoirpy/nodes/tests/test_reservoir.py b/reservoirpy/nodes/tests/test_reservoir.py
index 9c4e115..320acd3 100644
--- a/reservoirpy/nodes/tests/test_reservoir.py
+++ b/reservoirpy/nodes/tests/test_reservoir.py
@@ -10,7 +10,6 @@ from reservoirpy.nodes import Reservoir
 
 
 def test_reservoir_init():
-
     node = Reservoir(100, lr=0.8, input_bias=False)
 
     data = np.ones((1, 10))
@@ -38,7 +37,6 @@ def test_reservoir_init():
 
 
 def test_reservoir_init_from_matrices():
-
     Win = np.ones((100, 10))
 
     node = Reservoir(100, lr=0.8, Win=Win, input_bias=False)
@@ -108,7 +106,6 @@ def test_reservoir_init_from_matrices():
 
 
 def test_reservoir_bias():
-
     node = Reservoir(100, lr=0.8, input_bias=False)
 
     data = np.ones((1, 10))
@@ -169,7 +166,6 @@ def test_reservoir_run():
 
 
 def test_reservoir_chain():
-
     node1 = Reservoir(100, lr=0.8, input_bias=False)
     node2 = Reservoir(50, lr=1.0, input_bias=False)
 
@@ -185,7 +181,6 @@ def test_reservoir_chain():
 
 
 def test_reservoir_feedback():
-
     node1 = Reservoir(100, lr=0.8, input_bias=False)
     node2 = Reservoir(50, lr=1.0, input_bias=False)
 
@@ -226,3 +221,33 @@ def test_reservoir_feedback():
         node1 <<= node2
         data = np.ones((1, 10))
         res = (node1 >> node2)(data)
+
+
+def test_reservoir_noise():
+    node1 = Reservoir(100, seed=123, noise_rc=0.1, noise_in=0.5)
+    node2 = Reservoir(100, seed=123, noise_rc=0.1, noise_in=0.5)
+
+    data = np.ones((10, 10))
+
+    assert_array_equal(node1.run(data), node2.run(data))
+
+    node1 = Reservoir(
+        100,
+        seed=123,
+        noise_rc=0.1,
+        noise_in=0.5,
+        noise_type="uniform",
+        noise_kwargs={"low": -1, "high": 0.5},
+    )
+    node2 = Reservoir(
+        100,
+        seed=123,
+        noise_rc=0.1,
+        noise_in=0.5,
+        noise_type="uniform",
+        noise_kwargs={"low": -1, "high": 0.5},
+    )
+
+    data = np.ones((10, 10))
+
+    assert_array_equal(node1.run(data), node2.run(data))
diff --git a/reservoirpy/utils/tests/test_random.py b/reservoirpy/utils/tests/test_random.py
index b08024b..5d33f10 100644
--- a/reservoirpy/utils/tests/test_random.py
+++ b/reservoirpy/utils/tests/test_random.py
@@ -35,10 +35,20 @@ def test_random_generator_from_seed():
 
 
 def test_noise():
-    a = noise(gain=0.0)
+
+    rng = np.random.default_rng(123)
+
+    a = noise(rng, gain=0.0)
     assert_equal(a, np.zeros((1,)))
 
-    a = noise(dist="uniform", gain=2.0, seed=123)
+    rng = np.random.default_rng(123)
+
+    a = noise(rng, dist="uniform", gain=2.0)
     b = 2.0 * np.random.default_rng(123).uniform()
 
     assert_equal(a, b)
+
+    a = noise(rng, dist="uniform", gain=2.0)
+    b = noise(rng, dist="uniform", gain=2.0)
+
+    assert np.any(np.abs(a - b) > 1e-8)