Forecast targets and feedback connections¶
A forecast starts with the first unseen sample after your history. Two settings describe how a custom model gets there:
- Prediction lead tells a head what to learn. Lead 2 pairs an input at time
twith the observation att + 2. - Feedback delay tells a connection when to reuse an output. Delay 2 feeds an output back into its input two model steps after that output was produced.
Both default to 1. For normal self-feedback, use the same lead and delay. Different values let you build deliberately lagged connections between parts of a model.
Start with the default¶
import torch
import resdag as rd
x = rd.reservoir_input(1, dtype=torch.float64)
y = rd.RidgeReadoutLayer(1, 1, alpha=0.0).double()(x)
model = rd.Model(
inputs={"state": x},
outputs={"next_state": y},
washout=0,
)
observed = torch.arange(8, dtype=torch.float64)[:, None]
model.fit(observed)
prediction = model.forecast(horizon=4) # approximately 8, 9, 10, 11
fit(observed) prepares the shifted input/target pairs for you. The default
feedback connection runs from the first output to the first input. Its delay
automatically matches that output's prediction lead.
Predict two steps ahead¶
x = rd.reservoir_input(1, dtype=torch.float64)
y = rd.RidgeReadoutLayer(1, 1, alpha=0.0).double()(x)
model = rd.Model(
inputs={"state": x},
outputs={"next_state": y},
prediction_lead=2,
feedback=[rd.Feedback(output="next_state", input="state", delay=2)],
washout=0,
)
observed = torch.arange(5, dtype=torch.float64)[:, None]
model.fit(observed)
prediction = model.forecast(horizon=4) # approximately 5, 6, 7, 8
The last two warmup outputs already estimate samples 5 and 6. Forecasting keeps
both pending predictions, then generates 7 and 8. It does not skip sample 5.
You can omit feedback here: the automatic connection also uses delay 2.
For a head trained to add 1, choosing lead 1 and delay 2 instead produces
5, 5, 6, 6 after the history 0, 1, 2, 3, 4. That repeated pattern is the
declared lag, rather than an indexing correction.
Route by public names¶
Each Feedback uses the names from the model's inputs and outputs mappings.
Internal layer names and mapping insertion order do not select connections.
connections = [
rd.Feedback(output="slow_prediction", input="slow_state", delay=2),
rd.Feedback(output="fast_prediction", input="fast_state", delay=1),
]
leads = {"slow_prediction": 2, "fast_prediction": 1}
Pass these as feedback=connections, prediction_lead=leads when constructing
your graph. A lead mapping can override selected heads; other heads use lead 1.
An output can feed several inputs, but each input accepts at most one feedback
connection. Connected widths must match. Add an explicit graph projection when
you need a width conversion.
Inputs without a connection are external drivers. Supply them by name in
forecast(..., future_inputs={"forcing": future_forcing}). Their first sample
belongs immediately after the observed history. For ordinary next-step heads,
a horizon of 100 needs 99 driver samples: warmup already provides the first
prediction. More generally, the count is
max(0, horizon - min(model.prediction_leads.values())).
Use feedback=[] when every input is externally driven. History must contain
at least the largest lead and largest connection delay so every pending value
can be initialized. Names, widths, history and driver lengths are checked before
forecasting changes reservoir state or runs model hooks.
Scaling and explicit targets¶
Feedback travels through original units: the source head's inverse transform is followed by the destination input's transform. Independently fitted target statistics therefore work without manual packing or scaling. All tensor operations stay on the model device; only an explicitly selected trajectory result converts to NumPy.
With fit(inputs, targets=...), target arrays are already aligned with their
input rows. prediction_lead records the physical time that each target row
represents; it does not shift explicit arrays a second time. For example, a
lead-2 target supplied at input row t must already contain the desired value
at physical time t + 2.
All returned heads share the same future physical timeline. With
return_warmup=True, the shared historical prefix starts at the largest lead:
for history length W and largest lead L, the result has W - L + horizon
samples per head. Trajectory timestamps follow this same alignment.
Custom temporal modules¶
Fast forecasting uses one input frame per step. Built-in pointwise transforms
and explicit reservoir layers support this. A custom module can declare
supports_framewise = True when its evaluation is independent across time,
preserves batch/time dimensions, and has no hidden temporal state.
For a pure causal sequence transform such as a prefix sum, choose
forecast(..., execution="history"). This recomputes the entire growing
observed/generated input prefix from the configured reservoir reset on each
step. It retains every prefix sample and costs more time and memory. Such
graphs also fit their terminal heads from complete-prefix features before
discarding washout rows, and retain their complete fitting history for cached
forecasting. Ordinary fast models retain only their compact synchronization
tail. History execution currently requires compile=False.
Custom temporal modules must be pure across evaluation calls. Expose mutable
streaming state through a BaseReservoirLayer adapter; replay cannot discover
or reset arbitrary buffers hidden in a custom module.
State and checkpoints¶
Each forecast call starts a fresh rollout from its history, rebuilding delayed
queues. get_state() and set_state() describe reservoir state for ordinary
forward calls; those tensors do not include pending feedback or its clock and
cannot resume a delayed forecast by themselves.
state_dict() includes prediction leads, connection names/delays, fitted
scalers and cached history. Loading restores those settings after validating
the graph's named ports. Complete model artifacts are covered by the
model workflow guide.