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

zhengruifeng 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 d06e4faa76ce [SPARK-59134][PS] Use native Spark function for NumPy 
frexp
d06e4faa76ce is described below

commit d06e4faa76ce15305ccd2d684ac3a260aba2e65c
Author: Spenser Sun <[email protected]>
AuthorDate: Tue Sep 1 09:11:46 2026 +0800

    [SPARK-59134][PS] Use native Spark function for NumPy frexp
    
    ### What changes were proposed in this pull request?
    
    Implements `np.frexp` for pandas-on-Spark with native Spark expressions, 
replacing its `NotImplemented` placeholder. It reuses the two-output ufunc 
support added for `np.modf` (SPARK-58790), so `mantissa, exponent = 
np.frexp(psser)` works on Series, Index and DataFrame.
    
    ### Why are the changes needed?
    
    `np.frexp` on a pandas-on-Spark object raises today, while pandas returns a 
`(mantissa, exponent)` pair, so the two APIs diverge here. Supporting it 
natively continues the NumPy coverage work under SPARK-58532.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. `np.frexp` on a pandas-on-Spark Series, Index or DataFrame returns a 
`(mantissa, exponent)` tuple instead of raising.
    
    ### How was this patch tested?
    
    New `test_np_frexp` in `NumPyCompatTestsMixin` (classic and Connect 
parity), which fails without the change; the outputs were also checked 
bit-exact against NumPy with a probe covering every power of two ±1 ulp and 
both ends of the double range.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    Closes #58434 from Spenserrrr/numpy-frexp-two-output.
    
    Authored-by: Spenser Sun <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 python/pyspark/pandas/numpy_compat.py            | 62 +++++++++++++++++++++-
 python/pyspark/pandas/tests/test_numpy_compat.py | 67 +++++++++++++++++++++++-
 2 files changed, 126 insertions(+), 3 deletions(-)

diff --git a/python/pyspark/pandas/numpy_compat.py 
b/python/pyspark/pandas/numpy_compat.py
index 0c93be229904..20e319654ddb 100644
--- a/python/pyspark/pandas/numpy_compat.py
+++ b/python/pyspark/pandas/numpy_compat.py
@@ -51,8 +51,6 @@ unary_np_spark_mappings = {
     "expm1": F.expm1,
     "fabs": lambda c: F.abs(c.cast("double")),
     "floor": F.floor,
-    "frexp": lambda _: NotImplemented,  # 'frexp' output lengths become 
different
-    # and it cannot be supported via pandas UDF.
     "invert": F.bitwise_not,
     "isfinite": lambda c: F.coalesce(
         ~(F.isnan(c) | (c == float("inf")) | (c == float("-inf"))), 
F.lit(False)
@@ -291,10 +289,70 @@ def _modf_fractional_func(c: Column) -> Column:
     )
 
 
+def _frexp_scale(c_double: Column, exponent: Column) -> Column:
+    # x * 2**-exponent, in two halves because a single 2**exponent is not 
finite across
+    # frexp's range (-1073 for the smallest subnormal, 1024 for the largest 
double).
+    half = F.floor(exponent / F.lit(2.0))
+    return c_double / F.pow(F.lit(2.0), half) / F.pow(F.lit(2.0), exponent - 
half)
+
+
+def _frexp_finite_exponent(c_double: Column) -> Column:
+    # floor(log2|x|) + 1, which the logarithm can put on the wrong side of an 
integer next
+    # to a power of two, but never by more than one: check the scaled 
magnitude against
+    # frexp's [0.5, 1) range and step the estimate where it falls outside.
+    estimate = F.floor(F.log2(F.abs(c_double))) + F.lit(1)
+    magnitude = F.abs(_frexp_scale(c_double, estimate))
+    return (
+        F.when(magnitude >= F.lit(1.0), estimate + F.lit(1))
+        .when(magnitude < F.lit(0.5), estimate - F.lit(1))
+        .otherwise(estimate)
+    )
+
+
+def _frexp_is_special_value(c: Column) -> Column:
+    # frexp returns 0, +-inf and nan unchanged as the mantissa, keeping the 
sign of a zero,
+    # and pairs them with a zero exponent. from_pandas delivers a NaN as a 
null; the sign of a
+    # NaN never survives to here, so numpy's -nan mantissa reads +nan.
+    c_double = c.cast("double")
+    return (
+        c.isNull()
+        | F.isnan(c_double)
+        | (c_double == 0)
+        | c_double.isin(float("-inf"), float("inf"))
+    )
+
+
+def _frexp_mantissa_func(c: Column) -> Column:
+    c_double = c.cast("double")
+    return F.when(_frexp_is_special_value(c), c_double).otherwise(
+        _frexp_scale(c_double, _frexp_finite_exponent(c_double))
+    )
+
+
+def _frexp_exponent_func(c: Column) -> Column:
+    return (
+        F.when(
+            # A genuine <NA> from a nullable dtype (e.g. Int64) arrives as a 
non-floating null
+            # and must propagate; a NaN arrives as a floating null and takes 
the zero branch
+            # below. A nullable float dtype's <NA> (Float32 or Float64) is a 
floating null too,
+            # indistinguishable from a NaN after from_pandas, so it reads 0 
instead.
+            c.isNull() & ~F.typeof(c).isin("float", "double"),
+            F.lit(None),
+        )
+        .when(_frexp_is_special_value(c), F.lit(0))
+        .otherwise(_frexp_finite_exponent(c.cast("double")))
+        # numpy returns the exponent as an int32.
+        .cast("int")
+    )
+
+
 # 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.frexp(x) -> (mantissa, exponent) with x == mantissa * 2**exponent, 
the mantissa
+    # keeping x's sign at a magnitude in [0.5, 1).
+    "frexp": (_frexp_mantissa_func, _frexp_exponent_func),
     # np.modf(x) -> (fractional part, integral part); the integral part is 
exactly trunc.
     "modf": (_modf_fractional_func, unary_np_spark_mappings["trunc"]),
 }
diff --git a/python/pyspark/pandas/tests/test_numpy_compat.py 
b/python/pyspark/pandas/tests/test_numpy_compat.py
index da4ed34aa121..e2c2927ac224 100644
--- a/python/pyspark/pandas/tests/test_numpy_compat.py
+++ b/python/pyspark/pandas/tests/test_numpy_compat.py
@@ -58,7 +58,6 @@ class NumPyCompatTestsMixin:
         "conjugate",
         "isnat",
         "matmul",
-        "frexp",
         # Values are close enough but tests failed.
         "log",  # flaky
         "log10",  # flaky
@@ -390,6 +389,72 @@ class NumPyCompatTestsMixin:
         self.assert_eq(ps_fractional, pd_fractional, almost=True)
         self.assert_eq(ps_integral, pd_integral, almost=True)
 
+    def test_np_frexp(self):
+        # np.frexp(x) returns a tuple (mantissa, exponent), where x == 
mantissa * 2**exponent.
+        for pdf in (
+            pd.DataFrame({"a": [-64, -3, -1, 0, 1, 3, 64]}),
+            pd.DataFrame(
+                {"a": [-np.inf, -64.0, -1.5, -0.5, -0.0, 0.0, 0.5, 1.5, 64.0, 
np.inf, np.nan]}
+            ),
+            pd.DataFrame({"a": pd.array([1, -2, None], dtype="Int64")}),
+        ):
+            psdf = ps.from_pandas(pdf)
+            ps_mantissa, ps_exponent = np.frexp(psdf.a)
+            pd_mantissa, pd_exponent = np.frexp(pdf.a)
+            self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
+            self.assert_eq(ps_exponent, pd_exponent, almost=True)
+
+        # Values next to a power of two, where the logarithm behind the 
exponent needs its
+        # correction, and both ends of the double range. Compared exactly, not 
with almost=True.
+        pdf = pd.DataFrame(
+            {
+                "a": [
+                    np.nextafter(2.0, 0.0),
+                    2.0,
+                    np.nextafter(2.0, np.inf),
+                    np.nextafter(-2.0, 0.0),
+                    np.finfo(np.float64).max,
+                    np.finfo(np.float64).tiny,
+                    np.nextafter(0.0, 1.0),  # the smallest subnormal
+                ]
+            }
+        )
+        psdf = ps.from_pandas(pdf)
+        ps_mantissa, ps_exponent = np.frexp(psdf.a)
+        pd_mantissa, pd_exponent = np.frexp(pdf.a)
+        self.assert_eq(ps_mantissa, pd_mantissa)
+        self.assert_eq(ps_exponent, pd_exponent)
+
+        # almost=True treats -0.0 and 0.0 as equal, so check the sign of zero 
explicitly:
+        # the mantissa of a zero is that zero, and of +-inf that infinity.
+        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_mantissa, _ = np.frexp(psdf.a)
+        pd_mantissa, _ = np.frexp(pdf.a)
+        self.assert_eq(np.signbit(ps_mantissa.to_pandas()), 
np.signbit(pd_mantissa))
+
+        # DataFrame input: np.frexp returns a tuple of DataFrames, one per 
output.
+        pdf = pd.DataFrame(
+            {
+                "a": [-3.5, -1.0, -0.5, 0.0, 6.0],
+                "b": [1.5, -0.0, np.inf, -np.inf, np.nan],
+            }
+        )
+        psdf = ps.from_pandas(pdf)
+        ps_mantissa, ps_exponent = np.frexp(psdf)
+        pd_mantissa, pd_exponent = np.frexp(pdf)
+        self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
+        self.assert_eq(ps_exponent, pd_exponent, almost=True)
+        self.assert_eq(np.signbit(ps_mantissa.to_pandas()), 
np.signbit(pd_mantissa))
+
+        # Index input: np.frexp returns a tuple of Index objects.
+        pidx = pd.Index([-3.5, -1.0, -0.5, 0.0, 6.0])
+        psidx = ps.from_pandas(pidx)
+        ps_mantissa, ps_exponent = np.frexp(psidx)
+        pd_mantissa, pd_exponent = np.frexp(pidx)
+        self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
+        self.assert_eq(ps_exponent, pd_exponent, almost=True)
+
     def test_floor_divide_func(self):
         from pyspark.pandas.utils import _floor_divide_func
 


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

Reply via email to