ENH PERF Speed up unique counts for strings, thereby speeding up encoding - #34386
Conversation
| ["a", "b", "c", "e"], | ||
| [16, 4, 20, 0], | ||
| ), | ||
| # Before #34385 was fixed, the result was [2, 6, 6, 6]. In practice, in |
There was a problem hiding this comment.
If the old behavior is desirable, it can easily be added back; as the comment says, I don't think it's particularly relevant since this code path is mostly just for strings.
There was a problem hiding this comment.
I think this new behaviors makes more sense. But it impacts _unique and there the behavior is not good anymore, indeed it breaks this invariant:remains true:
_, counts = _unique(values, return_counts=True)
assert len(values) == sum(counts)
You can add this check in test_unique_util_with_all_missing_values it will break with your changes but not in main.
Let's modify _unique_python so that this invariant is preserved. Codex suggested this fix:
191 - ret += (_get_counts(values, uniques),)
191 + counts = _get_counts(values, uniques)
192 + if missing_values.nan:
193 + counts[-1] = sum(is_scalar_nan(value) for value in values)
194 + ret += (counts,)
A bit brittle, but that works.
There was a problem hiding this comment.
- Why do you think it destroys the invariant? I am sleep deprived, so will think about this more, but at first glance it fixes the invariant, previously the invariant was not correct when there were multiple kinds of
nans, now it is correct. Nans do get counted in this PR. - That change will slow things down (it's
O(n)on values), if some change is necessary there are likely better ways to do it.
There was a problem hiding this comment.
To be more specific: previously the result in new test was [2, 6, 6, 6], and the sum of that is not len(values) == 8. Whereas now it is.
There was a problem hiding this comment.
Sorry this is messy (but not my fault, it's the sklearn code's fault 😜 ), it comes from the interaction between the _unique_python and _get_counts.
Just add those two lines at the end of test_unique_util_with_all_missing_values, run the test with pdb, and you'll get it I think:
_, counts = _unique(values, return_counts=True)
assert len(values) == sum(counts)That change will slow things down (it's O(n) on values)
Yes sorry this is quite ugly. I let codex write it without realizing it was bad, I'll try to propose something better.
|
Looks like another cool PR 😄 I'm going to take a look. |
|
A small focused benchmark shows that fit goes from from timeit import timeit
from sklearn.datasets import fetch_openml
from sklearn.preprocessing import OrdinalEncoder, OneHotEncoder
X, _ = fetch_openml(data_id=42165, as_frame=True, return_X_y=True)
X = X.loc[:, X.select_dtypes(include=["object", "string"]).columns]
one_hot = OneHotEncoder(handle_unknown="ignore", max_categories=10)
ordinal = OrdinalEncoder(max_categories=10)
for name, encoder in [("one hot", one_hot), ("ordinal", ordinal)]:
print(name, "fit", round(timeit(lambda : encoder.fit(X), number=100) * 10, 1), "ms")
encoder.fit(X)
print(name, "transform", round(timeit(lambda : encoder.transform(X), number=100) * 10, 1), "ms") |
shipitdev
left a comment
There was a problem hiding this comment.
This is an awesome speedup. I mostly read these PRs to learn, and seeing a massive performance bump just from swapping to a built-in Python collection is super cool.
I was looking at the test file diff where the output changed from [2, 6, 6, 6]. I did some digging and it looks like the old custom _NaNCounter explicitly grouped all NaNs together, whereas the native Counter treats separate float("nan") objects as distinct keys because of their memory addresses. Honestly, the new behavior feels more intuitive to me anyway since they are technically separate objects.
Awesome work man, and thanks for documenting the test changes so clearly!
There was a problem hiding this comment.
Overall LGTM and great speedup, let's just fix a small thing.
Also: can you add a changelog? Something like this I guess:
Improved the speed of :meth:`preprocessing.OneHotEncoder.fit`
and :meth:`preprocessing.OrdinalEncoder.fit` on object/string categorical
features when category counts are needed, for instance with `min_frequency`
or `max_categories`.
| ["a", "b", "c", "e"], | ||
| [16, 4, 20, 0], | ||
| ), | ||
| # Before #34385 was fixed, the result was [2, 6, 6, 6]. In practice, in |
There was a problem hiding this comment.
I think this new behaviors makes more sense. But it impacts _unique and there the behavior is not good anymore, indeed it breaks this invariant:remains true:
_, counts = _unique(values, return_counts=True)
assert len(values) == sum(counts)
You can add this check in test_unique_util_with_all_missing_values it will break with your changes but not in main.
Let's modify _unique_python so that this invariant is preserved. Codex suggested this fix:
191 - ret += (_get_counts(values, uniques),)
191 + counts = _get_counts(values, uniques)
192 + if missing_values.nan:
193 + counts[-1] = sum(is_scalar_nan(value) for value in values)
194 + ret += (counts,)
A bit brittle, but that works.
|
Ok I added a news file. See my inline replies in the thread to requested code change, it's not clear me to that a change is necessary there. |
|
4 separate commits later, the changelog entry is finally in (so sleep deprived); thanks for writing it, I wouldn't have been able to give a good description of the impact. I assume the Linter failure is something on |
cakedev0
left a comment
There was a problem hiding this comment.
Ok, I think I have a clear understanding on how to go with this now, see my suggestions.
Handling various nans makes things very painful 😭
|
OK, how's that? Fixes the nan merging issue, while not being |
cakedev0
left a comment
There was a problem hiding this comment.
A few nits about comments but otherwise LGTM
Nice solution for the "multi-nans" handling 👍
lorentzenchr
left a comment
There was a problem hiding this comment.
Looks like a nice improvement, even cleaner and less code.
ogrisel
left a comment
There was a problem hiding this comment.
Overall LGTM once the above suggestions have been addressed.
|
FYI, I tried #34386 (comment) on my Apple M4 laptop and I measure a 4x speed-up for both encoders. |
…' into 34385-unique-counts-object-dtype
|
OK, I think I addressed everything. |
ogrisel
left a comment
There was a problem hiding this comment.
Thank you very much. Much cleaner and more efficient code ;)
Fixes #34385
Instead of checking every value in the array for being a scalar,
_get_countsjust usesCounter's default logic, which seems to work just fine.I also optimized
is_scalar_nansince it is still called quite a lot, e.g. for every unique value, so relevant in cases where the number of unique values is the same as the number of values this can perhaps add up.Big picture benchmark
Before, using Python 3.14t (for the better concurrency) on https://gist.github.com/ogrisel/1b24301bfc90d61ab2138bb7fbf7f623:
After:
Microbenchmark of
is_scalar_nanBefore (3.14t):
After: