Skip to content

feat: add nw.factorize - #3809

Open
camriddell wants to merge 13 commits into
narwhals-dev:mainfrom
camriddell:feat-factorize
Open

feat: add nw.factorize#3809
camriddell wants to merge 13 commits into
narwhals-dev:mainfrom
camriddell:feat-factorize

Conversation

@camriddell

Copy link
Copy Markdown
Member

factorize produces unique values and value mappings back to the locations of those values. Inspired by pandas.factorize

Implementation Details

Value Semantics

  • uniques is the set of distinct non-null values.
  • codes[i] is the index into uniques for values[i].
  • Any value not present in uniques maps to -1 (currently only nulls).

Missing Value Semantics:

  • Nulls are dropped from the unique factorized values and the corresponding mapping code is -1.
  • NaNs are preserved (treated as actual values). Except for within pandas where it cannot distinguish between Null/NaN.

To Do/Discuss

Pure Narwhals implementation: is it tolerable to only have this implemented at the narwhals.functions level and NOT within each individual backend? This function can be composed purely of pieces that already exist

Missing Values:

  • Implement the use_na_sentinel from pandas.factorize?
    • for this route, I'd prefer to add a different keyword argument to convey "treat Null as an ordinary value" (e.g. drop_nulls or null_as_value)
  • Is -1 an appropriate "missing" mapping value? Currently when one factorizes a Series with a Null (or NaN in pandas), the Null is automatically dropped from the unique value set and the corresponding mapping code becomes -1.

Description

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

AI assistance

  • No AI tools were used for this PR.
  • AI tools were used.

Checklist

  • Code follows style guide (ruff)

  • Tests added

  • Documented the changes

  • If this is your first PR to narwhals, attach a screenshot of pytest passing locally (not CI):

    PYTEST_ADDOPTS="--numprocesses=logical" \
    make run-ci DEPS="--extra pandas --extra dask --group core-tests --group sklearn --group plugins" \
    CMD="pytest tests --cov=src --cov=tests --runslow --constructors=pandas,pandas[nullable],pandas[pyarrow],pyarrow,polars[eager],polars[lazy],dask,duckdb,sqlframe"

factorize produces unique values and value mappings back to the
locations of those values.
@FBruzzesi FBruzzesi added the enhancement New feature or request label Jul 21, 2026
@cakedev0

Copy link
Copy Markdown

Will this call pd.Series.factorize for pandas Series? Asking because it's much much faster than unique + replace_strict (almost 10x fast for categorical dtype).

Is -1 an appropriate "missing" mapping value?

Given "codes" are supposed to be non-negative integers, I'd say this is good. Or at least, it's much better than other options that comes to my mind: NaN, MaxInt32, n_uniques + 1, ...?

@FBruzzesi FBruzzesi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @camriddell - I left a couple of inline comments.

On top of those:

  1. I think the concern from @cakedev0 on performances is totally valid
  2. Is there a reason to not expose the feature in stable.v1?
  3. I didn't check the tests 🙈

Comment thread src/narwhals/functions.py Outdated
Comment thread src/narwhals/functions.py Outdated
- `nw.factorize` -> `nw.Series.factorize`
- factorize code now lives within each backend, allowing for fastpaths
  like pandas.factorize instead of the generic unique -> replace_strict
  logic
- added v1 test
@camriddell

Copy link
Copy Markdown
Member Author
  • Moves nw.factorize to a Series method: nw.Series.factorize
  • Moves the function logic to each respective backend, so that backend specific optimizations can be applied (e.g. use pandas.factorize for pandas; possible future Polars feat: Add pl.Expr.unique_id pola-rs/polars#27045)

@FBruzzesi FBruzzesi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again @camriddell 🙏🏼

Left a few inline suggestions. On top of those:

  1. I like the possibility in pyarrow to treat null as any other value:

    • dictionary_encode(array, null_encoding='mask') -> keeps null's in the indices
    • dictionary_encode(array, null_encoding='encode') -> encodes null values as any other value, not with a sentinel value.
      I think this is preferable, a user can always control the sentinel value to use by null_encoding='mask' + fill_null(<whatever value they want>).
      On the variable name (null_encoding), and its values ("mask" and "encode"), we can discuss better options
  2. As of #3809 (comment), I understand the appeal of being able to do uniques[codes] == values. However, since this is greenfield and very unlikely that polars will have the same method, I would like to have some creative freedom and propose a way to satisfy both returning uniques and a mapping.
    We can return a tuple of (codes, uniques), however, we can do so in a NamedTuple, so that we can enrich its behavior (draft version):

    from narwhals import Series
    from typing import Any, NamedTuple, Generic
    from collections.abc import Mapping
    
    from narwhals.typing import IntoSeriesT
    
    
    class Encoded(NamedTuple, Generic[IntoSeriesT]):
        """Result of `factorize`. Unpacks as `(codes, uniques)` like pandas."""
     
        codes: Series[IntoSeriesT]
        uniques: Series[IntoSeriesT]
     
        @property
        def mapping(self) -> Mapping[Any, int]:
            """Forward map as a joinable ``(value, code)`` frame; works for any dtype."""
            name = self.uniques.name
            return dict(
                self.uniques
                .to_frame()
                .with_row_index("code")
                .select(name, "code")
                .iter_rows()
            )

    In this way, one can both:

    codes, uniques = series.factorize()
    
    encoded = series.factorize()
    encoded.mapping

    Let me know your thoughts - In general I am not the biggest fan of NamedTuple's for various reasons, yet this seem like a good case to have one ;)

Comment thread src/narwhals/_arrow/series.py Outdated
Comment thread src/narwhals/_pandas_like/series.py Outdated
Comment thread src/narwhals/_polars/series.py Outdated
@camriddell

Copy link
Copy Markdown
Member Author

@FBruzzesi I think that wraps up all of your requests.

Seems like I'll need to add a back up path for an old pandas version, then we should hopefully be squared away.

@FBruzzesi FBruzzesi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @camriddell - I did a quick iteration for the top-level behavior, I will wait for the CI to be green for all the nitty gritty details

Comment thread src/narwhals/series.py
Comment on lines +3025 to +3034
@property
def mapping(self) -> Mapping[Any, int]:
"""Forward map as a joinable ``(value, code)`` frame; works for any dtype."""
name = self.uniques.name
return dict(
self.uniques.to_frame()
.with_row_index("code")
.select(name, "code")
.iter_rows()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am basically self-reviewing this bit - I ended up suggesting:

  • A wrong docstring (from another version I was writing)
  • possibly overlapping name ("code")
Suggested change
@property
def mapping(self) -> Mapping[Any, int]:
"""Forward map as a joinable ``(value, code)`` frame; works for any dtype."""
name = self.uniques.name
return dict(
self.uniques.to_frame()
.with_row_index("code")
.select(name, "code")
.iter_rows()
)
@property
def mapping(self) -> Mapping[Any, int]:
"""Forward map `value -> code` as a dict (handy for small cardinality)."""
uniques = self.uniques
return dict(zip(uniques, range(len(self.uniques)), strict=True))

Comment thread src/narwhals/series.py
Comment on lines +2918 to +2923
null_as_value: Whether to treat null as a regular value. When False,
nulls are removed from the returned unique values and the code -1 is
used to indicate the location of null values in the original array.
When True, nulls are preserved in the returned unique values and a
positive integer is used to indicate their location in the original
array.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still hold against having a sentinel value unless provided. I understand the ergonomic, but in polars spirit, I think there should be a way to preserve null values. null_as_value is probably not a good name for it, but we could enrich what pyarrow is doing:

  • "mask" -> preserve null values -> I would like this to be the default behavior
  • "encode" -> same as null_as_value = True i.e. null's get encoded as any other value
  • <value> -> encode with such sentinel value, equivalent to
     codes, _ = series.factorize("mask")
     codes.fill_null(<value)>

I am curious to know how others feel about this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond ergonomics, preserving the null value in the returned codes breaks base pandas as the returned NaN upcasts the integer codes to become floats. I'd prefer not to have the default case create a surprise for the backend where this function is likely to be used most often.

I am fine with the remaining semantics, just would advocate to make the "encode" behavior the default.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond ergonomics, preserving the null value in the returned codes breaks ... as the returned NaN upcasts the integer codes to become floats.

Is it not possible to use pd.Int64Dtype?

I'd prefer not to have the default case create a surprise for the backend where this function is likely to be used most often.

I understand this concern, but I agree with @FBruzzesi that the default behavior should feel like polars.

Third option

What if we held back on nw.Series.factorize for now, and instead introduced how to do it into our docs?

This feels pretty related to discord thread and would slide in perfectly into a whole section about transitioning from pd.Categorical 🙂

@camriddell, correct me if I'm wrong, but that feels like something you'd have a lot to speak on 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not possible to use pd.Int64Dtype?

@dangotbanned yeah we can return an Int64 dtype. Originally I had thought that we supported a pandas version that didn't have nullable types but I am no longer certain that is the case.

What if we held back on nw.Series.factorize for now, and instead introduced how to do it into our docs?

I am biased as I have already sunk a few hours into this, but I would like to see it land. This is also a fairly common operation for our array-based libraries downstream users, and the backend implementations are all quite different from one another.

@FBruzzesi & @dangotbanned how do you feel about this as an API? I would love it if the SENTINEL policy was its own struct, so one could embed the sentinel value inside of it but we're using the wrong language for that.

class NullEncoding(StrEnum):
    PRESERVE = auto() # keep the Nulls in the resultant codes
    ENCODE = auto()   # encode Nulls as one would like any other value
    SENTINEL = auto() # replace Nulls with a particular `sentinel` value

class Series:
    def factorize(
        self, *, sort: bool = False, null_policy: NullEncoding = NullEncoding.PRESERVE, sentinel: Any | NoDefault = NO_DEFAULT,
    ) -> Encoded[IntoSeriesT]:
        if null_policy is NullEncoding.SENTINEL:
            if sentinel is NO_DEFAULT:
                msg = "Must supply `sentinel` when null_policy=NullEncoding.SENTINEL"
                raise TypeError(msg)
        elif sentinel is not NO_DEFAULT:
            msg = f"Argument `sentinel` is ignored when null_policy={null_policy}"
            raise TypeError(msg)

        ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @camriddell sorry for the late feedback, I completely lost this thread!

I like having null_policy (and such name) as well as using sentinel. I would disagree on creating a custom Enum to expose to the user. I think Literal[...] is just fine.

On the larger picture of having the feature vs documenting it, I would be in favor of having it with the unstable tag. As Cam mentioned, this topic came up quite a lot recently but not only, and there is real need for it.

On the Int64 dtype for pandas: I am quite ok with that, keep in mind that one day pyarrow might be a default/requirement if I remember correctly. I am sure Marco knows much more about it.

Comment thread src/narwhals/series.py
Comment on lines +2928 to +2929
of the corresponding value in `uniques`. Null values are encoded
as -1.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of sync with the new behaviour

@dangotbanned dangotbanned left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @camriddell, hope some of this can be helpful

Comment thread src/narwhals/series.py
result = result.rename(orig_name) if name_is_none else result
return cast("Self", result)

def factorize(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please mark this as unstable?

Comment thread src/narwhals/series.py
Comment on lines +3014 to +3015
@dataclass(frozen=True)
class Encoded(Generic[IntoSeriesT]):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also be marked as unstable and/or private please?

It is mostly an implementation detail

Comment thread tests/v2_test.py
assert schema._version is Version.V2


@pytest.mark.parametrize(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the other comments, if factorize is unstable, would we still have these v1, v2 tests?

Comment on lines +1069 to +1090
null_encoding: Literal["encode", "mask"] = "encode" if null_as_value else "mask"
encoded = pc.dictionary_encode(
native, null_encoding=null_encoding
).unify_dictionaries()
uniques = encoded.chunk(0).dictionary # type: ignore[attr-defined]
codes = pa.chunked_array([c.indices for c in encoded.chunks], type=pa.int32()) # type: ignore[attr-defined]
codes = cast("pa.ChunkedArray[pa.Int32Scalar]", codes)

if not sort:
return (
self._with_native(pc.fill_null(codes, pa.scalar(-1))),
self._with_native(uniques),
)

sorted_uniques = pc.take(uniques, pc.sort_indices(uniques))
new_mapping = pc.index_in(uniques, value_set=sorted_uniques)
new_codes = pc.take(new_mapping, codes)

return (
self._with_native(pc.fill_null(new_codes, pa.scalar(-1))),
self._with_native(sorted_uniques),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As somebody who has battled against pyarrow's frustrating API a lot - I feel like this is doing too many steps 🤔

I'm not 100% sure what is going on here, so I don't a concrete suggestion.

But I do have some code that may be helpful to look at. If only inspire an oh that's how these bits could fit together moment 😉

Show (maybe related) pyarrow functions

Definitions

Some usage

def with_row_index_by(
self,
name: str,
order_by: Sequence[str],
*,
descending: bool = False,
nulls_last: bool = False,
) -> Self:
indices = fn.sort_indices(
self.native, *order_by, nulls_last=nulls_last, descending=descending
)
column = fn.unsort_indices(indices)
return self._with_native(self.native.add_column(0, name, column))

def mode_all(native: ChunkedOrArrayAny) -> ChunkedArrayAny:
"""Compute the most occurring value(s) and return *all* of them."""
struct_arr = pc.mode(native, n=len(native))
indices = cat.encode(struct_arr.field("count"))
index_true_modes = lit(0)
return chunked_array(
struct_arr.field("mode").filter(pc.equal(indices, index_true_modes))
)

def sort(self, *, descending: bool = False, nulls_last: bool = False) -> Self:
opts = options.array_sort(descending=descending, nulls_last=nulls_last)
indices = pc.array_sort_indices(self.native, options=opts)
return self._with_native(self._gather(indices))
def scatter(self, indices: Self, values: Self) -> Self:
mask = fn.is_in(fn.int_range(len(self), chunked=False), indices.native)
replacements = values._gather(pc.sort_indices(indices.native))
return self._with_native(fn.replace_with_mask(self.native, mask, replacements))

# (2.3): The cursed box 😨
if builtins.len(replacements) != builtins.len(lists):
# This is a very unlucky case to hit, because we *can* detect the issue earlier
# but we *can't* join a table with a list in it. So we deal with the fallout now ...
# The end result is identical to (2.1)
indices_all = to_table(explode_w_idx.column(idx).unique(), idx)
indices_repaired = implode_by_idx.set_column(1, v, replacements)
replacements = (
indices_all.join(indices_repaired, idx)
.sort_by(idx)
.column(v)
.fill_null(lit(EMPTY, lists.type.value_type))
)
return replace_with_mask(result, is_null_sensitive, replacements)

@camriddell camriddell Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested pc.dictionary_encoding vs uniques -> replace and found that the former was faster at large dataset sizes.

Timing code
import pyarrow as pa
import pyarrow.compute as pc

def factorize_via_replace(arr, *, sort=False):
    uniques = pc.unique(arr)
    if sort:
        uniques = pc.take(uniques, pc.sort_indices(uniques))

    return pc.index_in(arr, value_set=uniques), uniques

def factorize_via_dictionary(arr, sort=False):
    encoded = pc.dictionary_encode(arr, null_encoding='mask').combine_chunks()
    uniques = encoded.dictionary
    codes = pa.chunked_array(encoded.indices)

    if not sort:
        return codes, uniques

    sorted_uniques = pc.take(uniques, pc.sort_indices(uniques))
    new_mapping = pc.index_in(uniques, value_set=sorted_uniques)
    new_codes = pc.take(new_mapping, codes)

    return new_codes, sorted_uniques


import numpy as np
from numpy.random import default_rng
from string import ascii_lowercase
from timeit import timeit
from itertools import product

rng = default_rng(0)
n = 10

for sz, n_chunks, sort in product([10_000, 1_000_000], [5, 100, 10_000], [True, False]):
    n_chunks = min(n_chunks, sz)

    arr = rng.choice([*ascii_lowercase], size=(sz, 4)).view('<U4').ravel()
    arr = pa.chunked_array(np.array_split(arr, n_chunks), type=pa.string())

    c1, u1 = factorize_via_replace(arr, sort=sort)
    c2, u2 = factorize_via_dictionary(arr, sort=sort)
    assert u1.equals(u2)
    assert c1.equals(c2)

    print(f'N={sz:<10,}| Chunks={n_chunks:<10,}| sort={sort!r:<5}| Unique={len(u1):,}')
    print(f"{timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = :.6f}")
    print(f"{timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = :.6f}")
    print()
Timing results, stepping through dictionary_encoding is *generally* faster, but not in every scenario. ~2× faster for the largest array size (1M values, 10k chunks) when `sort=False`.
N=10,000    | Chunks=5         | sort=True | Unique=9,874
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.021563
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.028813

N=10,000    | Chunks=5         | sort=False| Unique=9,910
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.007005
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.003449

N=10,000    | Chunks=100       | sort=True | Unique=9,894
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.028647
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.029794

N=10,000    | Chunks=100       | sort=False| Unique=9,893
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.015438
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.006601

N=10,000    | Chunks=10,000    | sort=True | Unique=9,905
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.067098
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.241810

N=10,000    | Chunks=10,000    | sort=False| Unique=9,894
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 0.049481
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 0.222008

N=1,000,000 | Chunks=5         | sort=True | Unique=405,646
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 3.767795
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 3.885547

N=1,000,000 | Chunks=5         | sort=False| Unique=405,821
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 2.810773
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 1.740194

N=1,000,000 | Chunks=100       | sort=True | Unique=406,059
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 3.903205
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 3.496898

N=1,000,000 | Chunks=100       | sort=False| Unique=405,580
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 2.629999
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 1.398467

N=1,000,000 | Chunks=10,000    | sort=True | Unique=405,871
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 3.914798
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 3.816666

N=1,000,000 | Chunks=10,000    | sort=False| Unique=405,657
timeit(lambda: factorize_via_replace(arr, sort=sort), number=n) = 2.829571
timeit(lambda: factorize_via_dictionary(arr, sort=sort), number=n) = 1.563878

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(do note that in my timing I used combine_chunks() as that ended up being MUCH faster than the unify_dictionaries() approach. So I'll need to swap that out regardless.

Comment on lines +1065 to +1067
if pa.types.is_dictionary(native.type):
# re-encode if already dict encoded; can't be certain how the original dict encoding was done
native = native.cast(native.type.value_type)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you tell by the presence of nulls?

IIRC, one version keeps nulls in the indices 1.
If there aren't any nulls, then I would assume the kind of encoding doesn't matter

Footnotes

  1. It could also be the other property

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally had a version that dispatched based on the presense of nulls, but it felt a bit more like an implementation detail that produced a surprising behavior. Was planning to open an upstream issue.

>>> import pyarrow as pa
>>> arr = pa.array([*'ab', None])
>>> arr.dictionary_encode("encode").is_null()
<pyarrow.lib.BooleanArray object at 0x7f76b91b56c0>
[
  false,
  false,
  false
]
>>> arr.dictionary_encode("mask").is_null()
<pyarrow.lib.BooleanArray object at 0x7f76b91b5c00>
[
  false,
  false,
  true
]

Comment on lines +673 to +675
def factorize(
self, *, null_as_value: bool = False, sort: bool = False
) -> tuple[Self, Self]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is polars.Series.to_physical no good for this?
The example says:

Replicating the pandas pd.Series.factorize method

@camriddell camriddell Jul 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

factorize needs to guarantee that the returned unique values and the codes align. Since to_physical requires a Categorical/Enum input, the global categorical cache makes the physical codes surprising. However, we could reasonably make a fast path for an Enum type:

pl.Categorical
>>> s1 = pl.Series("X", [*"abc"]).cast(pl.Categorical)
>>> s2 = pl.Series("X", [*"abd"]).cast(pl.Categorical)
>>> s1.to_physical()
shape: (3,)
Series: 'X' [u32]
[
        0
        1
        2
]
>>> s2.to_physical()
shape: (3,)
Series: 'X' [u32]
[
        0
        1
        3
]
pl.Enum
>>> import polars as pl
>>> e1 = pl.Enum(['a', 'b', 'c'])
>>> e2 = pl.Enum(['a', 'b', 'd'])
>>> s1 = pl.Series("X", [*"abc"]).cast(e1)
>>> s2 = pl.Series("X", [*"abd"]).cast(e2)
>>> s1.to_physical()
shape: (3,)
Series: 'X' [u8]
[
        0
        1
        2
]
>>> s2.to_physical()
shape: (3,)
Series: 'X' [u8]
[
        0
        1
        2
]

Comment on lines +676 to +683
uniques = self.unique() if null_as_value else self.unique().drop_nulls()
if sort:
uniques = uniques.sort(descending=False, nulls_last=True)

if null_as_value:
codes = self.native.replace_strict(
old=uniques.native, new=range(len(uniques)), return_dtype=pl.Int32()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered starting this with a self.native.to_frame() and then doing all the steps with expressions?

Show pattern

This is based on the magic polars does for most Series methods:

def zfill(self, width: int) -> PolarsSeries:
name = self.name
ns = self.__narwhals_namespace__()
return self.to_frame().select(ns.col(name).str.zfill(width)).get_column(name)

Chaining Series methods has to do all these steps on every intermediate call. And this is just the python part 😅

For example, what if native was this?

import polars as pl

N = 10_000_000
N_UNIQUE = 100

native = (
    pl.select(big_boi=pl.int_range(N), eager=False)
    .gather((pl.col.big_boi % N_UNIQUE).shuffle(1))
    .collect()
    .to_series()
)

native
Show output

shape: (10_000_000,)
Series: 'big_boi' [i64]
[
	28
	88
	5
	1
	71
	…
	58
	1
	23
	92
	27
]

Would there be a benefit to not materializing until as late as possible?

uniques = native.unique().drop_nulls().sort(nulls_last=True)
codes = native.replace_strict(uniques, range(len(uniques)), return_dtype=pl.Int32()).cast(
    pl.Int32()
)

@camriddell camriddell Jul 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had intentionally re-used our PolarsSeries.sort as it implements a fallback for a previous version of Polars where the nulls_last argument did not exist and I didn't want to duplicate that code.

Would there be a benefit to not materializing until as late as possible?

Probably, but "as late as possible" comes pretty early as we must materialize before calling replace (otherwise range(len(uniques)) does not work). So pretty much by using expressions we can push the unique() and possibly drop_nulls & sort into the same operation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants