Repository: narwhals-dev/narwhals
Pull request #696 (2024-07-31 15:24:44)

feat: add `round` to Arrow
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #637 #537 #695
- Closes #

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.
I was going to the various methods and I found out that `round` was not yet implemented for `arrow`.

So..this one can be a bit problematic as I've seen on #537.
[The discussion on different rounding modes](https://github.com/narwhals-dev/narwhals/pull/306/files#r1641848166) seems to imply that polars is rounding "towards zero". 
~~However,~~ when I tried to specify Pyarrow's `"towards_zero"` rounding mode, the tests were failing:

```shell
E       assert [1.1, 2.5, 3.9] == [1.1, 2.6, 3.9]
E         
E         At index 1 diff: 2.5 != 2.6
E         Use -v to get more diff

tests/expr_and_series/round_test.py:31: AssertionError
```

With the default `"half_to_even"` roundign mode, the tests are passing!

Are we ok to settle it with `"half_to_even"`  for now?
Here is a snippet from [NumPy's docs](https://numpy.org/doc/stable/reference/generated/numpy.round.html) for ref:
>For values exactly halfway between rounded decimal values, NumPy rounds to the nearest even value. Thus 1.5 and 2.5 round to 2.0, -0.5 and 0.5 round to 0.0, etc.


So I understand that polars rounding != (pands, numpy, python,etc.) 
When passing 2.5 in the testing data, polars is the only one to fail but I am not able to repeat this behaviour with the rounding_modes in arrow.  



FBruzzesi: Sorry I just read the comments on specific rounding.

I think polars does the equivalent of pyarrow `round_mode = "half_towards_infinity"`, shall we try that?
lucianosrp: @FBruzzesi  - What about now? 

I think it is worth noting that if we were to add 2.5 in the test case, both polars and pyarrow would fail.
![image](https://github.com/user-attachments/assets/a6f830b8-89a3-4d60-9172-3d6aea518ffb)

FBruzzesi: I think that's because the built-in round has same behaviour of pandas, i.e. round to closest even. If we want to follow Polars then arrow allows for that.

Maybe we need to build expected manually instead of using round in the first place

Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 7e7a0d20..1fcfd528 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -182,6 +182,9 @@ class ArrowExpr:
     def cum_sum(self) -> Self:
         return reuse_series_implementation(self, "cum_sum")
 
+    def round(self, decimals: int) -> Self:
+        return reuse_series_implementation(self, "round", decimals)
+
     def any(self) -> Self:
         return reuse_series_implementation(self, "any", returns_scalar=True)
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 47910867..731db14a 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -288,6 +288,12 @@ class ArrowSeries:
         pc = get_pyarrow_compute()
         return self._from_native_series(pc.cumulative_sum(self._native_series))
 
+    def round(self, decimals: int) -> Self:
+        pc = get_pyarrow_compute()
+        return self._from_native_series(
+            pc.round(self._native_series, decimals, round_mode="half_towards_infinity")
+        )
+
     def diff(self) -> Self:
         pc = get_pyarrow_compute()
         return self._from_native_series(
diff --git a/narwhals/expr.py b/narwhals/expr.py
index b2049357..195c6470 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -1638,12 +1638,12 @@ class Expr:
             decimals: Number of decimals to round by.
 
         Notes:
-            For values exactly halfway between rounded decimal values pandas and Polars behave differently.
+            For values exactly halfway between rounded decimal values pandas behaves differently than Polars and Arrow.
 
             pandas rounds to the nearest even value (e.g. -0.5 and 0.5 round to 0.0, 1.5 and 2.5 round to 2.0, 3.5 and
             4.5 to 4.0, etc..).
 
-            Polars rounds away from 0 (e.g. -0.5 to -1.0, 0.5 to 1.0, 1.5 to 2.0, 2.5 to 3.0, etc..).
+            Polars and Arrow round away from 0 (e.g. -0.5 to -1.0, 0.5 to 1.0, 1.5 to 2.0, 2.5 to 3.0, etc..).
 
 
         Examples:
diff --git a/narwhals/series.py b/narwhals/series.py
index 27a59472..7caee0c7 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -1934,12 +1934,12 @@ class Series:
             decimals: Number of decimals to round by.
 
         Notes:
-            For values exactly halfway between rounded decimal values pandas and Polars behave differently.
+            For values exactly halfway between rounded decimal values pandas behaves differently than Polars and Arrow.
 
             pandas rounds to the nearest even value (e.g. -0.5 and 0.5 round to 0.0, 1.5 and 2.5 round to 2.0, 3.5 and
             4.5 to 4.0, etc..).
 
-            Polars rounds away from 0 (e.g. -0.5 to -1.0, 0.5 to 1.0, 1.5 to 2.0, 2.5 to 3.0, etc..).
+            Polars and Arrow round away from 0 (e.g. -0.5 to -1.0, 0.5 to 1.0, 1.5 to 2.0, 2.5 to 3.0, etc..).
 
         Examples:
             >>> import narwhals as nw


Test patch:
diff --git a/tests/expr_and_series/round_test.py b/tests/expr_and_series/round_test.py
index 0c42faf3..cec8ea51 100644
--- a/tests/expr_and_series/round_test.py
+++ b/tests/expr_and_series/round_test.py
@@ -12,9 +12,7 @@ from tests.utils import compare_dicts
 def test_round(request: Any, constructor: Any, decimals: int) -> None:
     if "dask" in str(constructor):
         request.applymarker(pytest.mark.xfail)
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-    data = {"a": [1.12345, 2.56789, 3.901234]}
+    data = {"a": [2.12345, 2.56789, 3.901234]}
     df_raw = constructor(data)
     df = nw.from_native(df_raw)
 
@@ -24,9 +22,7 @@ def test_round(request: Any, constructor: Any, decimals: int) -> None:
 
 
 @pytest.mark.parametrize("decimals", [0, 1, 2])
-def test_round_series(request: Any, constructor_eager: Any, decimals: int) -> None:
-    if "pyarrow_table" in str(constructor_eager):
-        request.applymarker(pytest.mark.xfail)
+def test_round_series(constructor_eager: Any, decimals: int) -> None:
     data = {"a": [1.12345, 2.56789, 3.901234]}
     df_raw = constructor_eager(data)
     df = nw.from_native(df_raw, eager_only=True)