diff --git a/sdm/processing/__init__.py b/sdm/processing/__init__.py index 45a6d4a48..800b423ab 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 d8665649d..673cfff32 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 000000000..40a3ab6da --- /dev/null +++ b/sdm/processing/numerical/min_max_scale.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import math + +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 feature column by its minimum and range. + + Values within the fitted range map to ``[0, 1]``; values outside it are + not clipped. Constant columns use a unit scale to keep the transform + finite and invertible. NaN and infinite values are ignored when fitting + statistics and preserved during the transform. Columns without finite + fitted values transform to 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: + numerical = table.numerical + nonfinite = ~_isfinite(numerical) + lower = numerical.masked_fill(nonfinite, math.inf) + lower = lower.amin(dim=-2, keepdim=True) + upper = numerical.masked_fill(nonfinite, -math.inf) + upper = upper.amax(dim=-2, keepdim=True) + self._set_buffer("minimum", lower) + scale = torch.where(lower == upper, 1.0, upper - lower) + self._set_buffer("scale", scale) + + 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/test/processing/numerical/test_min_max_scale.py b/test/processing/numerical/test_min_max_scale.py new file mode 100644 index 000000000..1621c6869 --- /dev/null +++ b/test/processing/numerical/test_min_max_scale.py @@ -0,0 +1,128 @@ +# 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.processing import MinMaxScale +from sdm.testing import withCUDA + + +@withCUDA +def test_min_max_scale_maps_fitted_range_to_unit_interval( + device: torch.device, +) -> None: + inp = torch.tensor( + [[1.0, 5.0], [3.0, 5.0], [5.0, 5.0]], + dtype=torch.float64, + device=device, + ) + table = TableTensor.from_tensor(inp) + + processor = MinMaxScale().fit(table) + out = processor.transform(table) + + expected = torch.tensor( + [[0.0, 0.0], [0.5, 0.0], [1.0, 0.0]], + dtype=torch.float64, + device=device, + ) + torch.testing.assert_close(out.numerical, expected) + + +@withCUDA +def test_min_max_scale_does_not_clip_values_outside_the_fitted_table( + device: torch.device, +) -> None: + context = torch.tensor( + [[1.0, 5.0], [3.0, 5.0]], + dtype=torch.float64, + device=device, + ) + query = torch.tensor( + [[-1.0, 3.0], [7.0, 9.0]], + dtype=torch.float64, + device=device, + ) + + processor = MinMaxScale().fit(TableTensor.from_tensor(context)) + out = processor.transform(TableTensor.from_tensor(query)) + + expected = torch.tensor( + [[-1.0, -2.0], [3.0, 4.0]], + dtype=torch.float64, + device=device, + ) + torch.testing.assert_close(out.numerical, expected) + + +@withCUDA +def test_min_max_scale_preserves_nan_and_inf(device: torch.device) -> None: + inp = torch.tensor( + [ + [1.0, 5.0], + [2.0, float("nan")], + [3.0, 5.0], + [float("inf"), 5.0], + [-float("inf"), float("inf")], + ], + dtype=torch.float64, + device=device, + ) + + out = MinMaxScale().fit_transform(TableTensor.from_tensor(inp)) + + expected = torch.tensor( + [ + [0.0, 0.0], + [0.5, float("nan")], + [1.0, 0.0], + [float("inf"), 0.0], + [-float("inf"), float("inf")], + ], + dtype=torch.float64, + device=device, + ) + torch.testing.assert_close(out.numerical, expected, equal_nan=True) + + +@withCUDA +def test_min_max_scale_fits_leading_batches_independently( + device: torch.device, +) -> None: + context = torch.tensor( + [[[1.0], [3.0]], [[10.0], [14.0]]], + dtype=torch.float64, + device=device, + ) + query = torch.tensor( + [[[2.0]], [[12.0]]], + dtype=torch.float64, + device=device, + ) + + processor = MinMaxScale().fit(TableTensor.from_tensor(context)) + out = processor.transform(TableTensor.from_tensor(query)) + + expected = torch.full((2, 1, 1), 0.5, dtype=torch.float64, device=device) + torch.testing.assert_close(out.numerical, expected) + + +@withCUDA +def test_min_max_scale_all_nonfinite_fit_yields_nan( + device: torch.device, +) -> None: + fit = torch.tensor( + [[float("nan")], [float("inf")], [-float("inf")]], + dtype=torch.float64, + device=device, + ) + query = torch.tensor([[1.0], [2.0]], dtype=torch.float64, device=device) + + out = ( + MinMaxScale() + .fit(TableTensor.from_tensor(fit)) + .transform(TableTensor.from_tensor(query)) + ) + + assert out.numerical.isnan().all() diff --git a/test/processing/test_contract.py b/test/processing/test_contract.py index 7525b3b8c..8078d86d6 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)),