Skip to content

Forecast a TSDynamics trajectory

rd.Model.fit(trajectory) reads a TSDynamics Trajectory directly. The model learns next-sample targets and training-only scaling, and forecast() returns a new trajectory in the original signal units, with component names and future timestamps. Ordinary model(tensor) calls still return differentiable tensors.

Install the optional dynamics extra on Python 3.12 or newer:

python -m pip install "resdag[dynamics]"

Simulate, fit and forecast

Use TSDynamics' public run method to generate a trajectory. Split training and evaluation samples before fitting or resampling.

import numpy as np
import torch
import tsdynamics as ts
import resdag as rd

trajectory = ts.systems.Lorenz().run(final_time=2.0, dt=0.02)
training, truth = trajectory[:81], trajectory[81:91]
model = rd.Model(
    graph=rd.classic_esn(16, 3, 3, seed=7, dtype=torch.float64, readout="svd"),
    washout=10,
)
model.fit(training)
prediction = model.forecast(history=training[-20:], horizon=10)

assert isinstance(prediction, ts.Trajectory)
assert prediction.y.shape == truth.y.shape == (10, 3)
assert prediction.variables == ("x", "y", "z")
assert np.isfinite(prediction.y).all()
np.testing.assert_allclose(prediction.t, truth.t, rtol=0, atol=1e-12)

The trajectory stores times in .t, values in .y and component labels in .variables. The forecast starts one sample after the supplied history. Omitting history uses the training tail retained by fit.

The example checks the interface and sample alignment; its small reservoir does not establish a forecast-accuracy benchmark for Lorenz.

Check the physical-time boundary

A measured signal needs no producing system. Here the observed position rises by one every 0.25 time units. A terminal linear readout learns that increment exactly, making the first unseen value and timestamp explicit.

def ramp_model(**options):
    x = rd.reservoir_input(1, dtype=torch.float64)
    y = rd.RidgeReadoutLayer(1, 1, alpha=0.0).double()(x)
    return rd.Model(inputs={"state": x}, outputs={"next": y}, washout=0, **options)

observed = ts.Trajectory(
    10.0 + 0.25 * np.arange(8),
    np.arange(8.0)[:, None],
    variables=("position",),
)
ramp = ramp_model().fit(observed)
future = ramp.forecast(horizon=4)
np.testing.assert_allclose(future.y, [[8.0], [9.0], [10.0], [11.0]], atol=1e-11)
np.testing.assert_allclose(future.t, [12.0, 12.25, 12.5, 12.75], rtol=0, atol=1e-12)

with_history = ramp.forecast(history=observed, horizon=4, return_warmup=True)
np.testing.assert_allclose(with_history.y, np.arange(1.0, 12.0)[:, None], atol=1e-11)
np.testing.assert_allclose(
    with_history.t, 10.0 + 0.25 * np.arange(1, 12), rtol=0, atol=1e-12
)

With return_warmup=True, each historical input contributes its next-sample prediction. Those dates are the history dates after the first observation, followed by the forecast dates. The boundary prediction appears once.

Scaling statistics use supervised training inputs after washout, excluding the final observation that supplies the shifted target. Validation and test samples never belong in that fit call. Forecasting leaves those statistics and the cached training tail unchanged.

Choose a sampling grid

Valid irregular trajectories are resampled automatically. By default, the new grid preserves the first time, last time and sample count. Its interval is (last_time - first_time) / (n_samples - 1). Existing regular observations pass through. Sampling comes from the actual time axis, even if source metadata contains an older dt.

Set resample_dt to choose an interval. That grid ends at the final observation and stays inside the observed time range, so it can drop a fractional interval at the beginning. Interpolation never extrapolates. resample_method accepts "linear" (default), "pchip" or "cubic". Interpolation does not apply an anti-alias filter when choosing a larger interval; apply any required filtering before supplying the training data.

irregular_times = np.array([2.0, 2.2, 2.9, 4.0])
irregular = ts.Trajectory(
    irregular_times,
    (3 * irregular_times + 10)[:, None],
    variables=("position",),
)
automatic = ramp_model().fit(irregular)
automatic_future = automatic.forecast(horizon=2)
np.testing.assert_allclose(automatic_future.t, [4 + 2 / 3, 4 + 4 / 3], atol=1e-12)
np.testing.assert_allclose(automatic_future.y, [[24.0], [26.0]], atol=1e-10)

fixed = ramp_model(resample_dt=0.75, resample_method="pchip").fit(irregular)
fixed_future = fixed.forecast(horizon=2)
np.testing.assert_allclose(fixed_future.t, [4.75, 5.5], rtol=0, atol=1e-12)
np.testing.assert_allclose(fixed_future.y, [[24.25], [26.5]], atol=1e-10)

The fixed grid above is [2.5, 3.25, 4.0]. A later trajectory supplied as forecast history is resampled to the fitted interval, keeping each recurrent step at the duration used for training. Its component names and order must match the training trajectory. Duplicate, decreasing or nonfinite times are rejected before execution. The time origin and interval must also remain distinguishable at float64 precision. If a large absolute time prevents a uniform grid, subtract a time origin or choose a coarser interval.

Keep tensors on the model device

Choose return_type="tensor" to receive the usual Torch output on the model device. Trajectory output makes a detached CPU/NumPy copy after forecasting; the tensor path makes no such copy. Since NumPy has no bfloat16 dtype, bfloat16 trajectory values are converted to float32 at that output boundary.

tensor_future = ramp.forecast(horizon=4, return_type="tensor")
assert isinstance(tensor_future, torch.Tensor)
torch.testing.assert_close(tensor_future, torch.from_numpy(future.y))

tensor_history = torch.from_numpy(observed.y.copy())
tensor_continuation = ramp.forecast(
    history=tensor_history, horizon=4, return_type="tensor"
)
torch.testing.assert_close(tensor_continuation, tensor_future)

return_type="auto" is the default: trajectory fitting or explicit trajectory history selects trajectory output; a tensor-only workflow selects tensors. Tensor history supplies no timestamps, so requesting trajectory output from it raises a clear error. Supply a trajectory history or request tensor output. A successful tensor refit returns the default to tensors.

Restore a checkpoint

The ordinary state dictionary includes scaling, the observed tail, sampling options, component names, clock information and portable source metadata. Rebuild the same named graph before loading.

import io

checkpoint = io.BytesIO()
torch.save(fixed.state_dict(), checkpoint)
checkpoint.seek(0)
restored = ramp_model()
restored.load_state_dict(torch.load(checkpoint, map_location="cpu", weights_only=True))
restored_future = restored.forecast(horizon=2)
np.testing.assert_array_equal(restored_future.t, fixed_future.t)
np.testing.assert_allclose(restored_future.y, fixed_future.y, rtol=0, atol=0)
assert restored_future.variables == ("position",)

The producing system is a transient reference and is not serialized. A restored forecast retains its source identity in .meta["source"], while .system is None. Model device/dtype conversions move tensors and fitted scaling; physical timestamps retain float64 precision. Source arrays become plain lists in checkpoint metadata. Opaque Python metadata values are represented by type markers instead of serialized objects.

Bare trajectory fitting infers shifted targets for one input/output of equal width. prediction_lead can select a larger distance. Warmup outputs then start at that lead's historical timestamp, while future predictions still begin at the first unseen sample. Named inputs and explicit trajectory targets are supported by the named trajectory workflow, which aligns all ports on a shared observed range and applies per-head leads to physical target timestamps. Tensor targets remain caller-aligned.

Both bare and named trajectory checkpoints use the current version 2 clock envelope. Older common-workflow trajectory metadata is rejected; reconstruct and refit those models to save a current checkpoint. There is no automatic converter. Ordinary tensor forward and explicit-state calls retain the PyTorch behavior described in the common model guide.