Skip to content

Commit a6903ff

Browse files
committed
Exclude NaN categories during alignment fitting
1 parent 75b4795 commit a6903ff

6 files changed

Lines changed: 16 additions & 77 deletions

File tree

‎sdm/processing/categorical/_categorical.py‎

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import os
5-
from collections.abc import Iterable, Sequence
6-
from typing import Any
75

8-
import torch
96
from torch import Tensor
107

118
from sdm import TableTensor
@@ -34,38 +31,6 @@ def _check_categorical_codes(table: TableTensor) -> None:
3431
)
3532

3633

37-
def _check_category_values(
38-
categories: Iterable[Tensor],
39-
columns: Sequence[str] | None = None,
40-
) -> None:
41-
"""Reject NaN values when fitting or loading categorical state."""
42-
if os.getenv("SDM_ENABLE_CATEGORY_CHECKS", "1") == "0":
43-
return
44-
for index, values in enumerate(categories):
45-
if not values.is_floating_point():
46-
continue
47-
if columns is None:
48-
message = f"Fitted category vocabulary {index} contains NaN."
49-
else:
50-
message = (
51-
f"Categorical column {columns[index]!r} contains NaN "
52-
"in its category vocabulary."
53-
)
54-
valid = values.isnan().any().logical_not()
55-
if torch.compiler.is_compiling():
56-
torch._assert_async(valid.to("cpu"), message)
57-
elif not valid:
58-
raise ValueError(message)
59-
60-
61-
class _CategoryList(BufferList[Tensor]):
62-
"""Category buffers validated after loading fitted state."""
63-
64-
def _load_from_state_dict(self, *args: Any, **kwargs: Any) -> None:
65-
super()._load_from_state_dict(*args, **kwargs)
66-
_check_category_values(self)
67-
68-
6934
def _check_categories(
7035
table: TableTensor,
7136
categories: BufferList[Tensor],

‎sdm/processing/categorical/align.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ def _fit_column(
130130
ordered_categories = input_categories
131131

132132
observed = counts.gather(1, order) >= self.min_frequency
133+
if input_categories.is_floating_point():
134+
observed &= ~input_categories.isnan()[order]
133135
# Only ragged vocabularies require per-batch materialization.
134136
fitted_categories = []
135137
for batch_index in range(batch_size):

‎sdm/processing/categorical/count.py‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
from torch import Tensor
88

99
from sdm import Stype, TableTensor
10+
from sdm.nn._buffer import BufferList
1011
from sdm.processing import Processor
1112
from sdm.processing.categorical._categorical import (
12-
_CategoryList,
1313
_check_categorical_codes,
1414
_check_categories,
15-
_check_category_values,
1615
)
1716
from sdm.tensor.categorical import _iter_categories
1817

@@ -27,7 +26,6 @@ class AddCategoryCounts(Processor):
2726
Transform inputs must use the fitted per-column category vocabularies.
2827
Use :class:`~sdm.processing.AlignCategories` before this processor when
2928
training and transform inputs were tensorized independently.
30-
Refit after changing category vocabularies; do not modify them in place.
3129
3230
Args:
3331
min_cardinality: A count column is added when its fitted vocabulary
@@ -45,7 +43,7 @@ def __init__(self, min_cardinality: int = 0) -> None:
4543
super().__init__()
4644
self.min_cardinality = min_cardinality
4745
self._columns: tuple[str, ...] = ()
48-
self._categories = _CategoryList()
46+
self._categories: BufferList[Tensor] = BufferList()
4947
self.register_buffer("_log_counts", torch.empty(0))
5048
self.register_buffer(
5149
"_num_categories",
@@ -66,10 +64,6 @@ def _fit(
6664
*,
6765
generator: torch.Generator | None = None,
6866
) -> None:
69-
_check_category_values(
70-
categories=_iter_categories(table.categorical),
71-
columns=table.categorical_columns,
72-
)
7367
_check_categorical_codes(table)
7468
categorical = table.categorical
7569
self._columns = tuple(
@@ -107,7 +101,7 @@ def _fit(
107101
value=counts.to(count_dtype).log1p().to(dtype),
108102
)
109103
self._set_buffer("_num_categories", num_categories)
110-
self._categories = _CategoryList(_iter_categories(table.categorical))
104+
self._categories = BufferList(_iter_categories(table.categorical))
111105

112106
def _transform(self, table: TableTensor) -> TableTensor:
113107
_check_categories(table, self._categories)

‎sdm/processing/categorical/impute.py‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@
44
import torch
55

66
from sdm import CategoricalTensor, Stype, TableTensor
7+
from sdm.nn._buffer import BufferList
78
from sdm.processing import Processor
89
from sdm.processing.categorical._categorical import (
9-
_CategoryList,
1010
_check_categorical_codes,
1111
_check_categories,
12-
_check_category_values,
1312
)
14-
from sdm.tensor.categorical import _iter_categories
1513

1614

1715
class ImputeMode(Processor):
@@ -25,7 +23,6 @@ class ImputeMode(Processor):
2523
Transform inputs must use the fitted per-column category vocabularies.
2624
Use :class:`~sdm.processing.AlignCategories` before this processor when
2725
training and transform inputs were tensorized independently.
28-
Refit after changing category vocabularies; do not modify them in place.
2926
"""
3027

3128
handles_stypes = (Stype.categorical,)
@@ -37,7 +34,7 @@ def __init__(self) -> None:
3734
super().__init__()
3835
# TODO: Add a separate processor that encodes missing values as their
3936
# own category instead of imputing an observed one.
40-
self._categories = _CategoryList()
37+
self._categories: BufferList[torch.Tensor] = BufferList()
4138
self.register_buffer(
4239
"_fill_values",
4340
torch.empty(0, dtype=torch.long),
@@ -49,10 +46,6 @@ def _fit(
4946
*,
5047
generator: torch.Generator | None = None,
5148
) -> None:
52-
_check_category_values(
53-
categories=_iter_categories(table.categorical),
54-
columns=table.categorical_columns,
55-
)
5649
data = table.categorical
5750
_check_categorical_codes(table)
5851
fill_values: list[torch.Tensor] = []
@@ -86,7 +79,7 @@ def _fit(
8679
if len(fill_values) > 0
8780
else torch.empty(0, dtype=torch.long, device=data.device),
8881
)
89-
self._categories = _CategoryList(table.categorical.categories)
82+
self._categories = BufferList(table.categorical.categories)
9083

9184
def _fit_transform(
9285
self,

‎test/processing/categorical/test_align.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,10 @@ def test_align_categories_numeric_values(device: torch.device) -> None:
295295

296296

297297
@withCUDA
298+
@pytest.mark.parametrize("sort_by", ["code", "frequency", "value"])
298299
def test_align_categories_does_not_match_nan_category_values(
299300
device: torch.device,
301+
sort_by: Literal["code", "frequency", "value"],
300302
) -> None:
301303
context = TableTensor(
302304
categorical=CategoricalTensor(
@@ -315,9 +317,13 @@ def test_align_categories_does_not_match_nan_category_values(
315317
),
316318
)
317319

318-
output = AlignCategories().fit(context).transform(query)
320+
processor = AlignCategories(sort_by=sort_by)
321+
aligned = processor.fit_transform(context)
322+
output = processor.transform(query)
319323

320-
assert output.categorical.code.squeeze(-1).tolist() == [-1, -1, -1]
324+
assert aligned.categorical.categories[0].tolist() == [1.0]
325+
assert aligned.categorical.code.squeeze(-1).tolist() == [-1, 0]
326+
assert output.categorical.code.squeeze(-1).tolist() == [-1, -1, 0]
321327

322328

323329
def test_align_categories_scales_to_large_numeric_vocabulary() -> None:

‎test/processing/categorical/test_impute.py‎

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,9 @@
66

77
import sdm.processing as sp
88
from sdm import CategoricalTensor, StringTensor, TableTensor
9-
from sdm.processing import Processor
109
from sdm.testing import withCUDA
1110

1211

13-
@withCUDA
14-
@pytest.mark.parametrize(
15-
"processor_type", [sp.AddCategoryCounts, sp.ImputeMode]
16-
)
17-
def test_float_category_state_roundtrip(
18-
device: torch.device,
19-
processor_type: type[Processor],
20-
) -> None:
21-
table = TableTensor(
22-
categorical=CategoricalTensor(
23-
code=torch.tensor([[0], [1], [0], [-1]], device=device),
24-
categories=(torch.tensor([1.0, 2.0], device=device),),
25-
),
26-
)
27-
processor = processor_type().fit(table)
28-
restored = processor_type().to(device=device)
29-
restored.load_state_dict(processor.state_dict())
30-
assert restored.transform(table).equal(processor.transform(table))
31-
32-
3312
def _table(
3413
values: list[list[int]] | list[list[list[int]]],
3514
*,

0 commit comments

Comments
 (0)