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 670de9da885f [SPARK-58581][PS][FOLLOWUP] Fix NumPy floor_divide
rounding and integer precision
670de9da885f is described below
commit 670de9da885f22c21a6195de45013ea6f045af4c
Author: Spenser Sun <[email protected]>
AuthorDate: Fri Aug 28 15:56:28 2026 +0800
[SPARK-58581][PS][FOLLOWUP] Fix NumPy floor_divide rounding and integer
precision
### What changes were proposed in this pull request?
The native `floor_divide` mapping computes `floor(c1 / c2)`. That gives a
wrong answer in two cases, both fixed here.
- **Floating operands.** `1.0 // 0.1` returned 10, where pandas, NumPy and
Python all give 9. The exact quotient is a little under 10, and the division
rounds it up before the floor ever runs. NumPy works the quotient out from the
remainder instead, which is exact in IEEE arithmetic, so the floating branch
now does the same, including NumPy's sign correction and snap-to-nearest.
- **Integral operands.** They were cast to double before dividing, which
drops the low bits above 2^53, so `-9007199254740993 // 2` came back as
`-4503599627370496` instead of `-4503599627370497`. They now divide in integer
space, and only the result is cast.
A second commit handles `-2**63 // -1`, the one quotient a long cannot
hold. Spark's integer division raises `ARITHMETIC_OVERFLOW` there, so that pair
is guarded and returns NumPy's wrapped value, which is what this mapping's
original pandas UDF produced.
A third commit covers a quotient that overflows to an infinity, which the
old and new formulas both returned as `nan`. NumPy keeps the infinity, and an
infinite quotient is its own floor, so `1e300 // 1e-300` now skips the flooring
step.
### Why are the changes needed?
Both answers disagree with pandas, which pandas-on-Spark is meant to match.
The `1.0 // 0.1` case is also inconsistent inside Spark itself. `1.0 % 0.1`
gives `0.09999999999999995`, and a remainder that large only makes sense if the
quotient is 9: with a quotient of 10 there would be nothing left over.
### Does this PR introduce _any_ user-facing change?
No. `np.floor_divide` goes through the pandas-on-Spark `floordiv` dunder
operation before this registry is consulted, so the mapping is not reached
today, as the comment on the entry notes.
**A follow-up PR will cover the reachable path.** The same two bugs are
live in `data_type_ops/num_ops.py` (`IntegralOps.floordiv`,
`FractionalOps.floordiv` and both `rfloordiv`), which use `F.floor(lc / rc)`:
`ps.Series([1.0]) // 0.1` gives `10.0` where pandas gives `9.0`, and `psser //
2` is off by one above 2^53. That one does change what users see on a
long-standing path, so it gets its own JIRA and PR rather than riding along
here.
### How was this patch tested?
Added three cases to `test_floor_divide_func`: divisors that binary cannot
represent exactly, integral operands above 2^53, and `-2**63 // -1`. They
compare exactly rather than with `almost=True`, which would let an off-by-one
through at these magnitudes.
Ran `pyspark.pandas.tests.test_numpy_compat` and
`pyspark.pandas.tests.connect.test_parity_numpy_compat`; both pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)
Closes #58306 from Spenserrrr/numpy-floordiv-precision.
Authored-by: Spenser Sun <[email protected]>
Signed-off-by: Ruifeng Zheng <[email protected]>
---
python/pyspark/pandas/numpy_compat.py | 111 ++++++++++++++++-------
python/pyspark/pandas/tests/test_numpy_compat.py | 50 ++++++++--
2 files changed, 121 insertions(+), 40 deletions(-)
diff --git a/python/pyspark/pandas/numpy_compat.py
b/python/pyspark/pandas/numpy_compat.py
index 865213080d77..aaa7d843cd75 100644
--- a/python/pyspark/pandas/numpy_compat.py
+++ b/python/pyspark/pandas/numpy_compat.py
@@ -187,52 +187,99 @@ def _logaddexp_func(c1: Column, c2: Column, base2: bool =
False) -> Column:
)
+def _floor_divide_floating(c1: Column, c2: Column) -> Column:
+ """Return floor(c1 / c2) for finite non-zero double operands, derived from
the remainder.
+
+ Flooring the quotient is wrong when the division rounds up across an
integer: 1.0 / 0.1
+ rounds to exactly 10.0, so its floor is 10 where NumPy, pandas and Python
return 9. A
+ remainder is exact, so NumPy's npy_divmod derives the quotient from it, as
this does.
+ """
+ remainder = F.try_mod(c1, c2)
+ # The remainder carries the dividend's sign, so this is the truncating
quotient.
+ truncated = (c1 - remainder) / c2
+ # Truncating and flooring differ by one on opposite signs with a remainder
left over.
+ quotient = F.when(
+ (remainder != 0) & ((remainder < 0) != (c2 < 0)), truncated -
F.lit(1.0)
+ ).otherwise(truncated)
+ # The quotient is whole in exact arithmetic, but the division can leave it
a few bits off, so
+ # round it back. F.floor cannot do this: it returns a bigint, which raises
on an infinity.
+ floor = quotient - F.pmod(quotient, F.lit(1.0))
+ return (
+ # An infinite quotient is its own floor, and has to be returned before
the line above
+ # is used, since pmod of an infinity is nan and leaves `floor` nan.
+ F.when(quotient.isin(float("inf"), float("-inf")), quotient)
+ # Flooring goes one too low when the division landed just under the
whole number.
+ .when(quotient - floor > F.lit(0.5), floor + F.lit(1.0))
+ .otherwise(floor)
+ )
+
+
+def _floor_divide_integral(c1: Column, c2: Column) -> Column:
+ """Return floor(c1 / c2) for integral operands, keeping the quotient in
integer space.
+
+ Casting an operand above 2**53 to double drops its low bits, turning
9007199254740993 into
+ 9007199254740992, and Spark's `/` always divides as double. The long casts
are no-ops for
+ the integral types the caller admits; they are there because `div` rejects
a double even in
+ a branch the guard turns off.
+ """
+ c1_long = c1.cast("long")
+ c2_long = c2.cast("long")
+ # `div` is integer division, truncating toward zero, so it needs the same
flooring
+ # correction as the floating helper. Integer arithmetic cannot round, so
nothing more.
+ truncated = F.call_function("div", c1_long, c2_long)
+ remainder = F.try_mod(c1_long, c2_long)
+ return F.when(
+ # The one quotient a long cannot hold, where NumPy wraps around and
`div` would raise.
+ (c1_long == F.lit(-(2**63))) & (c2_long == F.lit(-1)),
+ F.lit(float(-(2**63))),
+ ).otherwise(
+ F.when((remainder != 0) & ((remainder < 0) != (c2_long < 0)),
truncated - F.lit(1))
+ .otherwise(truncated)
+ .cast("double")
+ )
+
+
def _floor_divide_func(c1: Column, c2: Column) -> Column:
c1_double = c1.cast("double")
c2_double = c2.cast("double")
+ integral_types = ["tinyint", "smallint", "int", "bigint"]
- return F.when(
- F.typeof(c1).isin("float", "double") | F.typeof(c2).isin("float",
"double"),
+ return (
+ # Null, nan and a zero divisor are handled the same way for every
operand type.
F.when(c1.isNull() | F.isnan(c1), c1_double)
.when(c2.isNull() | F.isnan(c2), c2_double)
+ # pandas upcasts a zero divisor instead of raising. A negative zero
divisor negates the
+ # result, and no comparison can see that sign, so the string form is
used. A nullable Int64
+ # returns 0 instead, but arrives as bigint like a default int64, whose
answer this follows.
.when(
- c1_double.isin(float("-inf"), float("inf")),
- F.when(
- c2_double == 0,
- F.when(
- (c1_double < 0) != (c2_double.cast("string") == "-0.0"),
- F.lit(float("-inf")),
- ).otherwise(F.lit(float("inf"))),
- ).otherwise(F.lit(float("nan"))),
+ c2_double == 0,
+ F.when(c1_double == 0, F.lit(float("nan")))
+ .when((c1_double < 0) != (c2_double.cast("string") == "-0.0"),
F.lit(float("-inf")))
+ .otherwise(F.lit(float("inf"))),
)
+ # Integral operands divide as integers, so operands above 2**53 keep
their low bits.
.when(
- c2_double.isin(float("-inf"), float("inf")),
- F.when(c1_double == 0, c1_double / c2_double)
- .when((c1_double < 0) != (c2_double < 0), F.lit(-1.0))
- .otherwise(F.lit(0.0)),
+ F.typeof(c1).isin(integral_types) &
F.typeof(c2).isin(integral_types),
+ _floor_divide_integral(c1, c2),
)
+ # Only floating operands can be infinite or a negative zero, handled
before the division.
.when(
- c2_double == 0,
- F.when(c1_double == 0, F.lit(float("nan")))
+ F.typeof(c1).isin("float", "double") | F.typeof(c2).isin("float",
"double"),
+ # An infinite dividend has no remainder, so NumPy's quotient is
nan for any divisor.
+ F.when(c1_double.isin(float("-inf"), float("inf")),
F.lit(float("nan")))
+ # An infinite divisor gives a quotient between -1 and 1, so the
floor is 0 or -1.
.when(
- (c1_double < 0) != (c2_double.cast("string") == "-0.0"),
- F.lit(float("-inf")),
+ c2_double.isin(float("-inf"), float("inf")),
+ F.when(c1_double == 0, c1_double / c2_double)
+ .when((c1_double < 0) != (c2_double < 0), F.lit(-1.0))
+ .otherwise(F.lit(0.0)),
)
- .otherwise(F.lit(float("inf"))),
- )
- .when(c1_double == 0, c1_double / c2_double)
- .otherwise((c1_double / c2_double) - F.pmod(c1_double / c2_double,
F.lit(1.0))),
- ).otherwise(
- # np.floor_divide on pandas Series returns IEEE values for an integral
zero divisor.
- F.when(c1.isNull() | F.isnan(c1), c1_double)
- .when(c2.isNull() | F.isnan(c2), c2_double)
- .when(
- c2_double == 0,
- F.when(c1_double == 0, F.lit(float("nan")))
- .when(c1_double < 0, F.lit(float("-inf")))
- .otherwise(F.lit(float("inf"))),
+ # Dividing a zero dividend keeps its sign, so -0.0 // 3.0 is -0.0.
+ .when(c1_double == 0, c1_double / c2_double)
+ .otherwise(_floor_divide_floating(c1_double, c2_double)),
)
- .otherwise((c1_double / c2_double) - F.pmod(c1_double / c2_double,
F.lit(1.0)))
+ # A decimal cannot be matched by name: typeof reports its precision,
as in decimal(10,2).
+ .otherwise(_floor_divide_floating(c1_double, c2_double))
)
diff --git a/python/pyspark/pandas/tests/test_numpy_compat.py
b/python/pyspark/pandas/tests/test_numpy_compat.py
index 4b3f24427103..a733155b7199 100644
--- a/python/pyspark/pandas/tests/test_numpy_compat.py
+++ b/python/pyspark/pandas/tests/test_numpy_compat.py
@@ -365,6 +365,15 @@ class NumPyCompatTestsMixin:
def test_floor_divide_func(self):
from pyspark.pandas.numpy_compat import _floor_divide_func
+ def floor_divided(pdf):
+ psdf = ps.from_pandas(pdf)
+ return (
+ psdf.spark.frame()
+ .select(_floor_divide_func(F.col("x1"),
F.col("x2")).alias("result"))
+ .toPandas()["result"]
+ .rename(None)
+ )
+
for pdf in (
pd.DataFrame(
{
@@ -419,14 +428,39 @@ class NumPyCompatTestsMixin:
}
),
):
- psdf = ps.from_pandas(pdf)
- result = (
- psdf.spark.frame()
- .select(_floor_divide_func(F.col("x1"),
F.col("x2")).alias("result"))
- .toPandas()["result"]
- .rename(None)
- )
- self.assert_eq(result, np.floor_divide(pdf.x1, pdf.x2),
almost=True)
+ self.assert_eq(floor_divided(pdf), np.floor_divide(pdf.x1,
pdf.x2), almost=True)
+
+ # Divisors binary cannot represent exactly, where the quotient rounds
up across an
+ # integer: 1.0 / 0.1 rounds to 10.0, so flooring it gives 10 instead
of 9. Compared
+ # exactly, since almost=True would accept an off-by-one on the large
values below.
+ pdf = pd.DataFrame(
+ {
+ "x1": [1.0, 10.0, 2.0, 0.5, 7.0, -1.0, -10.0, 3.0],
+ "x2": [0.1, 0.1, 0.2, 0.1, 0.7, 0.1, 0.1, 7.0],
+ }
+ )
+ self.assert_eq(floor_divided(pdf), np.floor_divide(pdf.x1, pdf.x2))
+
+ # Integral operands above 2**53, where casting an operand to double
would drop its
+ # low bits: -9007199254740993 // 2 is -4503599627370497, not
-4503599627370496.
+ pdf = pd.DataFrame(
+ {
+ "x1": [9007199254740993, -9007199254740993,
4611686018427387905, 7, -7],
+ "x2": [1, 2, 3, 3, 3],
+ }
+ )
+ self.assert_eq(floor_divided(pdf), np.floor_divide(pdf.x1,
pdf.x2).astype("float64"))
+
+ # The most negative long divided by -1, whose quotient a long cannot
hold. NumPy wraps
+ # around, while Spark's integer division raises.
+ pdf = pd.DataFrame({"x1": [-(2**63), -(2**63)], "x2": [-1, 2]})
+ self.assert_eq(floor_divided(pdf), np.floor_divide(pdf.x1,
pdf.x2).astype("float64"))
+
+ # Finite operands whose quotient overflows to an infinity, which is
its own floor.
+ pdf = pd.DataFrame(
+ {"x1": [1e300, -1e300, 1e300, -1e300], "x2": [1e-300, 1e-300,
-1e-300, -1e-300]}
+ )
+ self.assert_eq(floor_divided(pdf), np.floor_divide(pdf.x1, pdf.x2))
def test_np_logaddexp(self):
for pdf in (
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]