Repository: geopandas/geopandas

Issue:
BUG: NaN now None when calling df.apply? (0.10.2 -> 0.11)
- [X] I have checked that this issue has not already been reported.
- [X] I have confirmed this bug exists on the latest version of geopandas.

---
y information for us to reproduce your bug.

#### Code Sample, a copy-pastable example

```python
def test_geopanda(request):
    geojson_path = path.join(request.fspath.dirname, "test_data/test.geojson")
    x = gpd.read_file(geojson_path)
    x["test_calc"] = x.apply(
        lambda x: float('NaN'),
        axis=1,
    )
    assert math.isnan(x["test_calc"][0])
```

#### Problem description

Previously when our lambda inside apply returned NaN, the result of `apply` was NaN for all rows. When upgrading to 0.11, it now returns NoneType instead. I couldn't see this documented as a known breaking change anywhere?

#### Expected Output

Test passes in 0.10.2, fails in 0.11

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.9.13 | packaged by conda-forge | (main, May 27 2022, 17:01:00)  [Clang 13.0.1 ]
executable : /Users/jcrowley/miniconda3/envs/dataprocessing_env/bin/python3
machine    : macOS-12.3.1-arm64-arm-64bit

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.10.3
GEOS lib   : /Users/jcrowley/miniconda3/envs/dataprocessing_env/lib/libgeos_c.dylib
GDAL       : 3.5.0
GDAL data dir: /Users/jcrowley/miniconda3/envs/dataprocessing_env/share/gdal
PROJ       : 9.0.1
PROJ data dir: /Users/jcrowley/miniconda3/envs/dataprocessing_env/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.11.0
pandas     : 1.4.3
fiona      : 1.8.21
numpy      : 1.23.0
shapely    : 1.8.2
rtree      : 1.0.0
pyproj     : 3.3.1
matplotlib : 3.5.2
mapclassify: 2.4.3
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : None
pygeos     : None

</details>



Discussion:
Ah, thanks for the report! That is not intentional. What actually happens here is that instead of returning `float` dtype, we somehow convert this columns to a `GeometryDtype`, which uses None to represent missing values. 

It is caused by changes in #2332 which fixed one regression but apparently introduced another one... (cc @m-richards)

The issue happens here

https://github.com/geopandas/geopandas/blob/28462f8e7fd893e01be52f444e142a1a00c36f8e/geopandas/geodataframe.py#L1511-L1518

The `GeoDataFrame.apply` returns a Series, and we try to ensure that if that contains geometry it has a correct dtype. Normally, float series would go through the `except` here but when it has only missing values, the `_ensure_geometry` passes and converts the array to GeometryDtype, hence turning NaNs into Nones.

@martinfleis thanks! I don't suppose there's a work-around we can try, while continuing to use 0.11 until a patch is out? Due to a [bug in conda-lock](https://github.com/conda-incubator/conda-lock/issues/202) we're unable to avoid the upgraded package. 
You can try something like this but I am not sure how robust it will be in your case.

```py
r = x.apply(
  lambda x: float('NaN'),
  axis=1,
)
if r.isna().all() and r.dtype == "geometry":
    r = r.astype(float)
x["test_calc"] = r
```

@martinfleis much appreciated :)

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1508,14 +1508,17 @@ def apply(self, func, axis=0, raw=False, result_type=None, args=(), **kwargs):
             else:
                 if self.crs is not None and result.crs is None:
                     result.set_crs(self.crs, inplace=True)
-        elif isinstance(result, Series):
-            # Reconstruct series GeometryDtype if lost by apply
-            try:
-                # Note CRS cannot be preserved in this case as func could refer
-                # to any column
-                result = _ensure_geometry(result)
-            except TypeError:
-                pass
+        elif isinstance(result, Series) and result.dtype == "object":
+            # Try reconstruct series GeometryDtype if lost by apply
+            # If all none and object dtype assert list of nones is more likely
+            # intended than list of null geometry.
+            if not result.isna().all():
+                try:
+                    # not enough info about func to preserve CRS
+                    result = _ensure_geometry(result)
+
+                except TypeError:
+                    pass
 
         return result
 


Test patch:
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -655,6 +655,12 @@ def test_df_apply_returning_series(df):
 
     result = df.apply(lambda row: row.value1, axis=1)
     assert_series_equal(result, df["value1"].rename(None))
+    # https://github.com/geopandas/geopandas/issues/2480
+    result = df.apply(lambda x: float("NaN"), axis=1)
+    assert result.dtype == "float64"
+    # assert list of nones is not promoted to GeometryDtype
+    result = df.apply(lambda x: None, axis=1)
+    assert result.dtype == "object"
 
 
 def test_preserve_attrs(df):