Skip to content

Commit d06e4fa

Browse files
Spenserrrrzhengruifeng
authored andcommitted
[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 <hsun112358@gmail.com> Signed-off-by: Ruifeng Zheng <ruifengz@apache.org>
1 parent 37bca2c commit d06e4fa

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

python/pyspark/pandas/numpy_compat.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,6 @@
5151
"expm1": F.expm1,
5252
"fabs": lambda c: F.abs(c.cast("double")),
5353
"floor": F.floor,
54-
"frexp": lambda _: NotImplemented, # 'frexp' output lengths become different
55-
# and it cannot be supported via pandas UDF.
5654
"invert": F.bitwise_not,
5755
"isfinite": lambda c: F.coalesce(
5856
~(F.isnan(c) | (c == float("inf")) | (c == float("-inf"))), F.lit(False)
@@ -291,10 +289,70 @@ def _modf_fractional_func(c: Column) -> Column:
291289
)
292290

293291

292+
def _frexp_scale(c_double: Column, exponent: Column) -> Column:
293+
# x * 2**-exponent, in two halves because a single 2**exponent is not finite across
294+
# frexp's range (-1073 for the smallest subnormal, 1024 for the largest double).
295+
half = F.floor(exponent / F.lit(2.0))
296+
return c_double / F.pow(F.lit(2.0), half) / F.pow(F.lit(2.0), exponent - half)
297+
298+
299+
def _frexp_finite_exponent(c_double: Column) -> Column:
300+
# floor(log2|x|) + 1, which the logarithm can put on the wrong side of an integer next
301+
# to a power of two, but never by more than one: check the scaled magnitude against
302+
# frexp's [0.5, 1) range and step the estimate where it falls outside.
303+
estimate = F.floor(F.log2(F.abs(c_double))) + F.lit(1)
304+
magnitude = F.abs(_frexp_scale(c_double, estimate))
305+
return (
306+
F.when(magnitude >= F.lit(1.0), estimate + F.lit(1))
307+
.when(magnitude < F.lit(0.5), estimate - F.lit(1))
308+
.otherwise(estimate)
309+
)
310+
311+
312+
def _frexp_is_special_value(c: Column) -> Column:
313+
# frexp returns 0, +-inf and nan unchanged as the mantissa, keeping the sign of a zero,
314+
# and pairs them with a zero exponent. from_pandas delivers a NaN as a null; the sign of a
315+
# NaN never survives to here, so numpy's -nan mantissa reads +nan.
316+
c_double = c.cast("double")
317+
return (
318+
c.isNull()
319+
| F.isnan(c_double)
320+
| (c_double == 0)
321+
| c_double.isin(float("-inf"), float("inf"))
322+
)
323+
324+
325+
def _frexp_mantissa_func(c: Column) -> Column:
326+
c_double = c.cast("double")
327+
return F.when(_frexp_is_special_value(c), c_double).otherwise(
328+
_frexp_scale(c_double, _frexp_finite_exponent(c_double))
329+
)
330+
331+
332+
def _frexp_exponent_func(c: Column) -> Column:
333+
return (
334+
F.when(
335+
# A genuine <NA> from a nullable dtype (e.g. Int64) arrives as a non-floating null
336+
# and must propagate; a NaN arrives as a floating null and takes the zero branch
337+
# below. A nullable float dtype's <NA> (Float32 or Float64) is a floating null too,
338+
# indistinguishable from a NaN after from_pandas, so it reads 0 instead.
339+
c.isNull() & ~F.typeof(c).isin("float", "double"),
340+
F.lit(None),
341+
)
342+
.when(_frexp_is_special_value(c), F.lit(0))
343+
.otherwise(_frexp_finite_exponent(c.cast("double")))
344+
# numpy returns the exponent as an int32.
345+
.cast("int")
346+
)
347+
348+
294349
# Every multi-output ufunc numpy ships (modf, frexp) has exactly two outputs, so each entry
295350
# maps to a pair of Column->Column functions applied independently and returned as a 2-tuple
296351
# that numpy's __array_ufunc__ unpacks (for example `fractional, integral = np.modf(series)`).
297352
multi_output_np_spark_mappings = {
353+
# np.frexp(x) -> (mantissa, exponent) with x == mantissa * 2**exponent, the mantissa
354+
# keeping x's sign at a magnitude in [0.5, 1).
355+
"frexp": (_frexp_mantissa_func, _frexp_exponent_func),
298356
# np.modf(x) -> (fractional part, integral part); the integral part is exactly trunc.
299357
"modf": (_modf_fractional_func, unary_np_spark_mappings["trunc"]),
300358
}

python/pyspark/pandas/tests/test_numpy_compat.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def setUpClass(cls):
5858
"conjugate",
5959
"isnat",
6060
"matmul",
61-
"frexp",
6261
# Values are close enough but tests failed.
6362
"log", # flaky
6463
"log10", # flaky
@@ -390,6 +389,72 @@ def test_np_modf(self):
390389
self.assert_eq(ps_fractional, pd_fractional, almost=True)
391390
self.assert_eq(ps_integral, pd_integral, almost=True)
392391

392+
def test_np_frexp(self):
393+
# np.frexp(x) returns a tuple (mantissa, exponent), where x == mantissa * 2**exponent.
394+
for pdf in (
395+
pd.DataFrame({"a": [-64, -3, -1, 0, 1, 3, 64]}),
396+
pd.DataFrame(
397+
{"a": [-np.inf, -64.0, -1.5, -0.5, -0.0, 0.0, 0.5, 1.5, 64.0, np.inf, np.nan]}
398+
),
399+
pd.DataFrame({"a": pd.array([1, -2, None], dtype="Int64")}),
400+
):
401+
psdf = ps.from_pandas(pdf)
402+
ps_mantissa, ps_exponent = np.frexp(psdf.a)
403+
pd_mantissa, pd_exponent = np.frexp(pdf.a)
404+
self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
405+
self.assert_eq(ps_exponent, pd_exponent, almost=True)
406+
407+
# Values next to a power of two, where the logarithm behind the exponent needs its
408+
# correction, and both ends of the double range. Compared exactly, not with almost=True.
409+
pdf = pd.DataFrame(
410+
{
411+
"a": [
412+
np.nextafter(2.0, 0.0),
413+
2.0,
414+
np.nextafter(2.0, np.inf),
415+
np.nextafter(-2.0, 0.0),
416+
np.finfo(np.float64).max,
417+
np.finfo(np.float64).tiny,
418+
np.nextafter(0.0, 1.0), # the smallest subnormal
419+
]
420+
}
421+
)
422+
psdf = ps.from_pandas(pdf)
423+
ps_mantissa, ps_exponent = np.frexp(psdf.a)
424+
pd_mantissa, pd_exponent = np.frexp(pdf.a)
425+
self.assert_eq(ps_mantissa, pd_mantissa)
426+
self.assert_eq(ps_exponent, pd_exponent)
427+
428+
# almost=True treats -0.0 and 0.0 as equal, so check the sign of zero explicitly:
429+
# the mantissa of a zero is that zero, and of +-inf that infinity.
430+
pdf = pd.DataFrame({"a": [-2.0, -0.5, -0.0, 0.0, 0.5, 2.0, -np.inf, np.inf]})
431+
psdf = ps.from_pandas(pdf)
432+
ps_mantissa, _ = np.frexp(psdf.a)
433+
pd_mantissa, _ = np.frexp(pdf.a)
434+
self.assert_eq(np.signbit(ps_mantissa.to_pandas()), np.signbit(pd_mantissa))
435+
436+
# DataFrame input: np.frexp returns a tuple of DataFrames, one per output.
437+
pdf = pd.DataFrame(
438+
{
439+
"a": [-3.5, -1.0, -0.5, 0.0, 6.0],
440+
"b": [1.5, -0.0, np.inf, -np.inf, np.nan],
441+
}
442+
)
443+
psdf = ps.from_pandas(pdf)
444+
ps_mantissa, ps_exponent = np.frexp(psdf)
445+
pd_mantissa, pd_exponent = np.frexp(pdf)
446+
self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
447+
self.assert_eq(ps_exponent, pd_exponent, almost=True)
448+
self.assert_eq(np.signbit(ps_mantissa.to_pandas()), np.signbit(pd_mantissa))
449+
450+
# Index input: np.frexp returns a tuple of Index objects.
451+
pidx = pd.Index([-3.5, -1.0, -0.5, 0.0, 6.0])
452+
psidx = ps.from_pandas(pidx)
453+
ps_mantissa, ps_exponent = np.frexp(psidx)
454+
pd_mantissa, pd_exponent = np.frexp(pidx)
455+
self.assert_eq(ps_mantissa, pd_mantissa, almost=True)
456+
self.assert_eq(ps_exponent, pd_exponent, almost=True)
457+
393458
def test_floor_divide_func(self):
394459
from pyspark.pandas.utils import _floor_divide_func
395460

0 commit comments

Comments
 (0)