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.00atτ = 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
0everywhere, 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
leakbalances the length of the memory against its clarity. A smallerleak(more decay) gives a clearer recall of the recent steps, but a shorter memory. A largerleakremembers longer, but with more noise.featurescontrols 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_scaleset how rich the dynamics are. More qubits and larger weights generally raise the memory capacity, up to a point.