Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdm/processing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
RankGaussian,
Standardize,
RobustScale,
MinMaxScale,
FlipSign,
DropConstantColumns,
PCA,
Expand Down Expand Up @@ -83,6 +84,7 @@
"RankGaussian",
"Standardize",
"RobustScale",
"MinMaxScale",
"FlipSign",
"DropConstantColumns",
"PCA",
Expand Down
2 changes: 2 additions & 0 deletions sdm/processing/numerical/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@
"RankGaussian",
"Standardize",
"RobustScale",
"MinMaxScale",
"FlipSign",
"DropConstantColumns",
"PCA",
Expand Down
57 changes: 57 additions & 0 deletions sdm/processing/numerical/min_max_scale.py
Original file line number Diff line number Diff line change
@@ -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)
128 changes: 128 additions & 0 deletions test/processing/numerical/test_min_max_scale.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions test/processing/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading