Skip to content

Commit fdbba09

Browse files
committed
feat(timesfm3): add conditional detrending
1 parent 9dde7a5 commit fdbba09

9 files changed

Lines changed: 382 additions & 38 deletions

File tree

‎sdm/models/timesfm3/model.py‎

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,57 @@
2626
import torch
2727
from torch import Tensor
2828

29-
from sdm import Recipe, RelatedTables, Stype, StypeLike, TableTensor, Task
29+
from sdm import (
30+
ColumnarTensor,
31+
Recipe,
32+
RelatedTables,
33+
Stype,
34+
StypeLike,
35+
TableTensor,
36+
Task,
37+
)
3038
from sdm.cache import Cache
39+
from sdm.models import ICLModel
3140
from sdm.models._huggingface import download_checkpoint
32-
from sdm.models.base import ICLModel
41+
from sdm.models.callback import Callback
3342
from sdm.models.timesfm3.block import ResidualBlock
3443
from sdm.models.timesfm3.ckpt import remap_ckpt
3544
from sdm.models.timesfm3.icl import ICLBlock
36-
from sdm.models.timesfm3.recipe import default_recipe
45+
from sdm.models.timesfm3.recipe import TIME_COLUMN, default_recipe
3746
from sdm.models.timesfm3.util import (
3847
gather_future_patches,
3948
get_running_stats,
4049
revin,
4150
)
51+
from sdm.processing.execution import MemberContext
4252
from sdm.tensor.table import TableSchema
4353

4454

55+
def _with_time_column(table: TableTensor, start: int) -> TableTensor:
56+
time_steps = torch.arange(
57+
start, start + table.size(-2), device=table.device
58+
).expand(*table.size()[:-2], -1)
59+
return cast(
60+
TableTensor,
61+
torch.cat(
62+
(
63+
table,
64+
TableTensor(
65+
columns={Stype.id: [TIME_COLUMN]},
66+
id=ColumnarTensor((time_steps,)),
67+
),
68+
),
69+
dim=-1,
70+
),
71+
)
72+
73+
74+
def _drop_time_column(table: TableTensor) -> TableTensor:
75+
if TIME_COLUMN in table.columns[Stype.id]:
76+
return table.drop_columns(TIME_COLUMN)
77+
return table
78+
79+
4580
class TimesFM3(ICLModel):
4681
r"""The multivariate forecasting foundation model from `"TimesFM-3: A
4782
Zero-shot Foundation Model for Multivariate Forecasting"
@@ -149,58 +184,84 @@ def _load_from_pretrained(
149184

150185
return self
151186

152-
def forward(self, *args: Any, **kwargs: Any) -> TableTensor:
187+
def forward(
188+
self,
189+
x_context: Tensor | TableTensor,
190+
y_context: Tensor | TableTensor,
191+
x_query: Tensor | TableTensor,
192+
*args: Any,
193+
**kwargs: Any,
194+
) -> TableTensor:
153195
r""":meta private:""" # noqa: D415
154-
x_context = kwargs["x_context"] if "x_context" in kwargs else args[0]
155196
if not isinstance(x_context, TableTensor):
156197
x_context = TableTensor.from_tensor(x_context)
157-
kwargs["_x_context_schema"] = x_context.schema
158-
159-
x_query = kwargs["x_query"] if "x_query" in kwargs else args[2]
198+
if not isinstance(y_context, TableTensor):
199+
y_context = TableTensor.from_tensor(y_context)
160200
if not isinstance(x_query, TableTensor):
161201
x_query = TableTensor.from_tensor(x_query)
162-
kwargs["_x_query_schema"] = x_query.schema
163202

203+
context_length = x_context.size(-2)
164204
x_query = expand_query(x_context.schema, x_query)
165205

166-
if "x_query" in kwargs:
167-
kwargs["x_query"] = x_query
168-
else:
169-
args = (*args[:2], x_query, *args[3:])
170-
171-
return super().forward(*args, **kwargs) # type: ignore
206+
out = super().forward(
207+
_with_time_column(x_context, 0),
208+
_with_time_column(y_context, 0),
209+
_with_time_column(x_query, context_length),
210+
*args,
211+
**kwargs,
212+
)
213+
return _drop_time_column(out)
172214

173-
def fit(self, *args: Any, **kwargs: Any) -> None:
215+
def fit(
216+
self,
217+
x: Tensor | TableTensor,
218+
y: Tensor | TableTensor,
219+
*args: Any,
220+
**kwargs: Any,
221+
) -> None:
174222
r""":meta private:""" # noqa: D415
175-
x = kwargs["x"] if "x" in kwargs else args[0]
176223
if not isinstance(x, TableTensor):
177224
x = TableTensor.from_tensor(x)
178-
kwargs["_x_context_schema"] = x.schema
225+
if not isinstance(y, TableTensor):
226+
y = TableTensor.from_tensor(y)
179227

180-
super().fit(*args, **kwargs)
228+
kwargs["_x_context_schema"] = x.schema
229+
kwargs["_context_length"] = x.size(-2)
230+
super().fit(
231+
_with_time_column(x, 0), _with_time_column(y, 0), *args, **kwargs
232+
)
181233

182-
def predict(self, *args: Any, **kwargs: Any) -> TableTensor:
234+
def predict(
235+
self,
236+
x: Tensor | TableTensor,
237+
*args: Any,
238+
**kwargs: Any,
239+
) -> TableTensor:
183240
r""":meta private:""" # noqa: D415
184241
if self._cache is None:
185242
raise RuntimeError(
186243
f"{self.__class__.__name__!r} not yet fitted. Make sure to "
187244
f"call '{self.__class__.__name__}.fit()' before."
188245
)
189-
190-
x = kwargs["x"] if "x" in kwargs else args[0]
191246
if not isinstance(x, TableTensor):
192247
x = TableTensor.from_tensor(x)
193248

194249
cached_kwargs = cast(dict[str, Any], self._cache["kwargs"])
195-
cached_kwargs["_x_query_schema"] = x.schema
196250
x = expand_query(cached_kwargs["_x_context_schema"], x)
251+
out = super().predict(
252+
_with_time_column(x, cached_kwargs["_context_length"]),
253+
*args,
254+
**kwargs,
255+
)
256+
return _drop_time_column(out)
197257

198-
if "x" in kwargs:
199-
kwargs["x"] = x
200-
else:
201-
args = (x, *args[1:])
202-
203-
return super().predict(*args, **kwargs)
258+
def _prepare_context(
259+
self,
260+
context: MemberContext,
261+
callbacks: Sequence[Callback],
262+
) -> MemberContext:
263+
context = context._replace(y=_drop_time_column(context.y))
264+
return super()._prepare_context(context, callbacks)
204265

205266
def _forward(
206267
self,
@@ -212,7 +273,7 @@ def _forward(
212273
cache: Cache | None,
213274
generator: torch.Generator | None,
214275
**kwargs: Any,
215-
) -> TableTensor: # [..., R_query, Y * 9]
276+
) -> TableTensor: # [..., R_query, T * Q]
216277

217278
if y_context is not None:
218279
columns = y_context.columns[Stype.numerical]
@@ -227,18 +288,26 @@ def _forward(
227288
assert x_context is not None
228289
size = (*x_context.size()[:-2], 0)
229290

230-
return TableTensor(
291+
out = TableTensor(
231292
columns={
232293
Stype.numerical: [
233-
f"{name}__q{i}"
234-
for name, i in product(columns, range(10, 100, 10))
294+
f"{name}__q{int(100 * q)}"
295+
for name, q in product(columns, self.model.quantiles)
235296
]
236297
},
237298
numerical=torch.zeros(
238-
(*size, len(columns) * 9),
299+
(*size, len(columns) * len(self.model.quantiles)),
239300
device=next(self.parameters()).device,
240301
),
241302
)
303+
if x_query is None:
304+
return out
305+
context_length = (
306+
x_context.size(-2)
307+
if x_context is not None
308+
else kwargs["_context_length"]
309+
)
310+
return _with_time_column(out, context_length)
242311

243312

244313
class _TimesFM3(torch.nn.Module):
@@ -310,7 +379,9 @@ def _preprocess(
310379
count, mean, std = get_running_stats(x, mask)
311380

312381
# Gather the prediction patches and hide unavailable values
313-
horizon_x, past_end = gather_future_patches(x, self.num_horizon_patches)
382+
horizon_x, past_end = gather_future_patches(
383+
x, self.num_horizon_patches
384+
)
314385
horizon_mask, _ = gather_future_patches(mask, self.num_horizon_patches)
315386
horizon_mask.logical_or_(context_only[..., None])
316387
horizon_mask.logical_or_(past_end)

‎sdm/models/timesfm3/recipe.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
import sdm.processing as sp
55

6+
TIME_COLUMN = "__timesfm3_time__"
7+
68

79
def default_recipe() -> sp.Recipe: # noqa: D103
810
return sp.Recipe(
11+
features=sp.LinearDetrend(TIME_COLUMN, threshold=0.5),
12+
target=sp.LinearDetrend(TIME_COLUMN, threshold=0.5),
913
output=sp.AverageEstimators(),
1014
)

‎sdm/processing/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
QuantileTransform,
3535
RankGaussian,
3636
Standardize,
37+
LinearDetrend,
3738
RobustScale,
3839
FlipSign,
3940
DropConstantColumns,
@@ -79,6 +80,7 @@
7980
"QuantileTransform",
8081
"RankGaussian",
8182
"Standardize",
83+
"LinearDetrend",
8284
"RobustScale",
8385
"FlipSign",
8486
"DropConstantColumns",

‎sdm/processing/numerical/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sdm.processing.numerical.quantile import QuantileTransform
1414
from sdm.processing.numerical.rank_gaussian import RankGaussian
1515
from sdm.processing.numerical.standardize import Standardize
16+
from sdm.processing.numerical.linear_detrend import LinearDetrend
1617
from sdm.processing.numerical.robust_scale import RobustScale
1718
from sdm.processing.numerical.flip_sign import FlipSign
1819
from sdm.processing.numerical.constant import DropConstantColumns
@@ -30,6 +31,7 @@
3031
"QuantileTransform",
3132
"RankGaussian",
3233
"Standardize",
34+
"LinearDetrend",
3335
"RobustScale",
3436
"FlipSign",
3537
"DropConstantColumns",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import torch
5+
from torch import Tensor
6+
7+
from sdm import Stype, TableTensor
8+
from sdm.processing import InvertibleMixin, Processor
9+
10+
11+
class LinearDetrend(Processor, InvertibleMixin):
12+
"""Remove a linear trend fitted to observed numerical values.
13+
14+
The ``time_column`` contains numerical step coordinates stored as an ID
15+
column. Non-finite values are ignored when fitting and preserved during
16+
transformation. For predictions with multiple outputs per fitted column,
17+
the outputs for each column must be adjacent.
18+
19+
Args:
20+
time_column: Column containing the step coordinate for each row.
21+
threshold: Detrend only when the residual standard deviation is less
22+
than this fraction of the original standard deviation. ``None``
23+
always applies the fitted trend.
24+
"""
25+
26+
handles_stypes = frozenset({Stype.numerical})
27+
requires_fit = True
28+
29+
def __init__(
30+
self,
31+
time_column: str,
32+
*,
33+
threshold: float | None = None,
34+
) -> None:
35+
super().__init__()
36+
self.time_column = time_column
37+
self.threshold = threshold
38+
self.register_buffer("origin", torch.empty(0, dtype=torch.long))
39+
self.register_buffer("slope", torch.empty(0))
40+
self.register_buffer("intercept", torch.empty(0))
41+
42+
def _time(self, table: TableTensor) -> Tensor:
43+
return table[self.time_column].id[..., 0]
44+
45+
def _fit(
46+
self,
47+
table: TableTensor,
48+
*,
49+
generator: torch.Generator | None = None,
50+
) -> None:
51+
values = table.numerical.to(
52+
dtype=torch.promote_types(table.numerical.dtype, torch.float32)
53+
)
54+
time = self._time(table)
55+
self.origin = time[..., -1:].clone()
56+
time = (time - self.origin).to(dtype=values.dtype).unsqueeze(-1)
57+
valid = values.isfinite()
58+
count = valid.sum(dim=-2, keepdim=True, dtype=torch.int32)
59+
count.clamp_min_(1)
60+
sum_t = torch.where(valid, time, 0.0).sum(dim=-2, keepdim=True)
61+
sum_t2 = torch.where(valid, time.square(), 0.0).sum(
62+
dim=-2, keepdim=True
63+
)
64+
centered = values.masked_fill(~valid, 0.0)
65+
mean = centered.sum(dim=-2, keepdim=True) / count
66+
centered.sub_(mean).masked_fill_(~valid, 0.0)
67+
time_ss = (sum_t2 - sum_t.square() / count).clamp_min_(0.0)
68+
cross = time.transpose(-2, -1) @ centered
69+
70+
constant_time = time_ss == 0
71+
self.slope = cross / torch.where(constant_time, 1.0, time_ss)
72+
self.slope.masked_fill_(constant_time, 0.0)
73+
self.intercept = mean - self.slope * (sum_t / count)
74+
75+
if self.threshold is not None:
76+
value_ss = centered.square_().sum(dim=-2, keepdim=True)
77+
# OLS residual sum of squares: value_ss - slope * cross.
78+
residual_ss = (value_ss - self.slope * cross).clamp_min_(0.0)
79+
apply = residual_ss.sqrt() < self.threshold * value_ss.sqrt()
80+
self.slope.masked_fill_(~apply, 0.0)
81+
self.intercept.masked_fill_(~apply, 0.0)
82+
83+
def _trend(self, table: TableTensor) -> Tensor:
84+
time = (
85+
(self._time(table) - self.origin)
86+
.to(dtype=self.slope.dtype)
87+
.unsqueeze(-1)
88+
)
89+
return self.slope * time + self.intercept
90+
91+
def _transform(self, table: TableTensor) -> TableTensor:
92+
time = (self._time(table) - self.origin).to(dtype=self.slope.dtype)
93+
numerical = table.numerical - self.intercept
94+
numerical.addcmul_(self.slope, time.unsqueeze(-1), value=-1)
95+
return table.replace_blocks(
96+
numerical=numerical.to(table.numerical.dtype)
97+
)
98+
99+
def _inverse_transform(self, table: TableTensor) -> TableTensor:
100+
trend = self._trend(table) # [..., F, T]
101+
values = table.numerical.unflatten(-1, (trend.size(-1), -1))
102+
numerical = (values + trend.unsqueeze(-1)).flatten(-2)
103+
return table.replace_blocks(
104+
numerical=numerical.to(table.numerical.dtype)
105+
)
106+
107+
def __repr__(self, *, indent: int = 0) -> str:
108+
return (
109+
f"{' ' * indent}{self.__class__.__name__}("
110+
f"time_column={self.time_column!r}, threshold={self.threshold})"
111+
)

0 commit comments

Comments
 (0)