This is an automated email from the ASF dual-hosted git repository.

zhengruifeng pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 9943a967242e [SPARK-58790][PS] Use native Spark function for NumPy modf
9943a967242e is described below

commit 9943a967242ed19ea4896d46d0f7159d125bf9e2
Author: Spenser Sun <[email protected]>
AuthorDate: Mon Aug 24 11:29:49 2026 +0800

    [SPARK-58790][PS] Use native Spark function for NumPy modf
    
    ### What changes were proposed in this pull request?
    
    This PR implements `np.modf` for the pandas API on Spark using native Spark 
expressions.
    
    `np.modf(x)` is a multi-output ufunc: it returns a tuple `(fractional_part, 
integral_part)`. Previously the mapping for `modf` was a malformed 
`pandas_udf(lambda s1, s2: np.modf(s1, s2), DoubleType())` — a unary, 
two-output ufunc wired as a two-argument call with a single `DoubleType` return 
type — so `np.modf` on a pandas-on-Spark object did not work, and `modf` was 
excluded from the compatibility tests via a blacklist.
    
    Multi-output ufuncs cannot be expressed in the existing 
`unary_np_spark_mappings` / `binary_np_spark_mappings` tables, where each entry 
is a single `Column -> Column` function that `column_op` applies once to 
produce one Series. This PR adds:
    
    - A dedicated `multi_output_np_spark_mappings` table whose entries are a 
pair of `Column -> Column` functions (every NumPy multi-output ufunc has 
exactly two outputs), and a branch in `maybe_dispatch_ufunc_to_spark_func` that 
applies `column_op` once per output and returns the two results as a tuple. For 
`modf`, the integral part reuses the existing native `trunc` mapping (the 
integral part of `modf` is exactly truncation toward zero), and the fractional 
part is `signum(x) * (abs(x) % [...]
    - Support for the tuple return across all input types. NumPy's 
`__array_ufunc__` protocol accepts a tuple return for a multi-output ufunc, so 
`fractional, integral = np.modf(x)` unpacks. `Series` and `Index` share 
`IndexOpsMixin.__array_ufunc__`, whose return type is widened to 
`Union[SeriesOrIndex, Tuple[SeriesOrIndex, SeriesOrIndex]]`. A `DataFrame` has 
its own `DataFrame.__array_ufunc__`, which applies the ufunc per column; it now 
detects the per-column tuple and regroups the resul [...]
    
    The two `maybe_dispatch_ufunc_to_*` helpers are also typed to return 
`SeriesOrIndex` (matching `column_op` and the `data_type_ops` operators) rather 
than the base `IndexOpsMixin`, so `__array_ufunc__` returns the dispatch result 
directly without a cast.
    
    ### Why are the changes needed?
    
    This is a sub-task of SPARK-58532, which replaces `pandas_udf`-based NumPy 
ufunc implementations in the pandas API on Spark with native Spark SQL 
expressions. Native expressions execute inside the JVM, code-generate, and are 
visible to the optimizer, avoiding the per-batch JVM-to-Python-worker round 
trip that the UDF fallback incurs. In addition, the previous `modf` mapping was 
broken, so this also makes `np.modf` work for pandas-on-Spark.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Previously `np.modf` on a pandas-on-Spark `Series` / `Index` / 
`DataFrame` raised an error (the mapping was malformed, and the `DataFrame` 
path additionally assumed a single output per column). After this PR it returns 
a tuple `(fractional, integral)` matching NumPy:
    
    ```python
    >>> import numpy as np
    >>> import pyspark.pandas as ps
    >>> fractional, integral = np.modf(ps.Series([-3.5, 0.0, 2.7]))
    >>> fractional.to_numpy()
    array([-0.5,  0. ,  0.7])
    >>> integral.to_numpy()
    array([-3.,  0.,  2.])
    >>> fractional, integral = np.modf(ps.DataFrame({"a": [-3.5, 2.7], "b": 
[1.5, -0.5]}))
    >>> fractional.to_numpy()
    array([[-0.5,  0.5],
           [ 0.7, -0.5]])
    ```
    
    ### How was this patch tested?
    
    Added `test_np_modf` to `NumPyCompatTestsMixin` (run by both the classic 
`NumPyCompatTests` and the Spark Connect `NumPyCompatParityTests` suites). It 
checks `Series` inputs (integer, float-edge with `±inf` / `nan` / signed zero / 
whole numbers / fractions, and nullable `Int64`) with `np.signbit` comparisons 
for signed-zero correctness, and `DataFrame` and `Index` inputs. `modf` was 
removed from the test blacklist.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #58021 from Spenserrrr/numpy-modf-two-output.
    
    Authored-by: Spenser Sun <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
    (cherry picked from commit 40c177b7f243fbba386cb6cecabddbc17734aa8c)
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 python/pyspark/pandas/base.py                    | 12 +++---
 python/pyspark/pandas/frame.py                   | 23 ++++++++---
 python/pyspark/pandas/numpy_compat.py            | 43 ++++++++++++++++++--
 python/pyspark/pandas/tests/test_numpy_compat.py | 50 +++++++++++++++++++++++-
 4 files changed, 112 insertions(+), 16 deletions(-)

diff --git a/python/pyspark/pandas/base.py b/python/pyspark/pandas/base.py
index fe14f2becc92..db3fb53ce855 100644
--- a/python/pyspark/pandas/base.py
+++ b/python/pyspark/pandas/base.py
@@ -542,12 +542,14 @@ class IndexOpsMixin(object, metaclass=ABCMeta):
     # NDArray Compat
     def __array_ufunc__(
         self, ufunc: Callable, method: str, *inputs: Any, **kwargs: Any
-    ) -> SeriesOrIndex:
+    ) -> Union[SeriesOrIndex, Tuple[SeriesOrIndex, SeriesOrIndex]]:
         from pyspark.pandas import numpy_compat
 
-        # Try dunder methods first.
-        result = numpy_compat.maybe_dispatch_ufunc_to_dunder_op(
-            self, ufunc, method, *inputs, **kwargs
+        # Try dunder methods first. A multi-output ufunc (for example np.modf) 
yields a
+        # 2-tuple of results rather than a single Series or Index, so both 
`result` and this
+        # method's return type must admit that tuple.
+        result: Union[SeriesOrIndex, Tuple[SeriesOrIndex, SeriesOrIndex]] = (
+            numpy_compat.maybe_dispatch_ufunc_to_dunder_op(self, ufunc, 
method, *inputs, **kwargs)
         )
 
         # After that, we try with PySpark APIs.
@@ -557,7 +559,7 @@ class IndexOpsMixin(object, metaclass=ABCMeta):
             )
 
         if result is not NotImplemented:
-            return cast(SeriesOrIndex, result)
+            return result
         else:
             # TODO: support more APIs?
             raise NotImplementedError(
diff --git a/python/pyspark/pandas/frame.py b/python/pyspark/pandas/frame.py
index fdc7b6f7f49d..fc075ba32494 100644
--- a/python/pyspark/pandas/frame.py
+++ b/python/pyspark/pandas/frame.py
@@ -14394,7 +14394,7 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
     # NDArray Compat
     def __array_ufunc__(
         self, ufunc: Callable, method: str, *inputs: Any, **kwargs: Any
-    ) -> "DataFrame":
+    ) -> Union["DataFrame", Tuple["DataFrame", "DataFrame"]]:
         # TODO: is it possible to deduplicate it with '_map_series_op'?
         if all(isinstance(inp, DataFrame) for inp in inputs) and any(
             not same_anchor(inp, inputs[0]) for inp in inputs
@@ -14423,14 +14423,25 @@ defaultdict(<class 'list'>, {'col..., 'col...})]
             # DataFrame and Series
             this = inputs[0]
             assert all(inp is this for inp in inputs if isinstance(inp, 
DataFrame))
-            applied = [
+            column_labels = this._internal.column_labels
+            outputs = [
                 ufunc(
                     *[inp[label] if isinstance(inp, DataFrame) else inp for 
inp in inputs], **kwargs
-                ).rename(label)
-                for label in this._internal.column_labels
+                )
+                for label in column_labels
             ]
-            internal = this._internal.with_new_columns(applied)
-            return DataFrame(internal)
+            if outputs and isinstance(outputs[0], tuple):
+                # A multi-output ufunc (for example np.modf) yields a 
two-element tuple per
+                # column; regroup into one DataFrame per output, matching 
numpy/pandas.
+                first_cols = [out[0].rename(label) for out, label in 
zip(outputs, column_labels)]
+                second_cols = [out[1].rename(label) for out, label in 
zip(outputs, column_labels)]
+                first_df: DataFrame = 
DataFrame(this._internal.with_new_columns(first_cols))
+                second_df: DataFrame = 
DataFrame(this._internal.with_new_columns(second_cols))
+                return first_df, second_df
+            else:
+                applied = [out.rename(label) for out, label in zip(outputs, 
column_labels)]
+                internal = this._internal.with_new_columns(applied)
+                return DataFrame(internal)
 
     def __class_getitem__(cls, params: Any) -> object:
         # See https://github.com/python/typing/issues/193
diff --git a/python/pyspark/pandas/numpy_compat.py 
b/python/pyspark/pandas/numpy_compat.py
index 765ba4453444..c27aa0225e3f 100644
--- a/python/pyspark/pandas/numpy_compat.py
+++ b/python/pyspark/pandas/numpy_compat.py
@@ -14,7 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-from typing import Any, Callable, no_type_check
+from typing import Any, Callable, Tuple, Union, no_type_check
 
 import numpy as np
 
@@ -22,6 +22,7 @@ from pyspark.loose_version import LooseVersion
 from pyspark.sql import Column, functions as F
 from pyspark.sql.pandas.functions import pandas_udf
 from pyspark.sql.types import DoubleType, BooleanType
+from pyspark.pandas._typing import SeriesOrIndex
 from pyspark.pandas.base import IndexOpsMixin
 
 
@@ -272,7 +273,6 @@ binary_np_spark_mappings = {
     ),
     "maximum": F.greatest,
     "minimum": F.least,
-    "modf": pandas_udf(lambda s1, s2: np.modf(s1, s2), DoubleType()),  # type: 
ignore[call-overload]
     "nextafter": pandas_udf(  # type: ignore[call-overload]
         lambda s1, s2: np.nextafter(s1, s2), DoubleType()
     ),
@@ -284,11 +284,35 @@ binary_np_spark_mappings = {
 }
 
 
+def _modf_fractional_func(c: Column) -> Column:
+    c_double = c.cast("double")
+    # signum * (abs % 1) keeps the fractional magnitude with the sign of the 
input,
+    # including the signed zero of a whole number (for example -2.0 -> -0.0), 
the same
+    # way the "trunc" mapping (reused below for the integral part) relies on 
signum.
+    fractional = F.signum(c_double) * (F.abs(c_double) % F.lit(1.0))
+    return (
+        F.when(c.isNull() | F.isnan(c_double), c_double)
+        # +-inf has no fractional part; numpy returns a zero with the input's 
sign.
+        .when(c_double == float("inf"), F.lit(0.0))
+        .when(c_double == float("-inf"), F.lit(-0.0))
+        .otherwise(fractional)
+    )
+
+
+# Every multi-output ufunc numpy ships (modf, frexp) has exactly two outputs, 
so each entry
+# maps to a pair of Column->Column functions applied independently and 
returned as a 2-tuple
+# that numpy's __array_ufunc__ unpacks (for example `fractional, integral = 
np.modf(series)`).
+multi_output_np_spark_mappings = {
+    # np.modf(x) -> (fractional part, integral part); the integral part is 
exactly trunc.
+    "modf": (_modf_fractional_func, unary_np_spark_mappings["trunc"]),
+}
+
+
 # Copied from pandas.
 # See also 
https://docs.scipy.org/doc/numpy/reference/arrays.classes.html#standard-array-subclasses
 def maybe_dispatch_ufunc_to_dunder_op(
     ser_or_index: IndexOpsMixin, ufunc: Callable, method: str, *inputs: Any, 
**kwargs: Any
-) -> IndexOpsMixin:
+) -> SeriesOrIndex:
     special = {
         "add",
         "sub",
@@ -354,11 +378,22 @@ def maybe_dispatch_ufunc_to_dunder_op(
 # See also 
https://docs.scipy.org/doc/numpy/reference/arrays.classes.html#standard-array-subclasses
 def maybe_dispatch_ufunc_to_spark_func(
     ser_or_index: IndexOpsMixin, ufunc: Callable, method: str, *inputs: Any, 
**kwargs: Any
-) -> IndexOpsMixin:
+) -> Union[SeriesOrIndex, Tuple[SeriesOrIndex, SeriesOrIndex]]:
     from pyspark.pandas.base import column_op
 
     op_name = ufunc.__name__
 
+    if (
+        method == "__call__"
+        and op_name in multi_output_np_spark_mappings
+        and kwargs.get("out") is None
+    ):
+        # These ufuncs are unary in their input, so the single input is always 
a Series
+        # that column_op unwraps to a Column -- no literal wrapping needed. 
Build one
+        # Series per output and return them as a 2-tuple (see the mapping's 
docstring).
+        first_func, second_func = multi_output_np_spark_mappings[op_name]
+        return column_op(first_func)(*inputs), column_op(second_func)(*inputs)
+
     if (
         method == "__call__"
         and (op_name in unary_np_spark_mappings or op_name in 
binary_np_spark_mappings)
diff --git a/python/pyspark/pandas/tests/test_numpy_compat.py 
b/python/pyspark/pandas/tests/test_numpy_compat.py
index 36eb4444fc53..17201690cee2 100644
--- a/python/pyspark/pandas/tests/test_numpy_compat.py
+++ b/python/pyspark/pandas/tests/test_numpy_compat.py
@@ -42,7 +42,6 @@ class NumPyCompatTestsMixin:
         "log",  # flaky
         "log10",  # flaky
         "log1p",  # flaky
-        "modf",
     ]
 
     @property
@@ -264,6 +263,55 @@ class NumPyCompatTestsMixin:
 
             self.assert_eq(np.fmod(psdf.x1, psdf.x2), np.fmod(pdf.x1, pdf.x2), 
almost=True)
 
+    def test_np_modf(self):
+        # np.modf(x) returns a tuple (fractional part, integral part).
+        for pdf in (
+            pd.DataFrame({"a": [-64, -2, -1, 0, 1, 2, 64]}),
+            pd.DataFrame(
+                {"a": [-np.inf, -64.0, -2.0, -0.5, -0.0, 0.0, 0.5, 2.0, 64.0, 
np.inf, np.nan]}
+            ),
+            pd.DataFrame({"a": pd.array([1, -2, None], dtype="Int64")}),
+        ):
+            psdf = ps.from_pandas(pdf)
+            ps_fractional, ps_integral = np.modf(psdf.a)
+            pd_fractional, pd_integral = np.modf(pdf.a)
+            self.assert_eq(ps_fractional, pd_fractional, almost=True)
+            self.assert_eq(ps_integral, pd_integral, almost=True)
+
+        # almost=True treats -0.0 and 0.0 as equal, so verify the sign of zero 
explicitly:
+        # the fractional part of a negative whole number (-2.0 -> -0.0) and 
the fractional
+        # part of -inf (-> -0.0) must keep the input's sign, as must the 
integral part of a
+        # value in (-1, 0) (-0.5 -> -0.0).
+        pdf = pd.DataFrame({"a": [-2.0, -0.5, -0.0, 0.0, 0.5, 2.0, -np.inf, 
np.inf]})
+        psdf = ps.from_pandas(pdf)
+        ps_fractional, ps_integral = np.modf(psdf.a)
+        pd_fractional, pd_integral = np.modf(pdf.a)
+        self.assert_eq(np.signbit(ps_fractional.to_pandas()), 
np.signbit(pd_fractional))
+        self.assert_eq(np.signbit(ps_integral.to_pandas()), 
np.signbit(pd_integral))
+
+        # DataFrame input: np.modf returns a tuple of DataFrames, one per 
output.
+        pdf = pd.DataFrame(
+            {
+                "a": [-3.5, -2.0, -0.5, 0.0, 2.7],
+                "b": [1.5, -0.0, np.inf, -np.inf, np.nan],
+            }
+        )
+        psdf = ps.from_pandas(pdf)
+        ps_fractional, ps_integral = np.modf(psdf)
+        pd_fractional, pd_integral = np.modf(pdf)
+        self.assert_eq(ps_fractional, pd_fractional, almost=True)
+        self.assert_eq(ps_integral, pd_integral, almost=True)
+        self.assert_eq(np.signbit(ps_fractional.to_pandas()), 
np.signbit(pd_fractional))
+        self.assert_eq(np.signbit(ps_integral.to_pandas()), 
np.signbit(pd_integral))
+
+        # Index input: np.modf returns a tuple of Index objects.
+        pidx = pd.Index([-3.5, -2.0, -0.5, 0.0, 2.7])
+        psidx = ps.from_pandas(pidx)
+        ps_fractional, ps_integral = np.modf(psidx)
+        pd_fractional, pd_integral = np.modf(pidx)
+        self.assert_eq(ps_fractional, pd_fractional, almost=True)
+        self.assert_eq(ps_integral, pd_integral, almost=True)
+
     def test_floor_divide_func(self):
         from pyspark.pandas.numpy_compat import _floor_divide_func
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to