This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new c696cba6eb2a [SPARK-58971][PS] Add pct, na_option, and axis parameters
to Series.rank and DataFrame.rank
c696cba6eb2a is described below
commit c696cba6eb2ae543c44df02208385a3cc5867e29
Author: jzhan-2026 <[email protected]>
AuthorDate: Wed Oct 28 17:21:20 2026 -0700
[SPARK-58971][PS] Add pct, na_option, and axis parameters to Series.rank
and DataFrame.rank
### What changes were proposed in this pull request?
Add `na_option`, `pct`, and `axis` parameters to `Series.rank()`, and
`na_option` and `pct` to `DataFrame.rank()`, to match the pandas API.
- `na_option` (`'keep'`/`'top'`/`'bottom'`): controls how `NaN` values are
ranked
- `pct`: expresses ranks as percentile fractions in `(0, 1]`
- `axis` on `Series.rank()`: accepts `0`/`'index'` only, added for API
compatibility
Co-authored with jing.zhansnowflake.com
### Why are the changes needed?
The pandas-on-Spark implementations were missing these parameters, making
it harder to migrate pandas code to Spark without modification.
`DataFrame.rank` was tracked in SPARK-46167; this PR also covers `Series.rank`.
### Does this PR introduce _any_ user-facing change?
Yes. Two new keyword parameters on `Series.rank()` and `DataFrame.rank()`.
Defaults are unchanged so existing code is unaffected.
```python
>>> ps.Series([1, float('nan'), 2]).rank(na_option='top')
0 2.0
1 1.0
2 3.0
>>> ps.Series([1, 2, 2, 3]).rank(pct=True)
0 0.25
1 0.625
2 0.625
3 1.0
```
### How was this patch tested?
New test method test_rank_pct_na_option in test_compute.py and new cases in
test_stat.py covering all methods, both axes, combined parameters, and invalid
input errors.
### Was this patch authored or co-authored using generative AI tooling?
This patch was co-authored with AI assistance (Claude Sonnet 4.6).
Closes #58254 from jzhan-2026/pandas-rank-params.
Authored-by: jzhan-2026 <[email protected]>
Signed-off-by: Holden Karau <[email protected]>
---
python/pyspark/pandas/frame.py | 38 ++++++--
python/pyspark/pandas/series.py | 101 ++++++++++++++++++---
.../pandas/tests/computation/test_compute.py | 64 +++++++++++++
python/pyspark/pandas/tests/series/test_stat.py | 32 +++++++
4 files changed, 218 insertions(+), 17 deletions(-)
diff --git a/python/pyspark/pandas/frame.py b/python/pyspark/pandas/frame.py
index 2dbb6f5f46b0..baebb079bd3e 100644
--- a/python/pyspark/pandas/frame.py
+++ b/python/pyspark/pandas/frame.py
@@ -11762,13 +11762,14 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
# dtype: bool
return first_series(DataFrame(internal))
- # TODO(SPARK-46167): add pct, na_option parameter
def rank(
self,
method: Literal["average", "min", "max", "first", "dense"] = "average",
ascending: bool = True,
numeric_only: bool = False,
axis: Axis = 0,
+ na_option: Literal["keep", "top", "bottom"] = "keep",
+ pct: bool = False,
) -> "DataFrame":
"""
Compute numerical data ranks (1 through n) along axis. Equal values are
@@ -11804,6 +11805,17 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
.. note:: For axis=1, pandas UDF is used which may have
performance overhead
for very wide DataFrames (100+ columns).
+ na_option : {'keep', 'top', 'bottom'}, default 'keep'
+ * keep: leave NA values where they are
+ * top: smallest rank if ascending
+ * bottom: largest rank if ascending
+
+ .. versionadded:: 4.4.0
+
+ pct : bool, default False
+ Whether or not to display the returned rankings in percentile form.
+
+ .. versionadded:: 4.4.0
Returns
-------
@@ -11893,14 +11905,24 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
if axis == 0:
return psdf._apply_series_op(
- lambda psser: psser._rank(method=method, ascending=ascending),
should_resolve=True
+ lambda psser: psser._rank(
+ method=method, ascending=ascending, na_option=na_option,
pct=pct
+ ),
+ should_resolve=True,
)
else:
# Fast path for small dataframes
limit = get_option("compute.shortcut_limit")
pdf = psdf.head(limit + 1)._to_internal_pandas()
if len(pdf) <= limit:
- pdf_rank = pdf.rank(method=method, ascending=ascending,
axis=1, numeric_only=False)
+ pdf_rank = pdf.rank(
+ method=method,
+ ascending=ascending,
+ axis=1,
+ numeric_only=False,
+ na_option=na_option,
+ pct=pct,
+ )
return DataFrame(InternalFrame.from_pandas(pdf_rank))
column_label_strings = [
@@ -11917,9 +11939,13 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
)
def rank_axis_1(*cols: pd.Series) -> pd.DataFrame:
pdf_row = pd.concat(cols, axis=1, keys=column_label_strings)
- return pdf_row.rank(method=method, ascending=ascending,
axis=1).rename(
- columns=dict(zip(pdf_row.columns, column_label_strings))
- )
+ return pdf_row.rank(
+ method=method,
+ ascending=ascending,
+ axis=1,
+ na_option=na_option,
+ pct=pct,
+ ).rename(columns=dict(zip(pdf_row.columns,
column_label_strings)))
ranked_struct_col = rank_axis_1(*psdf._internal.data_spark_columns)
new_data_columns = [
diff --git a/python/pyspark/pandas/series.py b/python/pyspark/pandas/series.py
index 7ce31bb6c092..0f32e467f08d 100644
--- a/python/pyspark/pandas/series.py
+++ b/python/pyspark/pandas/series.py
@@ -4195,9 +4195,14 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
return self._reduce_for_stat_function(quantile, name="quantile")
- # TODO: add axis, pct, na_option parameter
def rank(
- self, method: str = "average", ascending: bool = True, numeric_only:
bool = False
+ self,
+ method: str = "average",
+ ascending: bool = True,
+ numeric_only: bool = False,
+ axis: int = 0,
+ na_option: Literal["keep", "top", "bottom"] = "keep",
+ pct: bool = False,
) -> "Series":
"""
Compute numerical data ranks (1 through n) along axis. Equal values are
@@ -4224,6 +4229,20 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
.. versionchanged:: 4.0.0
The default value of ``numeric_only`` is now ``False``.
+ axis : {0 or 'index'}, default 0
+ Parameter needed for compatibility with DataFrame.
+
+ na_option : {'keep', 'top', 'bottom'}, default 'keep'
+ * keep: leave NA values where they are
+ * top: smallest rank if ascending
+ * bottom: largest rank if ascending
+
+ .. versionadded:: 4.4.0
+
+ pct : bool, default False
+ Whether or not to display the returned rankings in percentile form.
+
+ .. versionadded:: 4.4.0
Returns
-------
@@ -4291,12 +4310,42 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
y b
z c
Name: A, dtype: object
+
+ With pct=True, ranks are expressed as percentiles.
+
+ >>> s = ps.Series([1, 2, 2, 3], name='A')
+ >>> s.rank(pct=True)
+ 0 0.250
+ 1 0.625
+ 2 0.625
+ 3 1.000
+ Name: A, dtype: float64
+
+ With na_option='top', NaN values are assigned the smallest rank.
+
+ >>> s = ps.Series([1, float('nan'), 2, 3], name='A')
+ >>> s.rank(na_option='top').sort_index()
+ 0 2.0
+ 1 1.0
+ 2 3.0
+ 3 4.0
+ Name: A, dtype: float64
+
+ With na_option='bottom', NaN values are assigned the largest rank.
+
+ >>> s.rank(na_option='bottom').sort_index()
+ 0 1.0
+ 1 4.0
+ 2 2.0
+ 3 3.0
+ Name: A, dtype: float64
"""
+ validate_axis(axis)
is_numeric = isinstance(self.spark.data_type, (NumericType,
BooleanType))
if numeric_only and not is_numeric:
raise TypeError("Series.rank does not allow numeric_only=True with
non-numeric dtype.")
else:
- return self._rank(method, ascending).spark.analyzed
+ return self._rank(method, ascending, na_option=na_option,
pct=pct).spark.analyzed
def _rank(
self,
@@ -4304,32 +4353,42 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
ascending: bool = True,
*,
part_cols: Sequence["ColumnOrName"] = (),
+ na_option: Literal["keep", "top", "bottom"] = "keep",
+ pct: bool = False,
) -> "Series":
if method not in ["average", "min", "max", "first", "dense"]:
msg = "method must be one of 'average', 'min', 'max', 'first',
'dense'"
raise ValueError(msg)
+ if na_option not in ["keep", "top", "bottom"]:
+ raise ValueError("na_option must be one of 'keep', 'top',
'bottom'")
if self._internal.index_level > 1:
raise NotImplementedError("rank do not support MultiIndex now")
+ # 'top' always assigns rank 1 to NaN (nulls first in window order).
+ # 'bottom' always assigns the largest rank to NaN (nulls last). 'keep'
also nulls last.
+ nulls_first = na_option == "top"
if ascending:
- asc_func = PySparkColumn.asc
+ null_func = (
+ PySparkColumn.asc_nulls_first if nulls_first else
PySparkColumn.asc_nulls_last
+ )
else:
- asc_func = PySparkColumn.desc
+ null_func = (
+ PySparkColumn.desc_nulls_first if nulls_first else
PySparkColumn.desc_nulls_last
+ )
+ sort_col = null_func(self.spark.column)
+ nat_order_col = F.col(NATURAL_ORDER_COLUMN_NAME).asc()
if method == "first":
window = (
- Window.orderBy(
- asc_func(self.spark.column),
- F.col(NATURAL_ORDER_COLUMN_NAME).asc(),
- )
+ Window.orderBy(sort_col, nat_order_col)
.partitionBy(*part_cols)
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
scol = F.row_number().over(window)
elif method == "dense":
window = (
- Window.orderBy(asc_func(self.spark.column))
+ Window.orderBy(sort_col)
.partitionBy(*part_cols)
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
@@ -4342,7 +4401,7 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
elif method == "max":
stat_func = F.max
window1 = (
- Window.orderBy(asc_func(self.spark.column))
+ Window.orderBy(sort_col)
.partitionBy(*part_cols)
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
@@ -4351,6 +4410,26 @@ class Series(Frame, IndexOpsMixin, Generic[T]):
cast("List[ColumnOrName]", [self.spark.column]) +
list(part_cols)
).rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing)
scol = stat_func(F.row_number().over(window1)).over(window2)
+
+ if pct:
+ partition_window = Window.partitionBy(*part_cols)
+ if method == "dense":
+ # For dense ranking, pct denominator is the number of distinct
values.
+ if na_option == "keep":
+ denom = F.max(F.when(self.spark.column.isNotNull(),
scol)).over(
+ partition_window
+ )
+ else:
+ denom = F.max(scol).over(partition_window)
+ elif na_option == "keep":
+ denom = F.count(self.spark.column).over(partition_window)
+ else:
+ denom = F.count(F.lit(1)).over(partition_window)
+ scol = scol / denom.cast(DoubleType())
+
+ if na_option == "keep":
+ scol = F.when(self.spark.column.isNotNull(), scol)
+
return self._with_new_scol(scol.cast(DoubleType()))
def filter(
diff --git a/python/pyspark/pandas/tests/computation/test_compute.py
b/python/pyspark/pandas/tests/computation/test_compute.py
index 394d46173142..c3b991bb55b2 100644
--- a/python/pyspark/pandas/tests/computation/test_compute.py
+++ b/python/pyspark/pandas/tests/computation/test_compute.py
@@ -363,6 +363,70 @@ class FrameComputeMixin:
with self.assertRaisesRegex(ValueError, "No axis named"):
psdf.rank(axis=2)
+ def test_rank_pct_na_option(self):
+ pdf = pd.DataFrame(
+ {"A": [1, 2, np.nan, 3], "B": [4, np.nan, 2, 1]},
+ columns=["A", "B"],
+ )
+ psdf = ps.from_pandas(pdf)
+
+ # pct=True
+ self.assert_eq(pdf.rank(pct=True).sort_index(),
psdf.rank(pct=True).sort_index())
+
+ # na_option='top'
+ self.assert_eq(
+ pdf.rank(na_option="top").sort_index(),
psdf.rank(na_option="top").sort_index()
+ )
+
+ # na_option='bottom'
+ self.assert_eq(
+ pdf.rank(na_option="bottom").sort_index(),
psdf.rank(na_option="bottom").sort_index()
+ )
+
+ # pct + na_option combined
+ self.assert_eq(
+ pdf.rank(pct=True, na_option="top").sort_index(),
+ psdf.rank(pct=True, na_option="top").sort_index(),
+ )
+ self.assert_eq(
+ pdf.rank(pct=True, na_option="bottom").sort_index(),
+ psdf.rank(pct=True, na_option="bottom").sort_index(),
+ )
+
+ # na_option with ascending=False
+ self.assert_eq(
+ pdf.rank(na_option="top", ascending=False).sort_index(),
+ psdf.rank(na_option="top", ascending=False).sort_index(),
+ )
+ self.assert_eq(
+ pdf.rank(na_option="bottom", ascending=False).sort_index(),
+ psdf.rank(na_option="bottom", ascending=False).sort_index(),
+ )
+
+ # all methods
+ for method in ["average", "min", "max", "first", "dense"]:
+ self.assert_eq(
+ pdf.rank(method=method, pct=True).sort_index(),
+ psdf.rank(method=method, pct=True).sort_index(),
+ )
+ self.assert_eq(
+ pdf.rank(method=method, na_option="bottom").sort_index(),
+ psdf.rank(method=method, na_option="bottom").sort_index(),
+ )
+
+ # axis=1 with pct and na_option
+ self.assert_eq(
+ pdf.rank(axis=1, pct=True).sort_index(), psdf.rank(axis=1,
pct=True).sort_index()
+ )
+ self.assert_eq(
+ pdf.rank(axis=1, na_option="top").sort_index(),
+ psdf.rank(axis=1, na_option="top").sort_index(),
+ )
+
+ # invalid na_option
+ with self.assertRaisesRegex(ValueError, "na_option must be one of"):
+ psdf.rank(na_option="invalid")
+
def test_nunique(self):
pdf = pd.DataFrame({"A": [1, 2, 3], "B": [np.nan, 3, np.nan]},
index=np.random.rand(3))
psdf = ps.from_pandas(pdf)
diff --git a/python/pyspark/pandas/tests/series/test_stat.py
b/python/pyspark/pandas/tests/series/test_stat.py
index 10cf9ed9e3ef..bfa09c899a2b 100644
--- a/python/pyspark/pandas/tests/series/test_stat.py
+++ b/python/pyspark/pandas/tests/series/test_stat.py
@@ -348,6 +348,38 @@ class SeriesStatMixin:
with self.assertRaisesRegex(ValueError, msg):
psser.rank(method="nothing")
+ # pct=True
+ pser = pd.Series([1, 2, 2, 3], name="x")
+ psser = ps.from_pandas(pser)
+ self.assert_eq(pser.rank(pct=True), psser.rank(pct=True).sort_index())
+
+ # na_option
+ pser = pd.Series([1, float("nan"), 2, 3], name="x")
+ psser = ps.from_pandas(pser)
+ self.assert_eq(pser.rank(na_option="top"),
psser.rank(na_option="top").sort_index())
+ self.assert_eq(pser.rank(na_option="bottom"),
psser.rank(na_option="bottom").sort_index())
+ self.assert_eq(
+ pser.rank(pct=True, na_option="top"),
+ psser.rank(pct=True, na_option="top").sort_index(),
+ )
+ self.assert_eq(
+ pser.rank(na_option="top", ascending=False),
+ psser.rank(na_option="top", ascending=False).sort_index(),
+ )
+ self.assert_eq(
+ pser.rank(na_option="bottom", ascending=False),
+ psser.rank(na_option="bottom", ascending=False).sort_index(),
+ )
+
+ # axis=0 is accepted (no-op for Series)
+ pser = pd.Series([1, 2, 3, 1], name="x")
+ psser = ps.from_pandas(pser)
+ self.assert_eq(pser.rank(axis=0), psser.rank(axis=0).sort_index())
+
+ # invalid na_option
+ with self.assertRaisesRegex(ValueError, "na_option must be one of"):
+ psser.rank(na_option="bad")
+
msg = "method must be one of 'average', 'min', 'max', 'first', 'dense'"
with self.assertRaisesRegex(ValueError, msg):
psser.rank(method="nothing")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]