55
66from __future__ import annotations
77
8- from typing import Any , ClassVar , cast
8+ from typing import Any , ClassVar
99
1010import torch
1111from torch import Tensor
1717from sdm .models ._huggingface import download_checkpoint
1818from sdm .models .kumo .timeseries .forecast .ckpt import remap_ckpt
1919from sdm .models .kumo .timeseries .forecast .cross import CrossChannelAttention
20+ from sdm .models .kumo .timeseries .forecast .darr import embed , knn
2021from sdm .models .kumo .timeseries .forecast .icl import ICLBlock
2122from sdm .models .kumo .timeseries .forecast .recipe import default_recipe
2223from sdm .nn import RevIN , SinusoidalEmbedding
23- from sdm .tensor .table import TableSchema
2424
2525
2626class 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