Skip to content

Commit 9d6c7bb

Browse files
agautam478rusty1s
andauthored
Add DARR retrieval-augmented Kumo forecasting (#1269)
# Summary > Add a composable DARR wrapper around KumoForecast, reusing its pretrained encoder and forecasting head. Direct KumoForecast behavior remains unchanged. # Description of the change > Fit historical window/continuation memory explicitly, retrieve cosine top-k neighbors, and blend their temperature-weighted continuations with the direct forecast. Preserve Kumo-TS defaults, frozen normalization, missing-continuation weighting, and autoregressive horizons up to 512 steps. > > Accept numerical Tensor/TableTensor inputs, align named columns, batch memory construction and queries, and keep fitted memory movable but out of state_dict. Document the caller’s responsibility to exclude future observations; include API documentation and a runnable M4 example. > > Ported from Kumo-TS at af74798bbf7d020d3298f545fb9be383560a18ce. No new dependencies, checkpoints, or license files. # Validation > H100 real-checkpoint comparisons against the pinned upstream implementation passed in float32 for 24-, 72-, 120-, and 512-step horizons; maximum hybrid-forecast absolute difference was below 8e-6. The runnable example passed. > > An eight-series M4 hourly holdout smoke test excluded the final 72 observations from memory and query history. Default DARR MAE was 4.38 versus 1.43 for direct forecasting; this validates functionality, not an accuracy improvement or an official benchmark. --------- Signed-off-by: Aditi Gautam <adgautam@nvidia.com> Co-authored-by: rusty1s <matthias.fey@tu-dortmund.de>
1 parent 89e015e commit 9d6c7bb

3 files changed

Lines changed: 199 additions & 92 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# ruff: noqa: D103
5+
6+
import torch
7+
import torch.nn.functional as F
8+
from torch import Tensor
9+
10+
11+
def embed(encoded: Tensor) -> Tensor:
12+
pooled = encoded.float().mean(dim=-3).mean(dim=-2)
13+
pooled = pooled.nan_to_num(nan=0, posinf=0, neginf=0)
14+
return F.normalize(pooled, dim=-1, eps=1e-12)
15+
16+
17+
def knn(
18+
query: Tensor, # [..., D]
19+
key: Tensor, # [..., N, D]
20+
value: Tensor, # [..., N, C, H]
21+
k: int,
22+
temperature: float | None,
23+
) -> Tensor: # [..., C, H]
24+
with torch.amp.autocast(query.device.type, enabled=False):
25+
sim = query.float().unsqueeze(-2) @ key.float().transpose(-1, -2)
26+
sim, index = sim.squeeze(-2).topk(k=min(k, key.size(-2)), dim=-1)
27+
28+
if temperature is None or temperature < 0:
29+
weight = torch.ones_like(sim) / sim.size(-1)
30+
else:
31+
weight = (sim / max(temperature, 1e-12)).softmax(dim=-1)
32+
33+
neighbor = value.gather(
34+
dim=-3,
35+
index=index[..., None, None].expand(*index.size(), *value.size()[-2:]),
36+
).float() # [..., K, C, H]
37+
38+
valid = neighbor.isfinite()
39+
weight = weight[..., None, None] * valid
40+
total = (weight * neighbor.masked_fill(~valid, 0)).sum(dim=-3)
41+
return total / weight.sum(dim=-3).clamp_min(1e-12)

‎sdm/models/kumo/timeseries/forecast/model.py‎

Lines changed: 150 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from typing import Any, ClassVar, cast
8+
from typing import Any, ClassVar
99

1010
import torch
1111
from torch import Tensor
@@ -17,10 +17,10 @@
1717
from sdm.models._huggingface import download_checkpoint
1818
from sdm.models.kumo.timeseries.forecast.ckpt import remap_ckpt
1919
from sdm.models.kumo.timeseries.forecast.cross import CrossChannelAttention
20+
from sdm.models.kumo.timeseries.forecast.darr import embed, knn
2021
from sdm.models.kumo.timeseries.forecast.icl import ICLBlock
2122
from sdm.models.kumo.timeseries.forecast.recipe import default_recipe
2223
from sdm.nn import RevIN, SinusoidalEmbedding
23-
from sdm.tensor.table import TableSchema
2424

2525

2626
class KumoForecast(ICLModel):
@@ -40,12 +40,20 @@ class KumoForecast(ICLModel):
4040
Optional cross-channel mixing lets forecasts use dependencies between
4141
variates after temporal encoding. The forecasting head predicts the native
4242
up to 72-step horizon directly from a 512-step history.
43+
Forecast horizons beyond 72 steps are extended autoregressively, up to
44+
512 steps.
45+
46+
For longer observation histories, Domain-Aware Representation and
47+
Retrieval (DARR) can augment the direct forecast with patterns retrieved
48+
from historical data. Set ``alpha < 1`` to retrieve similar historical
49+
windows using nearest neighbors and blend their observed continuations
50+
with the direct forecast.
4351
4452
Within the :class:`~sdm.models.ICLModel` protocol, :class:`KumoForecast`
4553
treats rows as ordered time steps. In a default forward pass,
4654
``x_context`` contains past-only historical covariates, ``y_context``
47-
contains one or more past target series, and ``x_query`` is required to be
48-
empty.
55+
contains one or more past target series, and ``x_query`` has no columns.
56+
The number of query rows determines the forecast horizon.
4957
5058
Args:
5159
cross_channel: Whether to mix variates after encoding.
@@ -105,15 +113,25 @@ def _load_from_pretrained(
105113

106114
return self
107115

108-
def forward(
116+
def forward( # noqa: D417
109117
self,
110118
x_context: Tensor | TableTensor,
111119
y_context: Tensor | TableTensor,
112120
x_query: Tensor | TableTensor,
113121
*args: Any,
122+
alpha: float = 1.0,
123+
k: int = 64,
124+
temperature: float | None = 0.05,
114125
**kwargs: Any,
115126
) -> TableTensor:
116-
r""":meta private:""" # noqa: D415
127+
r"""The in-context learning forward pass.
128+
129+
Args:
130+
alpha: Blend weight between direct and retrieved forecasts.
131+
``1`` uses only the direct forecast, ``0`` uses only retrieval.
132+
k: Maximum number of historical neighbors to retrieve.
133+
temperature: Softmax temperature for weighting retrieved neighbors.
134+
"""
117135
if not isinstance(x_context, TableTensor):
118136
x_context = TableTensor.from_tensor(x_context)
119137
if not isinstance(x_query, TableTensor):
@@ -128,37 +146,14 @@ def forward(
128146
)
129147
x_query = x_query[..., :0]
130148

149+
kwargs.update(alpha=alpha, k=k, temperature=temperature)
131150
return super().forward(x_context, y_context, x_query, *args, **kwargs)
132151

133-
def fit(self, x: Tensor | TableTensor, *args: Any, **kwargs: Any) -> None:
152+
def fit(self, *args: Any, **kwargs: Any) -> None:
134153
r""":meta private:""" # noqa: D415
135-
if isinstance(x, TableTensor):
136-
kwargs["context_only_columns"] = [
137-
column for columns in x.columns.values() for column in columns
138-
]
139-
else:
140-
kwargs["context_only_columns"] = range(x.size(-1))
141-
142-
return super().fit(x, *args, **kwargs)
143-
144-
def predict(
145-
self,
146-
x: Tensor | TableTensor,
147-
*args: Any,
148-
**kwargs: Any,
149-
) -> TableTensor:
150-
r""":meta private:""" # noqa: D415
151-
if x.size(-1) > 0:
152-
warn_once(
153-
key="kumo-forecast-future-covariate",
154-
message=(
155-
f"{self.__class__.__name__!r} does not support future "
156-
f"covariates. Query columns will be ignored."
157-
),
158-
)
159-
x = x[..., :0]
160-
161-
return super().predict(x, *args, **kwargs)
154+
raise NotImplementedError(
155+
f"{self.__class__.__name__!r} does not support `fit()`"
156+
)
162157

163158
def _forward(
164159
self,
@@ -170,56 +165,101 @@ def _forward(
170165
related_query_tables: RelatedTables[TableTensor] | None,
171166
cache: Cache | None,
172167
generator: torch.Generator | None,
168+
alpha: float = 1.0,
169+
k: int = 64,
170+
temperature: float | None = 0.05,
171+
batch_size: int = 8,
173172
**kwargs: Any,
174173
) -> TableTensor:
174+
assert y_context is not None
175+
assert x_query is not None
175176

176-
if (
177-
y_context is not None
178-
and y_context.size(-2) < self.model.context_size
179-
):
177+
if not 0 <= alpha <= 1:
178+
raise ValueError(
179+
f"Expected 'alpha' between zero and one (got {alpha})"
180+
)
181+
182+
if y_context.size(-2) < self.model.context_size:
180183
raise ValueError(
181184
f"Expected at least {self.model.context_size} context rows "
182185
f"(got {y_context.size(-2)})"
183186
)
184187

185-
if (
186-
x_query is not None
187-
and not 1 <= x_query.size(-2) <= self.model.prediction_size
188-
):
188+
horizon = x_query.size(-2)
189+
if not 1 <= horizon <= 512:
189190
raise ValueError(
190-
"Number of query rows must be between 1 and "
191-
f"{self.model.prediction_size} steps "
192-
f"(got {x_query.size(-2)})"
191+
"Number of query rows must be between 1 and 512 steps "
192+
f"(got {horizon})"
193+
)
194+
195+
context_size = self.model.context_size
196+
prediction_size = self.model.prediction_size
197+
memory_horizon = max(prediction_size, horizon)
198+
use_darr = alpha < 1 and y_context.size(-2) >= (
199+
self.model.context_size + memory_horizon
200+
)
201+
202+
x = y_context.numerical.transpose(-1, -2) # [..., C, R]
203+
if x_context_only is not None and x_context_only.size(-1) > 0:
204+
if self.model.cross_channel is None and not use_darr:
205+
warn_once(
206+
key="kumo-forecast-historical-covariate",
207+
message=(
208+
"Historical covariates have no effect when "
209+
"'cross_channel' and DARR retrieval are inactive."
210+
),
211+
)
212+
x = torch.cat(
213+
[x, x_context_only.numerical.transpose(-1, -2)],
214+
dim=-2,
215+
)
216+
217+
# Forecast:
218+
last_x = x[..., -self.model.context_size :]
219+
encoded, mean, scale = self.model.encode(last_x) # [..., C, P, D]
220+
out = self.model.decode(encoded, mean, scale)
221+
222+
# Autoregressive Forecasts:
223+
if horizon > prediction_size and (alpha != 0 or not use_darr):
224+
outs = [out]
225+
for _ in range(prediction_size, horizon, prediction_size):
226+
last_x = last_x[..., prediction_size:]
227+
last_x = torch.cat([last_x, out], dim=-1)
228+
out = self.model(last_x)
229+
outs.append(out)
230+
out = torch.cat(outs, dim=-1)
231+
out = out[..., :horizon]
232+
233+
if use_darr: # Memory-based Retrieval:
234+
key = self.model.encode_memory(
235+
x=x,
236+
horizon=memory_horizon,
237+
batch_size=batch_size,
238+
) # [..., N, D]
239+
query = embed(encoded) # [..., D]
240+
241+
window = x.unfold(
242+
dimension=-1,
243+
size=context_size + memory_horizon,
244+
step=prediction_size,
245+
).transpose(-3, -2) # [..., N, C, context_size+memory_horizon]
246+
247+
retrieved = knn(
248+
query=query,
249+
key=key,
250+
value=window[..., context_size : context_size + horizon],
251+
k=k,
252+
temperature=temperature,
193253
)
254+
if alpha == 0:
255+
out = retrieved
256+
else:
257+
out = alpha * out + (1 - alpha) * retrieved
194258

195-
if cache is None or cache.is_recording:
196-
assert y_context is not None
197-
y_schema = y_context.schema
198-
x = y_context.numerical
199-
if x_context_only is not None and x_context_only.size(-1) > 0:
200-
if self.model.cross_channel is None:
201-
warn_once(
202-
key="kumo-forecast-historical-covariate",
203-
message=(
204-
"Historical covariates have no effect when "
205-
"'cross_channel' is disabled."
206-
),
207-
)
208-
x = torch.cat([x, x_context_only.numerical], dim=-1)
209-
210-
x = x[..., -self.model.context_size :, :].transpose(-1, -2)
211-
out = self.model(x)[..., : y_context.size(-1), :].transpose(-1, -2)
212-
213-
if cache is not None:
214-
cache["out"] = out
215-
else:
216-
y_schema = cast(TableSchema, cache["y_schema"])
217-
out = cast(Tensor, cache["out"])
218-
219-
length = 0 if x_query is None else x_query.size(-2)
259+
out = out[..., : len(y_context.numerical_columns), :].transpose(-1, -2)
220260
return TableTensor(
221-
columns={Stype.numerical: y_schema.columns[Stype.numerical]},
222-
numerical=out[..., :length, :],
261+
columns={Stype.numerical: y_context.numerical_columns},
262+
numerical=out,
223263
)
224264

225265

@@ -282,7 +322,44 @@ def forward(
282322
self,
283323
x: Tensor, # [..., C, R]
284324
) -> Tensor: # [..., C, prediction_size]
325+
encoded, mean, scale = self.encode(x)
326+
return self.decode(encoded, mean, scale)
285327

328+
def decode(self, encoded: Tensor, mean: Tensor, scale: Tensor) -> Tensor:
329+
return self.revin.inverse(self.head(encoded.flatten(-2)), mean, scale)
330+
331+
def encode_memory(
332+
self,
333+
x: Tensor, # [..., C, R]
334+
horizon: int,
335+
batch_size: int,
336+
) -> Tensor: # [..., N, D]
337+
window = x.unfold(
338+
dimension=-1,
339+
size=self.context_size + horizon,
340+
step=self.prediction_size,
341+
).transpose(-3, -2) # [..., N, C, L + H]
342+
343+
key = x.new_empty(
344+
(*x.size()[:-2], window.size(-3), self.lin.out_features),
345+
dtype=torch.float32,
346+
)
347+
for start in range(0, window.size(-3), batch_size):
348+
x = window[
349+
...,
350+
start : start + batch_size,
351+
:,
352+
: self.context_size,
353+
]
354+
x, _, _ = self.encode(x)
355+
key[..., start : start + batch_size, :] = embed(x)
356+
357+
return key
358+
359+
def encode(
360+
self,
361+
x: Tensor, # [..., C, R]
362+
) -> tuple[Tensor, Tensor, Tensor]:
286363
mean, scale = self.revin.compute_stats(x)
287364
x = self.revin(x, mean, scale)
288365
x = x.nan_to_num(nan=0, posinf=0, neginf=0)
@@ -293,5 +370,4 @@ def forward(
293370
if self.cross_channel is not None:
294371
x = self.cross_channel(x) # [..., C, N, D]
295372

296-
x = self.head(x.flatten(-2)) # [..., C, prediction_size]
297-
return self.revin.inverse(x, mean, scale)
373+
return x, mean, scale

0 commit comments

Comments
 (0)