Skip to content

Commit dfe68af

Browse files
authored
ENH PERF Speed up unique counts for strings, thereby speeding up encoding (scikit-learn#34386)
1 parent add0e83 commit dfe68af

4 files changed

Lines changed: 57 additions & 40 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- Improved fit time of :class:`preprocessing.OneHotEncoder`
2+
and :class:`preprocessing.OrdinalEncoder` on object/string categorical
3+
features when category counts are needed, for instance with ``min_frequency``
4+
or ``max_categories``.
5+
By :user:`Itamar Turner-Trauring <itamarst>`.

sklearn/utils/_encode.py

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
# SPDX-License-Identifier: BSD-3-Clause
33

44
from collections import Counter
5-
from contextlib import suppress
5+
from collections.abc import Iterable
6+
from numbers import Real
67
from typing import NamedTuple
78

89
import numpy as np
@@ -96,6 +97,9 @@ class MissingValues(NamedTuple):
9697

9798
nan: bool
9899
none: bool
100+
# All the different nan instances seen, useful because they end up merged
101+
# into just np.nan:
102+
all_nans: Iterable[Real] = (np.nan,)
99103

100104
def to_list(self):
101105
"""Convert tuple to a list where None is always first."""
@@ -130,15 +134,20 @@ def _extract_missing(values):
130134
if not missing_values_set:
131135
return values, MissingValues(nan=False, none=False)
132136

137+
all_nans = missing_values_set - {None}
133138
if None in missing_values_set:
134139
if len(missing_values_set) == 1:
135-
output_missing_values = MissingValues(nan=False, none=True)
140+
output_missing_values = MissingValues(
141+
nan=False, none=True, all_nans=all_nans
142+
)
136143
else:
137144
# If there is more than one missing value, then it has to be
138145
# float('nan') or np.nan
139-
output_missing_values = MissingValues(nan=True, none=True)
146+
output_missing_values = MissingValues(
147+
nan=True, none=True, all_nans=all_nans
148+
)
140149
else:
141-
output_missing_values = MissingValues(nan=True, none=False)
150+
output_missing_values = MissingValues(nan=True, none=False, all_nans=all_nans)
142151

143152
# create set without the missing values
144153
output = values - missing_values_set
@@ -189,7 +198,7 @@ def _unique_python(values, *, return_inverse, return_counts):
189198
ret += (_map_to_integer(values, uniques),)
190199

191200
if return_counts:
192-
ret += (_get_counts(values, uniques),)
201+
ret += (_get_counts(values, uniques, missing_values.all_nans),)
193202

194203
return ret[0] if len(ret) == 1 else ret
195204

@@ -321,40 +330,21 @@ def is_valid(value):
321330
return diff
322331

323332

324-
class _NaNCounter(Counter):
325-
"""Counter with support for nan values."""
326-
327-
def __init__(self, items):
328-
super().__init__(self._generate_items(items))
329-
330-
def _generate_items(self, items):
331-
"""Generate items without nans. Stores the nan counts separately."""
332-
for item in items:
333-
if not is_scalar_nan(item):
334-
yield item
335-
continue
336-
if not hasattr(self, "nan_count"):
337-
self.nan_count = 0
338-
self.nan_count += 1
339-
340-
def __missing__(self, key):
341-
if hasattr(self, "nan_count") and is_scalar_nan(key):
342-
return self.nan_count
343-
raise KeyError(key)
344-
345-
346-
def _get_counts(values, uniques):
333+
def _get_counts(values, uniques, nan_values=(np.nan,)):
347334
"""Get the count of each of the `uniques` in `values`.
348335
349-
The counts will use the order passed in by `uniques`. For non-object dtypes,
350-
`uniques` is assumed to be sorted and `np.nan` is at the end.
336+
The counts will use the order passed in by `uniques`. `np.nan` is assumed
337+
to be the last item in `uniques`, if it was one of the values. For
338+
non-object dtypes, `uniques` is assumed to be sorted.
351339
"""
352340
if values.dtype.kind in "OU":
353-
counter = _NaNCounter(values)
341+
counter = Counter(values)
354342
output = np.zeros(len(uniques), dtype=np.int64)
355343
for i, item in enumerate(uniques):
356-
with suppress(KeyError):
357-
output[i] = counter[item]
344+
output[i] = counter[item]
345+
if len(uniques) > 0 and is_scalar_nan(uniques[-1]):
346+
# Should be the sum of all nans:
347+
output[-1] = sum(counter[nan] for nan in nan_values)
358348
return output
359349

360350
unique_values, counts = _unique_np(values, return_counts=True)

sklearn/utils/_missing.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Authors: The scikit-learn developers
22
# SPDX-License-Identifier: BSD-3-Clause
33

4-
import math
5-
import numbers
64
from contextlib import suppress
5+
from math import isnan
6+
from numbers import Real
77

88

99
def is_scalar_nan(x):
@@ -37,11 +37,7 @@ def is_scalar_nan(x):
3737
>>> is_scalar_nan([np.nan])
3838
False
3939
"""
40-
return (
41-
not isinstance(x, numbers.Integral)
42-
and isinstance(x, numbers.Real)
43-
and math.isnan(x)
44-
)
40+
return isinstance(x, Real) and isnan(x)
4541

4642

4743
def is_pandas_na(x):

sklearn/utils/tests/test_encode.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ def test_check_unknown_with_both_missing_values():
233233
assert_array_equal(valid_mask, [False, True, True, True, False, False, False])
234234

235235

236+
NAN1 = float("nan")
237+
NAN2 = float("nan")
238+
239+
236240
@pytest.mark.parametrize(
237241
"values, uniques, expected_counts",
238242
[
@@ -272,3 +276,25 @@ def test_check_unknown_with_both_missing_values():
272276
def test_get_counts(values, uniques, expected_counts):
273277
counts = _get_counts(values, uniques)
274278
assert_array_equal(counts, expected_counts)
279+
280+
281+
def test_get_counts_multiple_nans():
282+
"""
283+
When both np.nan and float("nan") are present, they get merged into np.nan.
284+
"""
285+
values = np.array(
286+
["a", np.nan, NAN1, np.nan, NAN2, NAN1, np.nan, "a"],
287+
dtype=object,
288+
)
289+
uniques = np.array(["a", np.nan], dtype=object)
290+
expected_counts = [2, 6]
291+
assert_array_equal(
292+
_get_counts(values, uniques, [np.nan, NAN1, NAN2]), expected_counts
293+
)
294+
295+
# Now try it via _unique, to make sure this works end-to-end:
296+
real_uniques, real_counts = _unique(values, return_counts=True)
297+
# Comparing two arrays with nan fails cause the nans are not equal to
298+
# themselves. So compare as Python lists:
299+
assert list(uniques) == list(real_uniques)
300+
assert_array_equal(real_counts, expected_counts)

0 commit comments

Comments
 (0)