Skip to content

Custom layers in forecasting

Ordinary graph execution accepts standard torch.nn.Module layers, including whole-sequence transformations. Forecasting must also decide how those layers receive future inputs.

The default execution="fast" processes the observed history once, then consumes one frame per future tick. Built-in feature transforms, pointwise PyTorch layers, readouts, and visible BaseReservoirLayer nodes support this execution. Explicit reservoir states carry their temporal memory between ticks.

Declare a framewise custom module

Set supports_framewise = True when splitting along the time axis preserves the module's behavior. For inputs shaped (batch, time, features), the module must preserve the batch and time axes and apply the same operation independently at each time. Feature widths may change inside the graph. The declaration also applies to a custom coupled aggregator's time axis, immediately before its final feature axis.

import torch
from torch import nn
import resdag as rd

class SignedSquare(nn.Module):
    supports_framewise = True

    def forward(self, values):
        return values.sign() * values.square()

source = rd.reservoir_input(1, dtype=torch.float64)
graph = rd.ESNModel(source, SignedSquare()(source))
observed = torch.tensor([[[0.5], [1.0], [2.0]]], dtype=torch.float64)
prediction = graph.forecast(observed, horizon=2)
torch.testing.assert_close(prediction, torch.tensor([[[4.0], [16.0]]]).double())

An unchanged inherited implementation retains its declaration. A subclass that overrides forward must declare the capability again. An existing module instance can instead set its own supports_framewise = True after its behavior has been checked. A declaration is a semantic promise; ResDAG does not infer a custom function's temporal behavior by trying sample inputs.

Known scalar symbolic arithmetic such as source + 1, source ** 2, and feature-only slices remains fast. Generic lambdas, arbitrary tensor methods, and unknown module classes require a declaration or explicit history execution. ResDAG validates the graph before warmup, hooks, or recurrent-state changes.

Replay the complete observed prefix

A cumulative sum depends on every earlier input. Giving it isolated future frames would discard that information. Wrap the graph with rd.Model and select execution="history" to rerun its complete input prefix at each future tick.

import torch
from torch import nn
import resdag as rd

class PrefixSum(nn.Module):
    def forward(self, values):
        return values.cumsum(dim=1)

source = rd.reservoir_input(1, dtype=torch.float64)
features = PrefixSum()(source)
head = rd.RidgeReadoutLayer(1, 1, alpha=1e-6).double()
model = rd.Model(
    inputs={"observed": source},
    outputs={"prediction": head(features)},
    preprocessing="off",
    washout=0,
)
observed = torch.arange(1, 21, dtype=torch.float64)[:, None]
model.fit(observed)
prediction = model.forecast(history=observed, horizon=3, execution="history")
assert prediction.shape == (3, 1)
assert torch.isfinite(prediction).all()

Each replay starts visible reservoirs from their configured reset state and recomputes the full supplied history plus every subsequently consumed forecast frame. It keeps the final prediction unconsumed, matching normal forecast timing. History execution costs more computation and memory as the prefix grows, and requires compile=False.

Graphs that need history execution retain their complete observed fitting history, so omitted history replays that prefix. Fast graphs retain a compact observed tail. Supplying history explicitly starts from that observed prefix; include every earlier sample the temporal operation needs. Within a forecast call, replay never shortens the supplied prefix.

Both execution choices expect custom modules to behave as pure input functions in evaluation mode. Temporal operations such as cumulative sums can compute their memory from the supplied prefix. Arbitrary mutable Python state or per-call temporal buffers cannot be reset automatically. Implement streaming memory through a ReservoirCell wrapped in BaseReservoirLayer, exposing that reservoir as its own functional graph node. Reservoirs hidden inside arbitrary containers remain outside the explicit-state contract.

The low-level ESNModel.forecast provides fast execution. Use the common rd.Model(graph=...) workflow for explicit history replay. Direct graph forward and whole-sequence forward_stateless remain available for custom operations without a framewise declaration.