Repository: pandas-dev/pandas
Pull request: API: Handle pow & rpow special cases
Closes https://github.com/pandas-dev/pandas/issues/29997

@jorisvandenbossche do you have thoughts on where to document this behavior? I added a bit to `reference/arrays.rst` for now, but perhaps we'll eventually have a `user_guide/scalar_na.rst`?


Problem statement:
API: expected result for pow(1, pd.NA) or pow(pd.NA, 0)
Should `pow(1, pd.NA)` be `1` or `NA`?

```python
In [1]: import pandas as pd

In [2]: 1 ** pd.NA
Out[2]: NA

In [3]: import numpy as np

In [5]: 1 ** np.nan
Out[5]: 1.0
```

cc @jorisvandenbossche

Hints from issue discussion:
R gives `1`

```r
> 1 ^ NA
[1] 1
```
Julia seems to do missing:

```
julia> 1 ^ missing
missing
```

But I think the rationale of returning 1 (i.e. "whathever number is used, 1 to the power something will always be 1") also makes sense.
Likewise there's an inconsistency with `np.nan` for `pow(pd.NA, 0)`:
```python
In [1]: import numpy as np; import pandas as pd; pd.__version__
Out[1]: '0.26.0.dev0+1155.ged20822a5'

In [2]: pd.NA ** 0
Out[2]: NA

In [3]: np.nan ** 0
Out[3]: 1.0
```

R also gives 1 then:

```
> NA ^ 0
[1] 1
```
I am +1 changing those two cases

Commit message: revert

Patch to review:
diff --git a/doc/source/user_guide/missing_data.rst b/doc/source/user_guide/missing_data.rst
index 1cc485a229123..8b4a3cbed10ff 100644
--- a/doc/source/user_guide/missing_data.rst
+++ b/doc/source/user_guide/missing_data.rst
@@ -832,6 +832,12 @@ return ``False``.
    pd.NA == pd.NA
    pd.NA < 2.5
 
+There are a few special cases when the result is known, even when one of the
+operands is ``NA``.
+
+* ``pd.NA ** 0`` is always 0.
+* ``1 ** pd.NA`` is always 1.
+
 To check if a value is equal to ``pd.NA``, the :func:`isna` function can be
 used:
 
diff --git a/pandas/_libs/missing.pyx b/pandas/_libs/missing.pyx
index 30832a8e4daab..d0ead37806ae7 100644
--- a/pandas/_libs/missing.pyx
+++ b/pandas/_libs/missing.pyx
@@ -365,8 +365,6 @@ class NAType(C_NAType):
     __rmod__ = _create_binary_propagating_op("__rmod__")
     __divmod__ = _create_binary_propagating_op("__divmod__", divmod=True)
     __rdivmod__ = _create_binary_propagating_op("__rdivmod__", divmod=True)
-    __pow__ = _create_binary_propagating_op("__pow__")
-    __rpow__ = _create_binary_propagating_op("__rpow__")
     # __lshift__ and __rshift__ are not implemented
 
     __eq__ = _create_binary_propagating_op("__eq__")
@@ -383,6 +381,29 @@ class NAType(C_NAType):
     __abs__ = _create_unary_propagating_op("__abs__")
     __invert__ = _create_unary_propagating_op("__invert__")
 
+    # pow has special
+    def __pow__(self, other):
+        if other is C_NA:
+            return NA
+        elif isinstance(other, (numbers.Number, np.bool_)):
+            if other == 0:
+                return other
+            else:
+                return NA
+
+        return NotImplemented
+
+    def __rpow__(self, other):
+        if other is C_NA:
+            return NA
+        elif isinstance(other, (numbers.Number, np.bool_)):
+            if other == 1:
+                return other
+            else:
+                return NA
+
+        return NotImplemented
+
     # Logical ops using Kleene logic
 
     def __and__(self, other):
diff --git a/pandas/tests/scalar/test_na_scalar.py b/pandas/tests/scalar/test_na_scalar.py
index 586433698a587..3c29fe38b704e 100644
--- a/pandas/tests/scalar/test_na_scalar.py
+++ b/pandas/tests/scalar/test_na_scalar.py
@@ -38,11 +38,14 @@ def test_arithmetic_ops(all_arithmetic_functions):
     op = all_arithmetic_functions
 
     for other in [NA, 1, 1.0, "a", np.int64(1), np.nan]:
-        if op.__name__ == "rmod" and isinstance(other, str):
+        if op.__name__ in ("pow", "rpow", "rmod") and isinstance(other, str):
             continue
         if op.__name__ in ("divmod", "rdivmod"):
             assert op(NA, other) is (NA, NA)
         else:
+            if op.__name__ == "rpow":
+                # avoid special case
+                other += 1
             assert op(NA, other) is NA
 
 
@@ -69,6 +72,25 @@ def test_comparison_ops():
         assert (other <= NA) is NA
 
 
+@pytest.mark.parametrize(
+    "value", [0, 0.0, False, np.bool_(False), np.int_(0), np.float_(0)]
+)
+def test_pow_special(value):
+    result = pd.NA ** value
+    assert isinstance(result, type(value))
+    assert result == 0
+
+
+@pytest.mark.parametrize(
+    "value", [1, 1.0, True, np.bool_(True), np.int_(1), np.float_(1)]
+)
+def test_rpow_special(value):
+    result = value ** pd.NA
+    assert result == 1
+    if not isinstance(value, (np.float_, np.bool_, np.int_)):
+        assert isinstance(result, type(value))
+
+
 def test_unary_ops():
     assert +NA is NA
     assert -NA is NA


Code review comments:
diff_hunk: @@ -832,6 +832,12 @@ return ``False``.
    pd.NA == pd.NA
    pd.NA < 2.5
 
+There are a few special cases when the result is known, even when one of the
+operands is ``NA``.
+
+* ``pd.NA ** 0`` is always 0.
path: doc/source/user_guide/missing_data.rst
text: @user1:
```suggestion
* ``pd.NA ** 0`` is always 1.
```

@author:
Whoops, yes.

diff_hunk: @@ -383,6 +381,29 @@ class NAType(C_NAType):
     __abs__ = _create_unary_propagating_op("__abs__")
     __invert__ = _create_unary_propagating_op("__invert__")
 
+    # pow has special
+    def __pow__(self, other):
+        if other is C_NA:
+            return NA
+        elif isinstance(other, (numbers.Number, np.bool_)):
+            if other == 0:
+                return other
path: pandas/_libs/missing.pyx
text: @user1:
This should actually be 1 right?
But, the question is then if this should be int or float, and I suppose it should be of the same type as other?

@author:
Making it the same type as other is probably best.