Repository: geopandas/geopandas

Issue:
REGR: indexing doesn't return geometry but Series if geometry is only column
One of the recent PRs caused a regression in indexing. If there's a single column in a GeoDataFrame, indexing using `df.loc[0].geometry` returns a Series instead of a shapely geometry (as it did before).

```py
import geopandas
df = geopandas.read_file(geopandas.datasets.get_path("nybb"))
df[["geometry"]].loc[0].geometry

geometry    MULTIPOLYGON (((970217.022 145643.332, 970227....
Name: 0, dtype: geometry
```

On 0.10.2:

```py
import geopandas
df = geopandas.read_file(geopandas.datasets.get_path("nybb"))
df[["geometry"]].loc[0].geometry

<shapely.geometry.multipolygon.MultiPolygon at 0x107377e50>
```

If there are other columns apart from geometry, it correctly returns the shapely geometry.

@m-richards I haven't looked in detail but I think it is caused by #2060, there was no other commit relevant to this.


Discussion:
It's most likely that then since it did mess with indexing, I should have a chance to look into this tomorrow sometime.

I don't have a clear hunch on why it's broken, I didn't explicitly test loc with integer indexing.
So the reason this changed is because we now try to return a GeoSeries for `df.loc[0]`. We do this in general when getting a Series out of the GeoDataFrame (through `_constructor_sliced` which was added in #2060). For getting a column as Series, this makes sense. But when getting a row, it probably is not that useful to do this ..

And because `df.loc[0]` now returns a GeoSeries if all elements of the row are geometries, `df.loc[0].geometry` then returns the same GeoSeries instead of getting the single element of the series with label "geometry" (because a GeoSeries has a `geometry` attribute which has priority over checking the index labels). 

(general workaround is to use `["geometry"]` instead of `.geometry` for label-based access, which is in general more robust)

---

For a solution, the main problem is that `_constructor_sliced` is not "aware" of whether is was used to create a row or a column, I think. So either we live with this change in behaviour, or we need to find another way to fix the cases for which we added `_constructor_sliced` (or live with reverting this specific fix). 

One example that fixed this was doing `df.iloc[:, 4]` in case that returns a geometry column (previously that returned a Series, now a GeoSeries).

That could also be fixed by overriding `iloc` (as we already do for `__getitem__` as well, although for `iloc` that would be more complicated)


One other possible solution would be to make our `_constructor_sliced` more strict in when it returns a GeoSeries vs Series. Currently that uses `_geoseries_constructor_with_fallback`, which only falls back to returning a Series if creating a GeoSeries raises an error.

But so in case of `df.iloc[:, 4]` you actually get an object with "geometry" dtype already if this is selecting a column of geometry dtype. 
While for `df.iloc[0, :]` (selecting a row), the constructor will get something more generic (typically numpy array, which is object dtype in case there are (some) geometries present).

Certainly for `GeoDataFrame._constructor_sliced`, I think it would be better to make the constructor more "strict" (so selecting a row will always give a Series, not depending on if your dataframe only has geometry columns or not (or another columns that has a missing value for that row)). 

We should maybe consider if we want to have `GeoSeries._constructor` to be more strict as well (the original use case for `_geoseries_constructor_with_fallback`), but that's a separate issue.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -13,7 +13,7 @@
 
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely, to_wkb, to_wkt
 from geopandas.base import GeoPandasBase, is_geometry_type
-from geopandas.geoseries import GeoSeries, _geoseries_constructor_with_fallback
+from geopandas.geoseries import GeoSeries
 import geopandas.io
 from geopandas.explore import _explore
 from . import _compat as compat
@@ -1450,6 +1450,14 @@ 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
 
         return result
 
@@ -1459,7 +1467,29 @@ def _constructor(self):
 
     @property
     def _constructor_sliced(self):
-        return _geoseries_constructor_with_fallback
+        def _geodataframe_constructor_sliced(data=None, index=None, crs=None, **kwargs):
+            """
+            A specialized (Geo)Series constructor which can fall back to a
+            Series if a certain operation does not produce geometries:
+
+            - We only return a GeoSeries if the data is actually of geometry
+              dtype (and so we don't try to convert geometry objects such as
+              the normal GeoSeries(..) constructor does with `_ensure_geometry`).
+            - When we get here from obtaining a row or column from a
+              GeoDataFrame, the goal is to only return a GeoSeries for a
+              geometry column, and not return a GeoSeries for a row that happened
+              to come from a DataFrame with only geometry dtype columns (and
+              thus could have a geometry dtype). Therefore, we don't return a
+              GeoSeries if we are sure we are in a row selection case (by
+              checking the identity of the index)
+            """
+            srs = pd.Series(data, index, **kwargs)
+            is_row_proxy = srs.index is self.columns
+            if is_geometry_type(srs) and not is_row_proxy:
+                srs = GeoSeries(srs, crs=crs)
+            return srs
+
+        return _geodataframe_constructor_sliced
 
     def __finalize__(self, other, method=None, **kwargs):
         """propagate metadata from other to self"""


Test patch:
diff --git a/geopandas/tests/test_op_output_types.py b/geopandas/tests/test_op_output_types.py
--- a/geopandas/tests/test_op_output_types.py
+++ b/geopandas/tests/test_op_output_types.py
@@ -4,6 +4,7 @@
 import geopandas._compat as compat
 
 from shapely.geometry import Point
+import numpy as np
 
 from geopandas import GeoDataFrame, GeoSeries
 
@@ -36,6 +37,20 @@ def df(request):
     return df
 
 
+@pytest.fixture
+def df2():
+    """For constructor_sliced tests"""
+    return GeoDataFrame(
+        {
+            "geometry": GeoSeries([Point(x, x) for x in range(3)]),
+            "geometry2": GeoSeries([Point(x, x) for x in range(3)]),
+            "geometry3": GeoSeries([Point(x, x) for x in range(3)]),
+            "value": [1, 2, 1],
+            "value_nan": np.nan,
+        }
+    )
+
+
 def _check_metadata_gdf(gdf, geo_name="geometry", crs=crs_wgs):
     assert gdf._geometry_column_name == geo_name
     assert gdf.geometry.name == geo_name
@@ -212,7 +227,9 @@ def identity(x):
     assert_object(df[["value1"]].apply(identity, axis=1), pd.DataFrame)
 
 
+@pytest.mark.xfail(not compat.PANDAS_GE_11, reason="apply is different in pandas 1.0.5")
 def test_apply_axis1_secondary_geo_cols(df):
+    # note #GH2436 would also fix this
     def identity(x):
         return x
 
@@ -271,3 +288,60 @@ def test_expanddim_in_unstack():
         assert unstack._geometry_column_name is None
     else:  # pandas GH37369, unstack doesn't call finalize
         assert unstack._geometry_column_name == "geometry"
+
+
+# indexing /  constructor_sliced tests
+
+test_case_column_sets = [
+    ["geometry"],
+    ["geometry2"],
+    ["geometry", "geometry2"],
+    # non active geo col case
+    ["geometry", "value"],
+    ["geometry", "value_nan"],
+    ["geometry2", "value"],
+    ["geometry2", "value_nan"],
+]
+
+
+@pytest.mark.parametrize(
+    "column_set",
+    test_case_column_sets,
+    ids=[", ".join(i) for i in test_case_column_sets],
+)
+def test_constructor_sliced_row_slices(df2, column_set):
+    # https://github.com/geopandas/geopandas/issues/2282
+    df_subset = df2[column_set]
+    assert isinstance(df_subset, GeoDataFrame)
+    res = df_subset.loc[0]
+    # row slices shouldn't be GeoSeries, even if they have a geometry col
+    assert type(res) == pd.Series
+    if "geometry" in column_set:
+        assert not isinstance(res.geometry, pd.Series)
+        assert res.geometry == Point(0, 0)
+
+
+def test_constructor_sliced_column_slices(df2):
+    # Note loc doesn't use _constructor_sliced so it's not tested here
+    geo_idx = df2.columns.get_loc("geometry")
+    sub = df2.head(1)
+    # column slices should be GeoSeries if of geometry type
+    assert type(sub.iloc[:, geo_idx]) == GeoSeries
+    assert type(sub.iloc[[0], geo_idx]) == GeoSeries
+    sub = df2.head(2)
+    assert type(sub.iloc[:, geo_idx]) == GeoSeries
+    assert type(sub.iloc[[0, 1], geo_idx]) == GeoSeries
+
+    # check iloc row slices are pd.Series instead
+    assert type(df2.iloc[0, :]) == pd.Series
+
+
+def test_constructor_sliced_in_pandas_methods(df2):
+    # constructor sliced is used in many places, checking a sample of non
+    # geometry cases are sensible
+    assert type(df2.count()) == pd.Series
+    # drop the secondary geometry columns as not hashable
+    hashable_test_df = df2.drop(columns=["geometry2", "geometry3"])
+    assert type(hashable_test_df.duplicated()) == pd.Series
+    assert type(df2.quantile()) == pd.Series
+    assert type(df2.memory_usage()) == pd.Series