Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
13 changes: 13 additions & 0 deletions src/narwhals/_arrow/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,19 @@ def hist_from_bin_count(
.to_frame()
)

def factorize(self, *, sort: bool = False) -> tuple[Self, Self]:
dtypes = self._version.dtypes
uniques = self.unique().drop_nulls()
if sort:
uniques = uniques.sort(descending=False, nulls_last=True)
codes = self.replace_strict(
old=uniques.to_list(),
new=[*range(len(uniques))],
default=-1,
return_dtype=dtypes.Int32(),
)
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.

return codes, uniques
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated

def __iter__(self) -> Iterator[Any]:
for x in self.native:
yield maybe_extract_py_scalar(x, return_py_scalar=True)
Expand Down
1 change: 1 addition & 0 deletions src/narwhals/_compliant/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def arg_max(self) -> int: ...
def arg_min(self) -> int: ...
def arg_true(self) -> Self: ...
def count(self) -> int: ...
def factorize(self, *, sort: bool = False) -> tuple[Self, Self]: ...
def filter(self, predicate: Any) -> Self: ...
def first(self) -> PythonLiteral: ...
def last(self) -> PythonLiteral: ...
Expand Down
9 changes: 9 additions & 0 deletions src/narwhals/_pandas_like/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,15 @@ 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, *, sort: bool = False) -> tuple[Self, Self]:
pdx = self.__native_namespace__()
name = self.native.name
codes, uniques = self.native.factorize(sort=sort)
return (
self._with_native(pdx.Series(codes, name=name)),
self._with_native(pdx.Series(uniques, name=name)),
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated
)

def _apply_pyarrow_compute_func(
self, native: NativeSeriesT, pc_func: Callable[[ChunkedArrayAny], ChunkedArrayAny]
) -> NativeSeriesT:
Expand Down
12 changes: 12 additions & 0 deletions src/narwhals/_polars/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,18 @@ 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, *, sort: bool = False) -> tuple[Self, Self]:
uniques = self.unique().drop_nulls()
if sort:
uniques = uniques.sort(descending=False, nulls_last=True)
codes = self.native.replace_strict(
old=uniques.to_list(),
new=[*range(len(uniques))],
Comment thread
FBruzzesi marked this conversation as resolved.
Outdated
default=-1,
return_dtype=pl.Int32(),
)
return self._with_native(codes.alias(self.name)), uniques

@property
def dt(self) -> PolarsSeriesDateTimeNamespace:
return PolarsSeriesDateTimeNamespace(self)
Expand Down
46 changes: 46 additions & 0 deletions src/narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,52 @@ def is_close(
result = result.rename(orig_name) if name_is_none else result
return cast("Self", result)

def factorize(self, *, sort: bool = False) -> tuple[Self, Self]:
"""Encode values as integer codes and unique values.

Arguments:
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.
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(sort=sort)
return self._with_compliant(codes), self._with_compliant(uniques)

@unstable
def any_value(self, *, ignore_nulls: bool = False) -> PythonLiteral:
"""Get a random value from the column.
Expand Down
124 changes: 124 additions & 0 deletions tests/series_only/factorize_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

from math import isnan
from typing import Any

import pytest

import narwhals as nw
from tests.utils import (
POLARS_VERSION,
ConstructorEager,
assert_equal_data,
assert_equal_series,
)

polars_lt_v1 = POLARS_VERSION < (1, 0, 0)
pl_skip_reason = "replace_strict only available after 1.0"


@pytest.mark.parametrize(
("values", "expected_n_unique"),
[
([], 0),
([*"abcabc"], 3),
([1, 2, 3, 2], 3),
([1.1, 2.2, 3.3, 2.2], 3),
([*"abc", None], 3),
([*"aaabbbccc", None], 3),
],
)
def test_factorize_invariants(
values: list[Any], expected_n_unique: int, constructor_eager: ConstructorEager
) -> None:
if "polars" in str(constructor_eager) and polars_lt_v1:
pytest.skip(reason=pl_skip_reason)

has_null = any(x is None for x in values)

df_native = constructor_eager({"a": values})
df = nw.from_native(df_native)
codes, uniqs = df["a"].factorize()

reconstructed_values = {"a": [uniqs[i] if i >= 0 else None for i in codes]}
assert_equal_data(df, reconstructed_values)
assert uniqs.dtype == df["a"].dtype
assert len(uniqs) == expected_n_unique

# codes should be integer, preserve length, and only contain -1 in the presence of nulls
assert codes.dtype.is_integer()
assert len(codes) == len(values)
assert (codes >= -1).all()
assert (codes == -1).any() == has_null

# Null values should always be dropped out from the unique returned values
assert not (uniqs.is_null().any())


@pytest.mark.parametrize(
("values", "expected_uniqs", "expected_codes"),
[
([], [], []),
([*"abc"], [*"abc"], [0, 1, 2]),
([*"abcabc"], [*"abc"], [0, 1, 2, 0, 1, 2]),
([*"aaabbbccc"], [*"abc"], [0, 0, 0, 1, 1, 1, 2, 2, 2]),
([*"abcabc", None], [*"abc"], [0, 1, 2, 0, 1, 2, -1]),
],
)
def test_factorize_sort(
values: list[Any],
expected_uniqs: list[Any],
expected_codes: list[int],
constructor_eager: ConstructorEager,
) -> None:
if "polars" in str(constructor_eager) and polars_lt_v1:
pytest.skip(reason=pl_skip_reason)

df_native = constructor_eager({"a": values})
df = nw.from_native(df_native)
codes, uniqs = df["a"].factorize(sort=True)

assert_equal_series(uniqs, expected_uniqs, name="a")
assert_equal_series(codes, expected_codes, name="a")


@pytest.mark.parametrize(
"values",
[
[1.1, 2.2, 1.1, float("nan")],
[1.1, 2.2, 1.1, float("nan"), float("nan")],
[1.1, 2.2, 1.1, None, float("nan")],
],
)
def test_factorize_nan_semantics(
values: list[float], constructor_eager: ConstructorEager
) -> None:
if "polars" in str(constructor_eager) and polars_lt_v1:
pytest.skip(reason=pl_skip_reason)

is_pandas_backend = any(x in str(constructor_eager) for x in ("pandas", "modin"))

df_native = constructor_eager({"a": values})
df = nw.from_native(df_native)
codes, uniqs = df["a"].factorize()

reconstructed_values = {"a": [uniqs[i] if i >= 0 else None for i in codes]}
assert_equal_data(df, reconstructed_values)

if is_pandas_backend:
# pandas treats NaN as missing, so NaN is not retained as a unique value.
assert len(uniqs) == 2
assert (codes == -1).any()
assert not uniqs.is_null().any()
else:
# Other backends treat NaN as a value, not as null.
assert len(uniqs) == 3

# The NaN should round-trip through codes -> uniques.
nan_index = (
i
for i, value in enumerate(values)
if isinstance(value, float) and isnan(value)
)
nan_codes = (codes[nan_i] for nan_i in nan_index)
assert all(isnan(uniqs[nan_c]) for nan_c in nan_codes)
30 changes: 30 additions & 0 deletions tests/v1_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1246,3 +1246,33 @@ def test_schema_from_generator() -> None:
)
assert schema == nw_v1.Schema({"a": nw_v1.Int64(), "b": nw_v1.String()})
assert schema._version is Version.V1


@pytest.mark.parametrize(
("values", "expected_uniqs", "expected_codes"),
[
([], [], []),
([*"abc"], [*"abc"], [0, 1, 2]),
([*"abcabc"], [*"abc"], [0, 1, 2, 0, 1, 2]),
([*"aaabbbccc"], [*"abc"], [0, 0, 0, 1, 1, 1, 2, 2, 2]),
([*"abcabc", None], [*"abc"], [0, 1, 2, 0, 1, 2, -1]),
],
)
def test_factorize(
values: list[Any],
expected_uniqs: list[Any],
expected_codes: list[int],
constructor_eager: ConstructorEager,
) -> None:
if "polars" in str(constructor_eager) and (POLARS_VERSION < (1, 0, 0)):
pytest.skip(reason="replace_strict only available after 1.0")

df_native = constructor_eager({"a": values})
df = nw_v1.from_native(df_native)
codes, uniqs = df["a"].factorize(sort=True)

assert_equal_series(uniqs, expected_uniqs, name="a")
assert_equal_series(codes, expected_codes, name="a")

assert codes._version is Version.V1
assert uniqs._version is Version.V1
30 changes: 30 additions & 0 deletions tests/v2_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,3 +586,33 @@ def test_schema_from_generator() -> None:
)
assert schema == nw_v2.Schema({"a": nw_v2.Int64(), "b": nw_v2.String()})
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?

("values", "expected_uniqs", "expected_codes"),
[
([], [], []),
([*"abc"], [*"abc"], [0, 1, 2]),
([*"abcabc"], [*"abc"], [0, 1, 2, 0, 1, 2]),
([*"aaabbbccc"], [*"abc"], [0, 0, 0, 1, 1, 1, 2, 2, 2]),
([*"abcabc", None], [*"abc"], [0, 1, 2, 0, 1, 2, -1]),
],
)
def test_factorize(
values: list[Any],
expected_uniqs: list[Any],
expected_codes: list[int],
constructor_eager: ConstructorEager,
) -> None:
if "polars" in str(constructor_eager) and (POLARS_VERSION < (1, 0, 0)):
pytest.skip(reason="replace_strict only available after 1.0")

df_native = constructor_eager({"a": values})
df = nw_v2.from_native(df_native)
codes, uniqs = df["a"].factorize(sort=True)

assert_equal_series(uniqs, expected_uniqs, name="a")
assert_equal_series(codes, expected_codes, name="a")

assert codes._version is Version.V2
assert uniqs._version is Version.V2
1 change: 1 addition & 0 deletions utils/check_api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def read_documented_members(source: str | Path) -> list[str]:
"arg_min",
"arg_true",
"dtype",
"factorize",
"from_iterable",
"from_numpy",
"gather_every",
Expand Down
Loading