From 7b98067cf1340931a689c180ac483b14b86ca801 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Wed, 21 Oct 2026 10:48:05 +0000 Subject: [PATCH 1/9] update --- sdm/models/kumo/timeseries/anomaly/model.py | 100 ++++++++++++++++++- sdm/models/kumo/timeseries/forecast/model.py | 4 +- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/sdm/models/kumo/timeseries/anomaly/model.py b/sdm/models/kumo/timeseries/anomaly/model.py index e7cb377acc..8ff1a8886b 100644 --- a/sdm/models/kumo/timeseries/anomaly/model.py +++ b/sdm/models/kumo/timeseries/anomaly/model.py @@ -3,10 +3,17 @@ # ruff: noqa: D205 +from __future__ import annotations + +from collections.abc import Sequence from typing import ClassVar -from sdm import Recipe, Stype -from sdm.models import Model +import torch +from torch import Tensor + +from sdm import Recipe, Stype, TableTensor +from sdm.models import Callback, Model +from sdm.models._huggingface import download_checkpoint class KumoAnomaly(Model): @@ -14,13 +21,102 @@ class KumoAnomaly(Model): `"NV-Tesseract-AD: Curriculum-Enhanced Conditional Diffusion for Multivariate Time-Series Anomaly Detection" `__ paper. + + :class:`KumoAnomaly` reconstructs multivariate time series with conditional + diffusion and uses reconstruction errors to score anomalies. It combines + sinusoidal time embeddings and learned feature embeddings with + diffusion-step and masking-strategy embeddings. Its denoiser alternates + attention over time and features with gated residuals and aggregated skip + connections that predict noise at each time step and feature. + + At inference, two complementary masks hide alternating temporal blocks. + Reverse diffusion reconstructs each hidden block from the visible context, + and the sample median combines repeated reconstructions. Mean absolute + reconstruction error across features gives a score per time step; larger + scores indicate less accurately reconstructed observations. + + Args: + pretrained: Whether to load the pretrained checkpoint. + device: The device. """ supported_feature_stypes: ClassVar[frozenset[Stype]] = frozenset( {Stype.numerical} ) + def __init__( + self, + pretrained: bool = True, + device: torch.device | str | None = None, + ) -> None: + super().__init__() + + self.model = _KumoAnomaly( + device="meta" if pretrained else device, + ) + + if pretrained: + self._load_from_pretrained(device=device) + + self.eval() + + def _load_from_pretrained( + self, + device: torch.device | str | None, + ) -> KumoAnomaly: + device = torch.get_default_device() if device is None else device + + path = download_checkpoint( + repo_id="nvidia/Kumo-Anomaly", + filename="final_model.pth", + revision="226c5003b0582adc1cac433123f47324bfae39f2", + ) + torch.load(path, map_location=device, weights_only=True) # TODO + + return self + @classmethod def default_recipe(cls) -> Recipe: r""":meta private:""" # noqa: D415 return Recipe() + + def forward( + self, + x: Tensor | TableTensor, + *, + recipe: Recipe | None = None, + num_samples: int = 30, + num_steps: int = 20, + callbacks: Sequence[Callback] | None = None, + generator: torch.Generator | None = None, + ) -> TableTensor: + r"""The forward pass. + + Args: + x: The feature tensor with shape ``[..., R, D]`` with ``R`` rows + and ``D`` columns. + recipe: The custom recipe for pre- and post-processing. + num_samples: The number of reconstruction samples per complementary + masking strategy. Reconstructions are combined using the + sample median. + num_steps: The number of denoiser evaluations per sample and + masking strategy. + callbacks: Callbacks applied in sequence to this model call. + generator: Pseudorandom number generator used for sampling during + pre-processing and model execution. + + Returns: + The anomaly scores with shape ``[..., R, 1]``, denoting the mean + absolute reconstruction error across preprocessed features at each + time step. + """ + raise NotImplementedError + + +class _KumoAnomaly(torch.nn.Module): + def __init__( + self, + device: torch.device | str | None = None, + dtype: torch.dtype | None = None, + ) -> None: + super().__init__() diff --git a/sdm/models/kumo/timeseries/forecast/model.py b/sdm/models/kumo/timeseries/forecast/model.py index b312ef67c3..7eb9e25689 100644 --- a/sdm/models/kumo/timeseries/forecast/model.py +++ b/sdm/models/kumo/timeseries/forecast/model.py @@ -94,14 +94,14 @@ def _load_from_pretrained( ) -> KumoForecast: device = torch.get_default_device() if device is None else device - checkpoint = download_checkpoint( + path = download_checkpoint( repo_id="nvidia/Kumo-Forecast", filename="run8_best_model_cr.pt" if use_cross_channel else "moment_head_512_6hr.pt", revision="abff20a58834638b28227ff4ab934f26206e4b09", ) - ckpt = torch.load(checkpoint, map_location=device, weights_only=True) + ckpt = torch.load(path, map_location=device, weights_only=True) self.model.load_state_dict(remap_ckpt(ckpt), assign=True) return self From 2aa400f3887a40cbce67d8b152cfa260385a90b9 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Wed, 21 Oct 2026 11:18:08 +0000 Subject: [PATCH 2/9] update --- sdm/models/kumo/timeseries/anomaly/model.py | 3 +- sdm/models/kumo/timeseries/anomaly/recipe.py | 15 +++++ sdm/processing/__init__.py | 2 + sdm/processing/numerical/__init__.py | 2 + sdm/processing/numerical/min_max_scale.py | 53 +++++++++++++++ sdm/processing/numerical/pca.py | 13 ++-- .../kumo/timeseries/anomaly/test_recipe.py | 45 +++++++++++++ .../numerical/test_min_max_scale.py | 67 +++++++++++++++++++ test/processing/numerical/test_pca.py | 18 +++++ test/processing/test_contract.py | 1 + test/processing/test_recipe.py | 4 +- 11 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 sdm/models/kumo/timeseries/anomaly/recipe.py create mode 100644 sdm/processing/numerical/min_max_scale.py create mode 100644 test/models/kumo/timeseries/anomaly/test_recipe.py create mode 100644 test/processing/numerical/test_min_max_scale.py diff --git a/sdm/models/kumo/timeseries/anomaly/model.py b/sdm/models/kumo/timeseries/anomaly/model.py index 8ff1a8886b..46a3534a7b 100644 --- a/sdm/models/kumo/timeseries/anomaly/model.py +++ b/sdm/models/kumo/timeseries/anomaly/model.py @@ -14,6 +14,7 @@ from sdm import Recipe, Stype, TableTensor from sdm.models import Callback, Model from sdm.models._huggingface import download_checkpoint +from sdm.models.kumo.timeseries.anomaly.recipe import default_recipe class KumoAnomaly(Model): @@ -78,7 +79,7 @@ def _load_from_pretrained( @classmethod def default_recipe(cls) -> Recipe: r""":meta private:""" # noqa: D415 - return Recipe() + return default_recipe() def forward( self, diff --git a/sdm/models/kumo/timeseries/anomaly/recipe.py b/sdm/models/kumo/timeseries/anomaly/recipe.py new file mode 100644 index 0000000000..3f6425b6cd --- /dev/null +++ b/sdm/models/kumo/timeseries/anomaly/recipe.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import sdm.processing as sp + + +def default_recipe() -> sp.Recipe: # noqa: D103 + return sp.Recipe( + features=sp.StypeDispatch( + numerical=[ + sp.PCA(num_components=40), + sp.MinMaxScale(), + ] + ), + ) diff --git a/sdm/processing/__init__.py b/sdm/processing/__init__.py index 45a6d4a48f..800b423ab3 100644 --- a/sdm/processing/__init__.py +++ b/sdm/processing/__init__.py @@ -35,6 +35,7 @@ RankGaussian, Standardize, RobustScale, + MinMaxScale, FlipSign, DropConstantColumns, PCA, @@ -83,6 +84,7 @@ "RankGaussian", "Standardize", "RobustScale", + "MinMaxScale", "FlipSign", "DropConstantColumns", "PCA", diff --git a/sdm/processing/numerical/__init__.py b/sdm/processing/numerical/__init__.py index d8665649d4..673cfff32a 100644 --- a/sdm/processing/numerical/__init__.py +++ b/sdm/processing/numerical/__init__.py @@ -13,6 +13,7 @@ from sdm.processing.numerical.rank_gaussian import RankGaussian from sdm.processing.numerical.standardize import Standardize from sdm.processing.numerical.robust_scale import RobustScale +from sdm.processing.numerical.min_max_scale import MinMaxScale from sdm.processing.numerical.flip_sign import FlipSign from sdm.processing.numerical.constant import DropConstantColumns from sdm.processing.numerical.pca import PCA @@ -32,6 +33,7 @@ "RankGaussian", "Standardize", "RobustScale", + "MinMaxScale", "FlipSign", "DropConstantColumns", "PCA", diff --git a/sdm/processing/numerical/min_max_scale.py b/sdm/processing/numerical/min_max_scale.py new file mode 100644 index 0000000000..1b84258e39 --- /dev/null +++ b/sdm/processing/numerical/min_max_scale.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import torch +from torch import Tensor + +from sdm import Stype, TableTensor +from sdm.processing import InvertibleMixin, Processor +from sdm.processing.numerical._stats import _isfinite + + +class MinMaxScale(Processor, InvertibleMixin): + """Scale each numerical column by its fitted minimum and range. + + Fitted minima map to zero and maxima to one. Values outside the fitted + range are not clipped. Constant columns use a unit scale. + NaN and infinite values are ignored when fitting statistics and preserved + during the transform. Columns without finite fitting values yield NaN. + """ + + handles_stypes = (Stype.numerical,) + requires_fit = True + + _minimum: Tensor + _scale: Tensor + + def __init__(self) -> None: + super().__init__() + self.register_buffer("_minimum", torch.empty(0)) + self.register_buffer("_scale", torch.empty(0)) + + def _fit( + self, + table: TableTensor, + *, + generator: torch.Generator | None = None, + ) -> None: + x = table.numerical + missing = ~_isfinite(x) + minimum = x.masked_fill(missing, torch.inf).amin(-2, keepdim=True) + maximum = x.masked_fill(missing, -torch.inf).amax(-2, keepdim=True) + minimum.masked_fill_(minimum.isinf(), torch.nan) + scale = maximum - minimum + self._set_buffer("_minimum", minimum) + self._set_buffer("_scale", scale.masked_fill_(scale == 0, 1)) + + def _transform(self, table: TableTensor) -> TableTensor: + numerical = (table.numerical - self._minimum).div_(self._scale) + return table.replace_blocks(numerical=numerical) + + def _inverse_transform(self, table: TableTensor) -> TableTensor: + numerical = (table.numerical * self._scale).add_(self._minimum) + return table.replace_blocks(numerical=numerical) diff --git a/sdm/processing/numerical/pca.py b/sdm/processing/numerical/pca.py index 2924ec561f..c993c3b75d 100644 --- a/sdm/processing/numerical/pca.py +++ b/sdm/processing/numerical/pca.py @@ -14,7 +14,8 @@ class PCA(Processor): r"""Project numerical columns onto their principal components. Args: - num_components: Number of principal components to keep. + num_components: Number of principal components to keep. Numerical + columns are unchanged when their count is at most this value. """ handles_stypes = (Stype.numerical,) @@ -36,11 +37,8 @@ def _fit( generator: torch.Generator | None = None, ) -> None: - if table.numerical.size(-1) == 0: - raise ValueError( - f"{self.__class__.__name__!r} requires 'table' to have " - f"numerical features" - ) + if table.numerical.size(-1) <= self.num_components: + return self._set_buffer("mean", table.numerical.mean(dim=-2, keepdim=True)) _, _, vh = torch.linalg.svd( @@ -58,6 +56,9 @@ def _fit( ) def _transform(self, table: TableTensor) -> TableTensor: + if table.numerical.size(-1) <= self.num_components: + return table + x = (table.numerical - self.mean) @ self.components out = TableTensor( columns={Stype.numerical: [f"pca_{i}" for i in range(x.size(-1))]}, diff --git a/test/models/kumo/timeseries/anomaly/test_recipe.py b/test/models/kumo/timeseries/anomaly/test_recipe.py new file mode 100644 index 0000000000..622ac87e21 --- /dev/null +++ b/test/models/kumo/timeseries/anomaly/test_recipe.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +import torch + +from sdm import EnsembleTable, TableTensor +from sdm.models.kumo.timeseries.anomaly import KumoAnomaly +from sdm.processing import PCA + + +@pytest.mark.parametrize("num_features", [3, 40, 45]) +@pytest.mark.parametrize("batch_shape", [(), (2,)]) +def test_recipe_reduces_and_scales_features( + num_features: int, + batch_shape: tuple[int, ...], +) -> None: + reference = TableTensor.from_tensor( + torch.randn(*batch_shape, 50, num_features) + ) + query = TableTensor.from_tensor( + torch.randn(*batch_shape, 10, num_features) + ) + recipe = KumoAnomaly.default_recipe() + recipe.features.fit_ensemble( + EnsembleTable.from_table(reference, num_members=1) + ) + output = recipe.features.transform_ensemble( + EnsembleTable.from_table(query, num_members=1) + )[0] + + fitted = reference + expected = query + if num_features > 40: + pca = PCA(num_components=40).fit(reference) + fitted = pca.transform(reference) + expected = pca.transform(query) + + minimum = fitted.numerical.amin(dim=-2, keepdim=True) + scale = fitted.numerical.amax(dim=-2, keepdim=True) - minimum + scale = scale.masked_fill(scale == 0, 1) + assert output.columns == expected.columns + torch.testing.assert_close( + output.numerical, (expected.numerical - minimum) / scale + ) diff --git a/test/processing/numerical/test_min_max_scale.py b/test/processing/numerical/test_min_max_scale.py new file mode 100644 index 0000000000..ed83c37e42 --- /dev/null +++ b/test/processing/numerical/test_min_max_scale.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +import torch + +from sdm import TableTensor +from sdm.processing import MinMaxScale +from sdm.testing import withCUDA + + +@withCUDA +@pytest.mark.parametrize( + "dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64] +) +def test_min_max_scale_fitted_range( + device: torch.device, dtype: torch.dtype +) -> None: + reference = torch.tensor( + [[1.0, 5.0], [3.0, 5.0]], device=device, dtype=dtype + ) + processor = MinMaxScale() + out = processor.fit_transform(TableTensor.from_tensor(reference)) + torch.testing.assert_close( + out.numerical, reference.new_tensor([[0.0, 0.0], [1.0, 0.0]]) + ) + + query = reference.new_tensor([[-1.0, 3.0], [5.0, 7.0]]) + out = processor.transform(TableTensor.from_tensor(query)) + torch.testing.assert_close( + out.numerical, query.new_tensor([[-1.0, -2.0], [2.0, 2.0]]) + ) + torch.testing.assert_close( + processor.inverse_transform(out).numerical, query + ) + + +@withCUDA +def test_min_max_scale_batched_statistics(device: torch.device) -> None: + reference = torch.tensor( + [[[[1.0], [3.0]], [[10.0], [18.0]]]], device=device + ) + query = torch.tensor([[[[2.0]], [[16.0]]]], device=device) + processor = MinMaxScale().fit(TableTensor.from_tensor(reference)) + out = processor.transform(TableTensor.from_tensor(query)) + torch.testing.assert_close( + out.numerical, query.new_tensor([[[[0.5]], [[0.75]]]]) + ) + + +@withCUDA +def test_min_max_scale_nonfinite_values(device: torch.device) -> None: + x = torch.tensor( + [ + [1.0, torch.nan], + [3.0, torch.inf], + [torch.inf, -torch.inf], + [-torch.inf, torch.nan], + [torch.nan, torch.nan], + ], + device=device, + ) + out = MinMaxScale().fit_transform(TableTensor.from_tensor(x)) + expected = x.clone() + expected[:2, 0] = x.new_tensor([0.0, 1.0]) + expected[:, 1] = torch.nan + torch.testing.assert_close(out.numerical, expected, equal_nan=True) diff --git a/test/processing/numerical/test_pca.py b/test/processing/numerical/test_pca.py index 964c727285..049c5432d2 100644 --- a/test/processing/numerical/test_pca.py +++ b/test/processing/numerical/test_pca.py @@ -19,6 +19,24 @@ def test_basic(shape: tuple[int, ...]) -> None: assert output.numerical_columns == ("pca_0", "pca_1") +@pytest.mark.parametrize("num_components", [3, 5]) +@pytest.mark.parametrize("shape", [(2, 3), (2, 4, 3)]) +def test_pca_preserves_features_without_reduction( + num_components: int, + shape: tuple[int, ...], +) -> None: + table = TableTensor.from_tensor(torch.randn(shape)) + pca = PCA(num_components=num_components) + output = pca.fit_transform(table) + assert output.columns == table.columns + torch.testing.assert_close(output.numerical, table.numerical) + + query = TableTensor.from_tensor(torch.randn(shape)) + output = pca.transform(query) + assert output.columns == query.columns + torch.testing.assert_close(output.numerical, query.numerical) + + def test_pca_projects_onto_fitted_dominant_direction() -> None: steps = torch.arange(10, dtype=torch.float) fit_data = torch.stack((steps, steps), dim=-1) diff --git a/test/processing/test_contract.py b/test/processing/test_contract.py index 7525b3b8c7..8078d86d68 100644 --- a/test/processing/test_contract.py +++ b/test/processing/test_contract.py @@ -129,6 +129,7 @@ def _make_processor_pair( ProcessorCase(sp.Standardize()), ProcessorCase(sp.RankGaussian()), ProcessorCase(sp.RobustScale()), + ProcessorCase(sp.MinMaxScale()), ProcessorCase(sp.FlipSign()), ProcessorCase(sp.DropConstantColumns()), ProcessorCase(sp.PCA(2)), diff --git a/test/processing/test_recipe.py b/test/processing/test_recipe.py index faa05f0dec..42b2eb973e 100644 --- a/test/processing/test_recipe.py +++ b/test/processing/test_recipe.py @@ -24,7 +24,9 @@ def test_recipe_normalizes_empty_roles_and_repr() -> None: assert len(recipe.features) == 1 assert len(recipe.output) == 0 assert "features=Sequential" in repr(recipe) - assert "target=Identity()" in repr(recipe) + assert "target=" not in repr(recipe) + assert "target=" not in repr(sp.Recipe(target=sp.Identity())) + assert "target=Standardize" in repr(sp.Recipe(target=sp.Standardize())) def test_target_forward_then_inverse_round_trips() -> None: From 3524ba79e2f828f9c17043e4d2a4f1299dd5f1ac Mon Sep 17 00:00:00 2001 From: rusty1s Date: Wed, 21 Oct 2026 11:22:06 +0000 Subject: [PATCH 3/9] update --- sdm/processing/numerical/pca.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/sdm/processing/numerical/pca.py b/sdm/processing/numerical/pca.py index c993c3b75d..c1e37e9ee7 100644 --- a/sdm/processing/numerical/pca.py +++ b/sdm/processing/numerical/pca.py @@ -14,8 +14,7 @@ class PCA(Processor): r"""Project numerical columns onto their principal components. Args: - num_components: Number of principal components to keep. Numerical - columns are unchanged when their count is at most this value. + num_components: Number of principal components to keep. """ handles_stypes = (Stype.numerical,) @@ -45,11 +44,7 @@ def _fit( table.numerical - self.mean, full_matrices=False, ) - num_components = min( - self.num_components, - table.numerical.size(-2), - table.numerical.size(-1), - ) + num_components = min(self.num_components, table.numerical.size(-2)) self._set_buffer( name="components", value=vh[..., :num_components, :].transpose(-2, -1), From 975d63a03e7c7276ee0170a34df1dfed7773d44c Mon Sep 17 00:00:00 2001 From: rusty1s Date: Wed, 21 Oct 2026 11:25:16 +0000 Subject: [PATCH 4/9] update --- test/processing/numerical/test_pca.py | 18 ------------------ test/processing/test_recipe.py | 4 +--- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/test/processing/numerical/test_pca.py b/test/processing/numerical/test_pca.py index 049c5432d2..964c727285 100644 --- a/test/processing/numerical/test_pca.py +++ b/test/processing/numerical/test_pca.py @@ -19,24 +19,6 @@ def test_basic(shape: tuple[int, ...]) -> None: assert output.numerical_columns == ("pca_0", "pca_1") -@pytest.mark.parametrize("num_components", [3, 5]) -@pytest.mark.parametrize("shape", [(2, 3), (2, 4, 3)]) -def test_pca_preserves_features_without_reduction( - num_components: int, - shape: tuple[int, ...], -) -> None: - table = TableTensor.from_tensor(torch.randn(shape)) - pca = PCA(num_components=num_components) - output = pca.fit_transform(table) - assert output.columns == table.columns - torch.testing.assert_close(output.numerical, table.numerical) - - query = TableTensor.from_tensor(torch.randn(shape)) - output = pca.transform(query) - assert output.columns == query.columns - torch.testing.assert_close(output.numerical, query.numerical) - - def test_pca_projects_onto_fitted_dominant_direction() -> None: steps = torch.arange(10, dtype=torch.float) fit_data = torch.stack((steps, steps), dim=-1) diff --git a/test/processing/test_recipe.py b/test/processing/test_recipe.py index 42b2eb973e..faa05f0dec 100644 --- a/test/processing/test_recipe.py +++ b/test/processing/test_recipe.py @@ -24,9 +24,7 @@ def test_recipe_normalizes_empty_roles_and_repr() -> None: assert len(recipe.features) == 1 assert len(recipe.output) == 0 assert "features=Sequential" in repr(recipe) - assert "target=" not in repr(recipe) - assert "target=" not in repr(sp.Recipe(target=sp.Identity())) - assert "target=Standardize" in repr(sp.Recipe(target=sp.Standardize())) + assert "target=Identity()" in repr(recipe) def test_target_forward_then_inverse_round_trips() -> None: From 47679cff77472c0b2d120985ab81bd99aa30df97 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Wed, 21 Oct 2026 11:33:09 +0000 Subject: [PATCH 5/9] update --- sdm/processing/numerical/min_max_scale.py | 23 ++++----- .../kumo/timeseries/anomaly/test_recipe.py | 45 ----------------- .../numerical/test_min_max_scale.py | 50 ++----------------- 3 files changed, 16 insertions(+), 102 deletions(-) delete mode 100644 test/models/kumo/timeseries/anomaly/test_recipe.py diff --git a/sdm/processing/numerical/min_max_scale.py b/sdm/processing/numerical/min_max_scale.py index 1b84258e39..4b68a78d4e 100644 --- a/sdm/processing/numerical/min_max_scale.py +++ b/sdm/processing/numerical/min_max_scale.py @@ -12,22 +12,20 @@ class MinMaxScale(Processor, InvertibleMixin): """Scale each numerical column by its fitted minimum and range. - Fitted minima map to zero and maxima to one. Values outside the fitted - range are not clipped. Constant columns use a unit scale. NaN and infinite values are ignored when fitting statistics and preserved - during the transform. Columns without finite fitting values yield NaN. + during the transform. """ handles_stypes = (Stype.numerical,) requires_fit = True - _minimum: Tensor - _scale: Tensor + minimum: Tensor + scale: Tensor def __init__(self) -> None: super().__init__() - self.register_buffer("_minimum", torch.empty(0)) - self.register_buffer("_scale", torch.empty(0)) + self.register_buffer("minimum", torch.empty(0)) + self.register_buffer("scale", torch.empty(0)) def _fit( self, @@ -38,16 +36,17 @@ def _fit( x = table.numerical missing = ~_isfinite(x) minimum = x.masked_fill(missing, torch.inf).amin(-2, keepdim=True) + minimum[minimum.isinf()] = torch.nan maximum = x.masked_fill(missing, -torch.inf).amax(-2, keepdim=True) - minimum.masked_fill_(minimum.isinf(), torch.nan) scale = maximum - minimum - self._set_buffer("_minimum", minimum) - self._set_buffer("_scale", scale.masked_fill_(scale == 0, 1)) + scale[scale == 0] = 1 + self._set_buffer("minimum", minimum) + self._set_buffer("scale", scale) def _transform(self, table: TableTensor) -> TableTensor: - numerical = (table.numerical - self._minimum).div_(self._scale) + numerical = (table.numerical - self.minimum).div_(self.scale) return table.replace_blocks(numerical=numerical) def _inverse_transform(self, table: TableTensor) -> TableTensor: - numerical = (table.numerical * self._scale).add_(self._minimum) + numerical = (table.numerical * self.scale).add_(self.minimum) return table.replace_blocks(numerical=numerical) diff --git a/test/models/kumo/timeseries/anomaly/test_recipe.py b/test/models/kumo/timeseries/anomaly/test_recipe.py deleted file mode 100644 index 622ac87e21..0000000000 --- a/test/models/kumo/timeseries/anomaly/test_recipe.py +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import pytest -import torch - -from sdm import EnsembleTable, TableTensor -from sdm.models.kumo.timeseries.anomaly import KumoAnomaly -from sdm.processing import PCA - - -@pytest.mark.parametrize("num_features", [3, 40, 45]) -@pytest.mark.parametrize("batch_shape", [(), (2,)]) -def test_recipe_reduces_and_scales_features( - num_features: int, - batch_shape: tuple[int, ...], -) -> None: - reference = TableTensor.from_tensor( - torch.randn(*batch_shape, 50, num_features) - ) - query = TableTensor.from_tensor( - torch.randn(*batch_shape, 10, num_features) - ) - recipe = KumoAnomaly.default_recipe() - recipe.features.fit_ensemble( - EnsembleTable.from_table(reference, num_members=1) - ) - output = recipe.features.transform_ensemble( - EnsembleTable.from_table(query, num_members=1) - )[0] - - fitted = reference - expected = query - if num_features > 40: - pca = PCA(num_components=40).fit(reference) - fitted = pca.transform(reference) - expected = pca.transform(query) - - minimum = fitted.numerical.amin(dim=-2, keepdim=True) - scale = fitted.numerical.amax(dim=-2, keepdim=True) - minimum - scale = scale.masked_fill(scale == 0, 1) - assert output.columns == expected.columns - torch.testing.assert_close( - output.numerical, (expected.numerical - minimum) / scale - ) diff --git a/test/processing/numerical/test_min_max_scale.py b/test/processing/numerical/test_min_max_scale.py index ed83c37e42..984b784afc 100644 --- a/test/processing/numerical/test_min_max_scale.py +++ b/test/processing/numerical/test_min_max_scale.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -import pytest import torch from sdm import TableTensor @@ -10,22 +9,15 @@ @withCUDA -@pytest.mark.parametrize( - "dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64] -) -def test_min_max_scale_fitted_range( - device: torch.device, dtype: torch.dtype -) -> None: - reference = torch.tensor( - [[1.0, 5.0], [3.0, 5.0]], device=device, dtype=dtype - ) +def test_min_max_scale(device: torch.device) -> None: + data = torch.tensor([[1.0, 5.0], [3.0, 5.0]], device=device) processor = MinMaxScale() - out = processor.fit_transform(TableTensor.from_tensor(reference)) + out = processor.fit_transform(TableTensor.from_tensor(data)) torch.testing.assert_close( - out.numerical, reference.new_tensor([[0.0, 0.0], [1.0, 0.0]]) + out.numerical, data.new_tensor([[0.0, 0.0], [1.0, 0.0]]) ) - query = reference.new_tensor([[-1.0, 3.0], [5.0, 7.0]]) + query = data.new_tensor([[-1.0, 3.0], [5.0, 7.0]]) out = processor.transform(TableTensor.from_tensor(query)) torch.testing.assert_close( out.numerical, query.new_tensor([[-1.0, -2.0], [2.0, 2.0]]) @@ -33,35 +25,3 @@ def test_min_max_scale_fitted_range( torch.testing.assert_close( processor.inverse_transform(out).numerical, query ) - - -@withCUDA -def test_min_max_scale_batched_statistics(device: torch.device) -> None: - reference = torch.tensor( - [[[[1.0], [3.0]], [[10.0], [18.0]]]], device=device - ) - query = torch.tensor([[[[2.0]], [[16.0]]]], device=device) - processor = MinMaxScale().fit(TableTensor.from_tensor(reference)) - out = processor.transform(TableTensor.from_tensor(query)) - torch.testing.assert_close( - out.numerical, query.new_tensor([[[[0.5]], [[0.75]]]]) - ) - - -@withCUDA -def test_min_max_scale_nonfinite_values(device: torch.device) -> None: - x = torch.tensor( - [ - [1.0, torch.nan], - [3.0, torch.inf], - [torch.inf, -torch.inf], - [-torch.inf, torch.nan], - [torch.nan, torch.nan], - ], - device=device, - ) - out = MinMaxScale().fit_transform(TableTensor.from_tensor(x)) - expected = x.clone() - expected[:2, 0] = x.new_tensor([0.0, 1.0]) - expected[:, 1] = torch.nan - torch.testing.assert_close(out.numerical, expected, equal_nan=True) From cbc7404d92c2f445e6857cb26d3edd680c41fea1 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Thu, 22 Oct 2026 03:14:30 +0000 Subject: [PATCH 6/9] update --- sdm/models/kumo/timeseries/anomaly/model.py | 73 ++++++++++++++++++- sdm/processing/numerical/min_max_scale.py | 2 +- .../kumo/timeseries/anomaly/test_model.py | 20 +++++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 test/models/kumo/timeseries/anomaly/test_model.py diff --git a/sdm/models/kumo/timeseries/anomaly/model.py b/sdm/models/kumo/timeseries/anomaly/model.py index 46a3534a7b..bc749aef7d 100644 --- a/sdm/models/kumo/timeseries/anomaly/model.py +++ b/sdm/models/kumo/timeseries/anomaly/model.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy from collections.abc import Sequence from typing import ClassVar @@ -12,9 +13,11 @@ from torch import Tensor from sdm import Recipe, Stype, TableTensor +from sdm._inference import inference_mode from sdm.models import Callback, Model from sdm.models._huggingface import download_checkpoint from sdm.models.kumo.timeseries.anomaly.recipe import default_recipe +from sdm.processing.execution import MemberContext, RecipeExecution class KumoAnomaly(Model): @@ -83,14 +86,14 @@ def default_recipe(cls) -> Recipe: def forward( self, - x: Tensor | TableTensor, + x: Tensor | TableTensor, # [ ..., R, D] *, recipe: Recipe | None = None, num_samples: int = 30, num_steps: int = 20, callbacks: Sequence[Callback] | None = None, generator: torch.Generator | None = None, - ) -> TableTensor: + ) -> TableTensor: # [..., R, 1] r"""The forward pass. Args: @@ -111,7 +114,62 @@ def forward( absolute reconstruction error across preprocessed features at each time step. """ - raise NotImplementedError + callbacks = () if callbacks is None else callbacks + requires_grad = self.training + requires_grad |= any(callback.requires_grad for callback in callbacks) + + recipe_execution = RecipeExecution( + self.default_recipe() if recipe is None else copy.deepcopy(recipe) + ) + with ( + torch.amp.autocast(x.device.type, enabled=False), + inference_mode("no_grad" if requires_grad else "inference"), + ): + context = recipe_execution.fit_transform( + x=x, + y=None, + related_tables=None, + num_members=1, + generator=generator, + )[0] + + for callback in callbacks: + context = MemberContext( + *callback.on_context_preprocessing_end( + model=self, + x=context.x, + y=context.y, + x_context_only=context.x_context_only, + related_tables=context.related_tables, + ), + input_stypes=context.input_stypes, + ) + + self._validate_context( + x=context.x, + y=context.y, + x_context_only=context.x_context_only, + related_tables=context.related_tables, + ) + + with inference_mode("grad" if requires_grad else "inference"): + out = self.model( + context.x.numerical, + num_samples=num_samples, + num_steps=num_steps, + generator=generator, + ) + + for callback in callbacks: + out = callback.on_model_forward_end(self, out) + + out = out.to(context.x.numerical) + + with ( + torch.amp.autocast(x.device.type, enabled=False), + inference_mode("grad" if requires_grad else "inference"), + ): + return recipe_execution.transform_output([out])[0] class _KumoAnomaly(torch.nn.Module): @@ -121,3 +179,12 @@ def __init__( dtype: torch.dtype | None = None, ) -> None: super().__init__() + + def forward( + self, + x: Tensor, # [..., R, C] + num_samples: int, + num_steps: int, + generator: torch.Generator | None = None, + ) -> Tensor: # [..., R, 1] + return x.mean(dim=-1, keepdim=True) diff --git a/sdm/processing/numerical/min_max_scale.py b/sdm/processing/numerical/min_max_scale.py index 4b68a78d4e..1915093459 100644 --- a/sdm/processing/numerical/min_max_scale.py +++ b/sdm/processing/numerical/min_max_scale.py @@ -10,7 +10,7 @@ class MinMaxScale(Processor, InvertibleMixin): - """Scale each numerical column by its fitted minimum and range. + """Scale each numerical column by its fitted min-max range. NaN and infinite values are ignored when fitting statistics and preserved during the transform. diff --git a/test/models/kumo/timeseries/anomaly/test_model.py b/test/models/kumo/timeseries/anomaly/test_model.py new file mode 100644 index 0000000000..996237d59b --- /dev/null +++ b/test/models/kumo/timeseries/anomaly/test_model.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import torch + +from sdm import TableTensor +from sdm.models import KumoAnomaly +from sdm.testing import withCUDA + + +@withCUDA +def test_forward(device: torch.device) -> None: + model = KumoAnomaly(pretrained=False, device=device) + x = TableTensor.from_tensor(torch.randn(32, 5, device=device)) + + out = model(x) + assert out.size() == (32, 1) + assert out.device == device + assert out.numerical.isfinite().all() + assert "mae" in out.numerical_columns From a0ad470af15c194ff83832a660e08128a5d282e6 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Thu, 22 Oct 2026 08:34:42 +0000 Subject: [PATCH 7/9] update --- sdm/models/base/icl.py | 35 +---- sdm/models/base/model.py | 18 ++- sdm/models/kumo/timeseries/anomaly/model.py | 29 ++-- sdm/processing/execution.py | 165 +++++++++++--------- 4 files changed, 124 insertions(+), 123 deletions(-) diff --git a/sdm/models/base/icl.py b/sdm/models/base/icl.py index 560db6a214..78734c8892 100644 --- a/sdm/models/base/icl.py +++ b/sdm/models/base/icl.py @@ -20,7 +20,6 @@ ) from sdm._inference import inference_mode from sdm._memory import set_pinned_max_round_threshold -from sdm._warnings import warn_once from sdm.cache import Cache from sdm.models.base import Callback, Model from sdm.processing.execution import ( @@ -701,7 +700,8 @@ def _validate_context( f"dimensions (got {tuple(x.size()[:-1])} and " f"{tuple(y.size()[:-1])})" ) - feature_stypes = x.active_stypes + self._validate_feature_stype(x) + if x_context_only is not None: if x_context_only.size()[:-1] != y.size()[:-1]: raise ValueError( @@ -710,18 +710,8 @@ def _validate_context( f"(got {tuple(x_context_only.size()[:-1])} and " f"{tuple(y.size()[:-1])})" ) - feature_stypes = feature_stypes | x_context_only.active_stypes - invalid = feature_stypes - self.supported_feature_stypes - {Stype.id} - if len(invalid) > 0: - stypes = ", ".join(f"{str(stype)!r}" for stype in invalid) - warn_once( - key="model-unsupported-feature-stypes", - message=( - f"{self.__class__.__name__!r} received unsupported " - f"feature stypes {stypes}. Columns with unsupported " - f"feature stypes will not be consumed by the model." - ), - ) + self._validate_feature_stype(x_context_only) + invalid = y.active_stypes - self.supported_target_stypes if len(invalid) > 0: stypes = ", ".join(f"{str(stype)!r}" for stype in invalid) @@ -745,21 +735,8 @@ def _validate_context( f"{self.__class__.__name__!r} does not support related " f"tables" ) - for table_name, table in related_tables.tables.items(): - invalid = table.active_stypes - self.supported_feature_stypes - invalid = invalid - {Stype.id} - if len(invalid) > 0: - stypes = ", ".join(f"{str(stype)!r}" for stype in invalid) - warn_once( - key="model-unsupported-feature-stypes", - message=( - f"{self.__class__.__name__!r} received " - f"unsupported feature stypes {stypes} in related " - f"table {table_name!r}. Columns with unsupported " - f"feature stypes will not be consumed by the " - f"model." - ), - ) + for table in related_tables.tables.values(): + self._validate_feature_stype(table) def _validate_query( self, diff --git a/sdm/models/base/model.py b/sdm/models/base/model.py index a266c9960d..1b48d1ecfd 100644 --- a/sdm/models/base/model.py +++ b/sdm/models/base/model.py @@ -8,7 +8,8 @@ import torch from torch import Tensor -from sdm import Recipe, Stype +from sdm import Recipe, Stype, TableTensor +from sdm._warnings import warn_once from sdm.cache import Cache from sdm.tensor.mixin import DeviceMixin @@ -74,6 +75,21 @@ def __repr__(self) -> str: device_repr = f"device={device}" if device.type != "cpu" else "" return f"{self.__class__.__name__}({device_repr})" + # Helpers ################################################################# + + def _validate_feature_stype(self, x: TableTensor) -> None: + invalid = x.active_stypes - self.supported_feature_stypes - {Stype.id} + if len(invalid) > 0: + stypes = ", ".join(f"{str(stype)!r}" for stype in invalid) + warn_once( + key="model-unsupported-feature-stypes", + message=( + f"{self.__class__.__name__!r} received unsupported " + f"feature stypes {stypes}. Columns with unsupported " + f"feature stypes will not be consumed by the model." + ), + ) + class _Prefetcher(Generic[T]): def __init__( diff --git a/sdm/models/kumo/timeseries/anomaly/model.py b/sdm/models/kumo/timeseries/anomaly/model.py index bc749aef7d..20a094b277 100644 --- a/sdm/models/kumo/timeseries/anomaly/model.py +++ b/sdm/models/kumo/timeseries/anomaly/model.py @@ -17,7 +17,7 @@ from sdm.models import Callback, Model from sdm.models._huggingface import download_checkpoint from sdm.models.kumo.timeseries.anomaly.recipe import default_recipe -from sdm.processing.execution import MemberContext, RecipeExecution +from sdm.processing.execution import MemberQuery, RecipeExecution class KumoAnomaly(Model): @@ -125,7 +125,7 @@ def forward( torch.amp.autocast(x.device.type, enabled=False), inference_mode("no_grad" if requires_grad else "inference"), ): - context = recipe_execution.fit_transform( + query = recipe_execution.fit_transform( x=x, y=None, related_tables=None, @@ -134,27 +134,19 @@ def forward( )[0] for callback in callbacks: - context = MemberContext( - *callback.on_context_preprocessing_end( + query = MemberQuery( + *callback.on_query_preprocessing_end( model=self, - x=context.x, - y=context.y, - x_context_only=context.x_context_only, - related_tables=context.related_tables, + x=query.x, + related_tables=query.related_tables, ), - input_stypes=context.input_stypes, ) - self._validate_context( - x=context.x, - y=context.y, - x_context_only=context.x_context_only, - related_tables=context.related_tables, - ) + self._validate_feature_stype(query.x) with inference_mode("grad" if requires_grad else "inference"): out = self.model( - context.x.numerical, + x=query.x.numerical, num_samples=num_samples, num_steps=num_steps, generator=generator, @@ -163,7 +155,10 @@ def forward( for callback in callbacks: out = callback.on_model_forward_end(self, out) - out = out.to(context.x.numerical) + out = TableTensor( + columns={Stype.numerical: ("mae",)}, + numerical=out.to(query.x.numerical), + ) with ( torch.amp.autocast(x.device.type, enabled=False), diff --git a/sdm/processing/execution.py b/sdm/processing/execution.py index 16c61f7997..68a4b0e08a 100644 --- a/sdm/processing/execution.py +++ b/sdm/processing/execution.py @@ -5,7 +5,7 @@ import copy from collections.abc import Mapping, Sequence -from typing import NamedTuple, cast +from typing import NamedTuple, cast, overload import torch from torch import Tensor @@ -53,6 +53,7 @@ def num_members(self) -> int: assert self._y_locations is not None return len(self._y_locations) + @overload def fit_transform( self, x: Tensor | TableTensor, @@ -62,60 +63,89 @@ def fit_transform( num_members: int | None = None, context_only_columns: Sequence[str] | Sequence[int] | None = None, generator: torch.Generator | None = None, - ) -> tuple[MemberContext, ...]: + ) -> tuple[MemberContext, ...]: ... + + @overload + def fit_transform( + self, + x: Tensor | TableTensor, + y: None, + related_tables: RelatedTables | None, + *, + num_members: int | None = None, + context_only_columns: None = None, + generator: torch.Generator | None = None, + ) -> tuple[MemberQuery, ...]: ... + + def fit_transform( + self, + x: Tensor | TableTensor, + y: Tensor | TableTensor | None, + related_tables: RelatedTables | None, + *, + num_members: int | None = None, + context_only_columns: Sequence[str] | Sequence[int] | None = None, + generator: torch.Generator | None = None, + ) -> tuple[MemberContext | MemberQuery, ...]: """Fit and transform context data.""" + self._num_estimators = num_members + # Transform target first to be able to resolve task type. Inverse # target transforms require distinct member assignment: - y: EnsembleTable = _to_ensemble_table(y, num_members, expand=True) - - series_processors = tuple( - module - for processor in (self.recipe.features, self.recipe.target) - for module in _iter_modules(processor) - if isinstance(module, sp.SeriesEnsembleProcessor) - ) - for module in series_processors: - module._set_buffer( - name="_offset", - value=torch.zeros( - size=(len(y),), - dtype=torch.long, - device=y[0].device, - ), + series_processors: tuple[sp.SeriesEnsembleProcessor, ...] = () + if y is not None: + y: EnsembleTable = _to_ensemble_table(y, num_members, expand=True) + + series_processors = tuple( + module + for processor in (self.recipe.features, self.recipe.target) + for module in _iter_modules(processor) + if isinstance(module, sp.SeriesEnsembleProcessor) ) + for module in series_processors: + module._set_buffer( + name="_offset", + value=torch.zeros( + size=(len(y),), + dtype=torch.long, + device=y[0].device, + ), + ) - y = self.recipe.target.fit_transform_ensemble(y, generator=generator) - self._num_estimators = num_members - self._y_locations = y._locations + y = self.recipe.target.fit_transform_ensemble( + y, generator=generator + ) + self._y_locations = y._locations - task_dispatchers = tuple( - module - for processor in (self.recipe.features, self.recipe.output) - for module in _iter_modules(processor) - if isinstance(module, sp.TaskDispatch) - ) - if len(task_dispatchers) > 0: - if any(group.size(-1) != 1 for group in y._iter_groups()): - raise ValueError( - "Expected the transformed target to contain exactly one " - "column" - ) + task_dispatchers = tuple( + module + for processor in (self.recipe.features, self.recipe.output) + for module in _iter_modules(processor) + if isinstance(module, sp.TaskDispatch) + ) + if len(task_dispatchers) > 0: + if any(group.size(-1) != 1 for group in y._iter_groups()): + raise ValueError( + "Expected the transformed target to contain exactly " + "one column" + ) - if all( - group.numerical.size(-1) == 1 for group in y._iter_groups() - ): - task = "regression" - elif all( - group.categorical.size(-1) == 1 for group in y._iter_groups() - ): - task = "classification" - else: - raise ValueError( - "'Recipe.target' must resolve to a single task type" - ) + if all( + group.numerical.size(-1) == 1 for group in y._iter_groups() + ): + task = "regression" + elif all( + group.categorical.size(-1) == 1 + for group in y._iter_groups() + ): + task = "classification" + else: + raise ValueError( + "'Recipe.target' must resolve to a single task type" + ) - for task_dispatcher in task_dispatchers: - task_dispatcher._task = task + for task_dispatcher in task_dispatchers: + task_dispatcher._task = task self._related_processors = None self._related_locations = None @@ -135,11 +165,6 @@ def fit_transform( ensemble_table, generator=generator, ) - if len(related_ensembles[name]) != self.num_members: - raise ValueError( - "Expected inputs to map to the same number of " - "ensemble members" - ) for module in _iter_modules(processor): if isinstance(module, sp.SeriesEnsembleProcessor): module._set_buffer( @@ -160,6 +185,7 @@ def fit_transform( x_context_only: EnsembleTable | None = None if context_only_columns: + assert y is not None if not isinstance(x, TableTensor): x = TableTensor.from_tensor(cast(Tensor, x)) context_only_columns = [str(col) for col in context_only_columns] @@ -170,11 +196,6 @@ def fit_transform( ), generator=generator, ) - if len(x_context_only) != self.num_members: - raise ValueError( - "Expected inputs to map to the same number of " - "ensemble members" - ) x = x.drop_columns(context_only_columns) inputs = _to_ensemble_table(x, num_members) @@ -182,10 +203,6 @@ def fit_transform( inputs, generator=generator, ) - if len(x) != self.num_members: - raise ValueError( - "Expected inputs to map to the same number of ensemble members" - ) for module in series_processors: module._set_buffer( @@ -197,8 +214,8 @@ def fit_transform( ), ) - members: list[MemberContext] = [] - for member_id in range(self.num_members): + members: list[MemberContext | MemberQuery] = [] + for member_id in range(len(x)): related_tables_i: RelatedTables[TableTensor] | None = None if related_tables is not None: related_tables_i = RelatedTables( @@ -209,8 +226,8 @@ def fit_transform( relationships=related_tables.relationships, task_links=related_tables.task_links, ) - members.append( - MemberContext( + if y is not None: + member = MemberContext( x=x[member_id], y=y[member_id], x_context_only=x_context_only[member_id] @@ -219,7 +236,12 @@ def fit_transform( related_tables=related_tables_i, input_stypes=inputs[member_id].stypes, ) - ) + else: + member = MemberQuery( + x=x[member_id], + related_tables=related_tables_i, + ) + members.append(member) return tuple(members) @@ -231,10 +253,6 @@ def transform( """Transform query data.""" x: EnsembleTable = _to_ensemble_table(x, self._num_estimators) x = self.recipe.features.transform_ensemble(x) - if len(x) != self.num_members: - raise ValueError( - "Expected inputs to map to the same number of ensemble members" - ) related_ensembles: Mapping[str, EnsembleTable] = {} if related_tables is not None: @@ -252,14 +270,9 @@ def transform( related_ensembles[name] = processor.transform_ensemble( ensemble_table ) - if len(related_ensembles[name]) != self.num_members: - raise ValueError( - "Expected inputs to map to the same number of " - "ensemble members" - ) members: list[MemberQuery] = [] - for member_id in range(self.num_members): + for member_id in range(len(x)): related_tables_i: RelatedTables[TableTensor] | None = None if related_tables is not None: related_tables_i = RelatedTables( From 1feafb1d0cffcae665710ae0d25be241e36ffbb1 Mon Sep 17 00:00:00 2001 From: rusty1s Date: Thu, 22 Oct 2026 08:39:35 +0000 Subject: [PATCH 8/9] update --- sdm/models/kumo/timeseries/anomaly/model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdm/models/kumo/timeseries/anomaly/model.py b/sdm/models/kumo/timeseries/anomaly/model.py index 20a094b277..15b445e7ca 100644 --- a/sdm/models/kumo/timeseries/anomaly/model.py +++ b/sdm/models/kumo/timeseries/anomaly/model.py @@ -152,14 +152,14 @@ def forward( generator=generator, ) - for callback in callbacks: - out = callback.on_model_forward_end(self, out) - out = TableTensor( columns={Stype.numerical: ("mae",)}, numerical=out.to(query.x.numerical), ) + for callback in callbacks: + out = callback.on_model_forward_end(self, out) + with ( torch.amp.autocast(x.device.type, enabled=False), inference_mode("grad" if requires_grad else "inference"), From 6f18bb41d66fbc1b381319ebd5181e7b49df62cc Mon Sep 17 00:00:00 2001 From: rusty1s Date: Thu, 22 Oct 2026 08:42:04 +0000 Subject: [PATCH 9/9] update --- sdm/processing/numerical/pca.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdm/processing/numerical/pca.py b/sdm/processing/numerical/pca.py index c1e37e9ee7..fee8df3510 100644 --- a/sdm/processing/numerical/pca.py +++ b/sdm/processing/numerical/pca.py @@ -63,3 +63,9 @@ def _transform(self, table: TableTensor) -> TableTensor: TableTensor, torch.cat([table.drop_stypes(Stype.numerical), out], dim=-1), ) + + def __repr__(self, *, indent: int = 0) -> str: + return ( + f"{' ' * indent}{self.__class__.__name__}(" + f"num_components={self.num_components})" + )