Skip to content

Short-Term Memory with a Quantum Reservoir

The short-term memory capacity task is the standard test of a reservoir. You feed it a random scalar signal, and you ask the readout to rebuild the signal delayed by τ steps. A reservoir with memory rebuilds recent values well, and it gets slowly worse as τ grows. A model without memory can only reproduce the present (τ = 0).

This example compares the three state policies, "stateless", "carry" (the default) and "leaky", on that task. It shows why a memory that fades over time, and not just any state carried from one step to the next, is what makes a quantum reservoir work. See the user guide for the application programming interface (API).

The task

A scalar signal s_t ~ Uniform(-1, 1) drives every qubit of the reservoir (we repeat it across the input slots). The target is the same signal delayed by τ steps: y_t = s_{t-τ}.

import numpy as np
from eigenkit import Encoding, Variational
from eigenkit import QuantumReservoirRegressor

N_QUBITS, MAX_LAG = 5, 5


def delay_data(seed, B, T, tau):
    rng = np.random.default_rng(seed)
    signal = rng.uniform(-1.0, 1.0, size=(B, T, 1))
    inputs = np.repeat(signal, N_QUBITS, axis=2)  # drive every qubit with the signal
    target = np.zeros((B, T))
    target[:, tau:] = signal[:, : T - tau, 0] if tau else signal[:, :, 0]
    return inputs, target

The reservoir

The reservoir is a circuit of 5 qubits. It encodes the signal as rotation angles, and then applies a fixed, random, entangling variational block. The weights are sampled once (with a seed) and never trained. We read out the full measurement distribution (features="probabilities").

blocks = [
    Encoding(in_features=N_QUBITS, rotation="RY"),
    Variational(depth=4, rotation="RY"),
]


def memory_curve(**kw):
    r2 = []
    for tau in range(MAX_LAG + 1):
        Xtr, ytr = delay_data(0, 60, 80, tau)
        Xte, yte = delay_data(1, 30, 80, tau)
        model = QuantumReservoirRegressor(
            blocks, features="probabilities", washout=10, random_state=2, **kw
        ).fit(Xtr, ytr)
        r2.append(max(model.score(Xte, yte), 0.0))
    return r2

Compare the policies

configs = {
    "memoryless (stateless)": dict(state_policy="stateless", window=1),
    "carry (unitary)": dict(state_policy="carry"),
    "leaky (alpha=0.5)": dict(state_policy="leaky", leak=0.5),
}
print(
    "lag tau:           " + "  ".join(f"{t:>4d}" for t in range(MAX_LAG + 1)) + "    MC"
)
for name, kw in configs.items():
    curve = memory_curve(**kw)
    print(
        f"{name:22s} " + "  ".join(f"{v:4.2f}" for v in curve) + f"   {sum(curve):4.2f}"
    )
lag tau:              0     1     2     3     4     5    MC
memoryless (stateless) 1.00  0.00  0.00  0.00  0.00  0.00   1.00
carry (unitary)        0.00  0.00  0.00  0.00  0.00  0.00   0.00
leaky (alpha=0.5)      0.96  0.91  0.74  0.53  0.31  0.18   3.63

The last column is the memory capacity (MC): the sum of R² (the coefficient of determination) over the lags. This single number sums up how much of the history of the input the reservoir keeps.

What the numbers say

  • Memoryless rebuilds the present perfectly (R² = 1.00 at τ = 0), and nothing else. With only the current input, it has no information about the past, as expected. MC = 1.0.
  • Pure unitary carry scores about 0 everywhere, even at τ = 0. The carry is correct (the state really is ψ_t = U(x_t)ψ_{t-1}), but without dissipation, the recurrence mixes the whole history into the state in a way that a linear readout cannot separate. There is no clean fading memory to read. MC ≈ 0.
  • Leaky carry shows the standard curve of short-term memory: it recalls the recent steps almost perfectly, and the recall fades smoothly with the lag (0.96 → 0.91 → 0.74 → …). The leak of weight (1 - α) toward the reset state lets old inputs decay, so the recent ones can still be recovered linearly. MC = 3.63, which is more than three times the baseline without memory.

Forecasting the next step

Memory is only useful if it helps to predict. Here, a fast quasiperiodic signal drives the reservoir, and the readout forecasts the next value, y_t = s_{t+1}. A fast oscillation moves a lot between steps, so the current sample alone cannot tell where the signal goes next. The forecast needs the context of the previous steps.

There are two ways to give the reservoir that context. The classical one is a sliding window, which embeds the last window values as a time delay (state_policy="stateless", with one qubit for each input in the window). The quantum one is a memory in the state that fades over time (state_policy="leaky", which drives all 5 qubits with the scalar and carries the state). We compare both on the prediction of one step ahead.

def signal(seed, B, T):
    rng = np.random.default_rng(seed)
    t = np.arange(T + 1)
    phase = rng.uniform(0, 2 * np.pi, size=(B, 1))
    return (np.sin(0.9 * t + phase) + 0.5 * np.sin(1.4 * t + 2 * phase)) / 1.5


T = 120
s_tr, s_te = signal(0, 60, T), signal(1, 30, T)
ytr, yte = s_tr[:, 1:], s_te[:, 1:]  # predict s_{t+1}
raw_tr, raw_te = s_tr[:, :T, None], s_te[:, :T, None]

# Classical memory: a sliding window of the last W values (W qubits).
for W in (1, 3, 5):
    bw = [Encoding(in_features=W, rotation="RY"), Variational(depth=4, rotation="RY")]
    model = QuantumReservoirRegressor(
        bw,
        state_policy="stateless",
        window=W,
        features="probabilities",
        washout=20,
        random_state=2,
    ).fit(raw_tr, ytr)
    print(f"stateless window={W} ({W}q)   R^2 = {model.score(raw_te, yte):.2f}")

# Quantum fading memory: carry state, no explicit window.
tiled_tr, tiled_te = np.repeat(raw_tr, N_QUBITS, 2), np.repeat(raw_te, N_QUBITS, 2)
leaky = QuantumReservoirRegressor(
    blocks,
    state_policy="leaky",
    leak=0.5,
    features="probabilities",
    washout=20,
    random_state=2,
).fit(tiled_tr, ytr)
print(f"leaky carry (5q)          R^2 = {leaky.score(tiled_te, yte):.2f}")
stateless window=1 (1q)   R^2 = 0.01
stateless window=3 (3q)   R^2 = 0.87
stateless window=5 (5q)   R^2 = 0.98
leaky carry (5q)          R^2 = 0.91

With no window (window=1), the readout sees only the current value, and it cannot forecast a fast oscillation at all. A short window recovers the local dynamics: three past values already reach R² = 0.87, and five reach 0.98. The leaky reservoir gets close too (0.91), by carrying a fading state instead of an explicit window.

Tuning notes

  • leak balances the length of the memory against its clarity. A smaller leak (more decay) gives a clearer recall of the recent steps, but a shorter memory. A larger leak remembers longer, but with more noise.
  • features controls how much information the readout gets. "probabilities" (used here) is quadratic in the amplitudes, and it captures more than "z", which gives one value per qubit. For structure specific to a task, pass a Hamiltonian string or a list of observables.
  • The size of the reservoir and weight_scale set how rich the dynamics are. More qubits and larger weights generally raise the memory capacity, up to a point.