Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/api-reference/series.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- dtype
- ewm_mean
- exp
- factorize
- fill_nan
- fill_null
- filter
Expand Down
51 changes: 51 additions & 0 deletions src/narwhals/_arrow/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,57 @@ def hist_from_bin_count(
.to_frame()
)

def factorize(
self, *, null_as_value: bool = False, sort: bool = False
) -> tuple[Self, Self]:
if len(self.native) == 0:
codes = pa.chunked_array([[]], type=pa.int32())
uniques = pa.chunked_array([[]], type=self.native.type)
return (self._with_native(codes), self._with_native(uniques))

# https://github.com/apache/arrow/issues/33297; input pa.NullArray's don't dictionary_encode properly
if pa.types.is_null(self.native.type):
if null_as_value:
codes, uniques = (
pa.repeat(0, len(self.native)),
pa.nulls(1, type=self.native.type),
)
else:
codes, uniques = (
pa.repeat(-1, len(self.native)),
pa.nulls(0, type=self.native.type),
)

return (self._with_native(codes.cast(pa.int32())), self._with_native(uniques))

native = self.native
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)
Comment on lines +1065 to +1067

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
]


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),
)
Comment on lines +1069 to +1090

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.


def __iter__(self) -> Iterator[Any]:
for x in self.native:
yield maybe_extract_py_scalar(x, return_py_scalar=True)
Expand Down
3 changes: 3 additions & 0 deletions src/narwhals/_compliant/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ def arg_max(self) -> int: ...
def arg_min(self) -> int: ...
def arg_true(self) -> Self: ...
def count(self) -> int: ...
def factorize(
self, *, null_as_value: bool, sort: bool = False
) -> tuple[Self, Self]: ...
def filter(self, predicate: Any) -> Self: ...
def first(self) -> PythonLiteral: ...
def last(self) -> PythonLiteral: ...
Expand Down
28 changes: 28 additions & 0 deletions src/narwhals/_pandas_like/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,34 @@ def is_native_dtype_pyarrow(self, native_dtype: Any) -> bool:
impl = self._implementation
return get_dtype_backend(native_dtype, implementation=impl) == "pyarrow"

def factorize(
self, *, null_as_value: bool = False, sort: bool = False
) -> tuple[Self, Self]:
pdx = self.__native_namespace__()

# https://github.com/apache/arrow/issues/33297; input pa.NullArray's don't dictionary_encode properly
if self.native.dtype == "null[pyarrow]":
if null_as_value:
codes, uniques = (
pdx.Series(0, index=self.native.index),
pdx.Series([None], dtype=self.native.dtype),
)
else:
codes, uniques = (
pdx.Series(-1, index=self.native.index),
pdx.Series([], dtype=self.native.dtype),
)

return (self._with_native(codes), self._with_native(uniques))

codes, uniques = self.native.factorize(
sort=sort, use_na_sentinel=not null_as_value
)
return (
self._with_native(pdx.Series(codes)),
self._with_native(pdx.Series(uniques)),
)

def _apply_pyarrow_compute_func(
self, native: NativeSeriesT, pc_func: Callable[[ChunkedArrayAny], ChunkedArrayAny]
) -> NativeSeriesT:
Expand Down
21 changes: 21 additions & 0 deletions src/narwhals/_polars/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,27 @@ def last(self) -> PythonLiteral:
def any_value(self, *, ignore_nulls: bool) -> PythonLiteral:
return self.drop_nulls().first() if ignore_nulls else self.first()

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

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
]

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()
)
Comment on lines +676 to +683

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.

else:
codes = self.native.replace_strict(
old=uniques.native,
new=range(len(uniques)),
default=-1,
return_dtype=pl.Int32(),
)

return self._with_native(codes.cast(pl.Int32())), uniques

@property
def dt(self) -> PolarsSeriesDateTimeNamespace:
return PolarsSeriesDateTimeNamespace(self)
Expand Down
86 changes: 86 additions & 0 deletions src/narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import math
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload

Expand Down Expand Up @@ -2905,6 +2906,68 @@ def is_close(
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?

self, *, null_as_value: bool = False, sort: bool = False
) -> Encoded[IntoSeriesT]:
"""Encode values as integer codes and unique values.

The integer codes are index locations that map the unique values back to their
positions within the original array.

Arguments:
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.
Comment on lines +2918 to +2923

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.

sort: Whether to sort the unique values before assigning codes.

Returns:
codes: An integer series where each value represents the index
of the corresponding value in `uniques`. Null values are encoded
as -1.
Comment on lines +2928 to +2929

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

uniques: A series containing the unique non-null values.

Examples:
>>> import polars as pl
>>> import narwhals as nw
>>> df = pl.DataFrame({"groups": ["a", "b", "a", None]})
>>> nw_df = nw.from_native(df)
>>> codes, uniques = nw_df["groups"].factorize(sort=True)
>>> codes
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
| Narwhals Series |
|----------------------|
|shape: (4,) |
|Series: 'groups' [i32]|
|[ |
| 0 |
| 1 |
| 0 |
| -1 |
|] |
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
>>> uniques
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
| Narwhals Series |
|----------------------|
|shape: (2,) |
|Series: 'groups' [str]|
|[ |
| "a" |
| "b" |
|] |
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
"""
codes, uniques = self._compliant_series.factorize(
null_as_value=null_as_value, sort=sort
)
return Encoded(
self._with_compliant(codes).alias("codes"),
self._with_compliant(uniques).alias("uniques"),
)

@unstable
def any_value(self, *, ignore_nulls: bool = False) -> PythonLiteral:
"""Get a random value from the column.
Expand Down Expand Up @@ -2946,3 +3009,26 @@ def list(self) -> SeriesListNamespace[Self]:
@property
def struct(self) -> SeriesStructNamespace[Self]:
return SeriesStructNamespace(self)


@dataclass(frozen=True)
class Encoded(Generic[IntoSeriesT]):
Comment on lines +3014 to +3015

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

"""Result of `factorize`. Unpacks as `(codes, uniques)` like pandas."""

codes: Series[IntoSeriesT]
uniques: Series[IntoSeriesT]

def __iter__(self) -> Iterator[Series[IntoSeriesT]]:
yield self.codes
yield self.uniques

@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()
)
Comment on lines +3025 to +3034

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))

Loading
Loading