Repository: geopandas/geopandas

Issue:
BUG: GeoSeries.to_frame when name is not 'geometry' doesn't set geometry column
- [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.

- [x] (optional) I have confirmed this bug exists on the master branch of geopandas.

---

**Note**: Please read [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.

#### Code Sample, a copy-pastable example

```python
In [2]: import geopandas

In [3]:  df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_cities"))

In [4]: srs = df['geometry']

In [5]: type(srs)
Out[5]: geopandas.geoseries.GeoSeries

In [6]: srs.name = 'foo'

In [7]: srs.to_frame().geometry
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-7-ee808c91f778> in <module>
----> 1 srs.to_frame().geometry

C:\MyPrograms\Miniconda3\envs\gpd_latest_conda_forge\lib\site-packages\pandas\core\generic.py in __getattr__(self, name)
   5476         ):
   5477             return self[name]
-> 5478         return object.__getattribute__(self, name)
   5479
   5480     def __setattr__(self, name: str, value) -> None:

c:\data\mydocuments\code\geopandas_test\geopandas-m-richards\geopandas\geodataframe.py in _get_geometry(self)
    196     def _get_geometry(self):
    197         if self._geometry_column_name not in self:
--> 198             raise AttributeError(
    199                 "No geometry data set yet (expected in"
    200                 " column '%s'.)" % self._geometry_column_name

AttributeError: No geometry data set yet (expected in column 'geometry'.)

In [8]: srs.name = 'geometry'

In [9]: srs.to_frame().geometry
Out[9]:
0        POINT (12.45339 41.90328)
1        POINT (12.44177 43.93610)
2         POINT (9.51667 47.13372)
3         POINT (6.13000 49.61166)
4        POINT (158.14997 6.91664)
                  ...
197      POINT (31.24802 30.05191)
198     POINT (139.74946 35.68696)
199       POINT (2.33139 48.86864)
200    POINT (-70.66899 -33.44807)
201      POINT (103.85387 1.29498)
Name: geometry, Length: 202, dtype: geometry
```

#### Problem description
`GeoSeries.to_frame()` should produce valid geometry, regardless of whether the series name is geometry.

#### Expected Output
As in [9]. (Edit: also `srs.to_frame().crs` should be preserved and not None)

I also think that if `GeoSeries.name = None` it might make more sense to produce a GeoDataFrame with a single column "geometry" rather than '0' that is default. 

Yet to look into properly, but I think this probably could be fixed by expanding `GeoSeries._constructor_expanddim`, similar to some of the other stuff I've been looking at.

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.8.10 | packaged by conda-forge | (default, May 11 2021, 06:25:23) [MSC v.1916 64 bit (AMD64)]
executable : C:\MyPrograms\Miniconda3\envs\gpd_latest_conda_forge\python.exe
machine    : Windows-10-10.0.19043-SP0

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : None
GEOS lib   : None
GDAL       : 3.3.1
GDAL data dir: None
PROJ       : 8.0.1
PROJ data dir: C:\MyPrograms\Miniconda3\envs\gpd_latest_conda_forge\Library\share\proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.9.0+59.g4536b92
pandas     : 1.3.1
fiona      : 1.8.20
numpy      : 1.21.1
shapely    : 1.7.1
rtree      : 0.9.7
pyproj     : 3.1.0
matplotlib : 3.4.2
mapclassify: 2.4.3
geopy      : 2.2.0
psycopg2   : 2.9.1 (dt dec pq3 ext lo64)
geoalchemy2: None
pyarrow    : 5.0.0
pygeos     : 0.10.1

</details>



Discussion:
Was able to reproduce this.

**geopandas.show_versions()** output:

```
SYSTEM INFO
-----------
python     : 3.9.6 | packaged by conda-forge | (default, Jul 11 2021, 03:39:48)  [GCC 9.3.0]
executable : /home/eemil/miniconda3/envs/minimal/bin/python
machine    : Linux-5.11.0-7620-generic-x86_64-with-glibc2.33

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.9.1
GEOS lib   : /home/eemil/miniconda3/envs/minimal/lib/libgeos_c.so
GDAL       : 3.3.1
GDAL data dir: /home/eemil/miniconda3/envs/minimal/share/gdal
PROJ       : 8.0.1
PROJ data dir: /home/eemil/miniconda3/envs/minimal/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.9.0
pandas     : 1.3.2
fiona      : 1.8.20
numpy      : 1.21.2
shapely    : 1.7.1
rtree      : 0.9.7
pyproj     : 3.1.0
matplotlib : 3.4.3
mapclassify: 2.4.3
geopy      : None
psycopg2   : None
geoalchemy2: None
pyarrow    : None
pygeos     : None
```

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -312,7 +312,7 @@ def set_geometry(self, col, drop=False, inplace=False, crs=None):
         geo_column_name = self._geometry_column_name
         if isinstance(col, (Series, list, np.ndarray, GeometryArray)):
             level = col
-        elif hasattr(col, "ndim") and col.ndim != 1:
+        elif hasattr(col, "ndim") and col.ndim > 1:
             raise ValueError("Must pass array with one dimension only.")
         else:
             try:
@@ -1470,6 +1470,11 @@ def __finalize__(self, other, method=None, **kwargs):
                     f"Please ensure this column from the first DataFrame is not "
                     f"repeated."
                 )
+        elif method == "unstack":
+            # unstack adds multiindex columns and reshapes data.
+            # it never makes sense to retain geometry column
+            self._geometry_column_name = None
+            self._crs = None
         return self
 
     def dissolve(
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -3,7 +3,7 @@
 
 import numpy as np
 import pandas as pd
-from pandas import Series, MultiIndex
+from pandas import Series, MultiIndex, DataFrame
 from pandas.core.internals import SingleBlockManager
 
 from pyproj import CRS
@@ -53,6 +53,35 @@ def _geoseries_constructor_with_fallback(data=None, index=None, crs=None, **kwar
         return Series(data=data, index=index, **kwargs)
 
 
+def _geoseries_expanddim(data=None, *args, **kwargs):
+    from geopandas import GeoDataFrame
+
+    # pd.Series._constructor_expanddim == pd.DataFrame
+    df = pd.DataFrame(data, *args, **kwargs)
+    geo_col_name = None
+    if isinstance(data, GeoSeries):
+        # pandas default column name is 0, keep convention
+        geo_col_name = data.name if data.name is not None else 0
+
+    if df.shape[1] == 1:
+        geo_col_name = df.columns[0]
+
+    if (df.dtypes == "geometry").sum() > 0:
+        if geo_col_name is None or not is_geometry_type(df[geo_col_name]):
+            df = GeoDataFrame(df)
+            df._geometry_column_name = None
+        else:
+            df = df.set_geometry(geo_col_name)
+
+    return df
+
+
+# pd.concat (pandas/core/reshape/concat.py) requires this for the
+# concatenation of series since pandas 1.1
+# (https://github.com/pandas-dev/pandas/commit/f9e4c8c84bcef987973f2624cc2932394c171c8c)
+_geoseries_expanddim._get_axis_number = DataFrame._get_axis_number
+
+
 class GeoSeries(GeoPandasBase, Series):
     """
     A Series object designed to store shapely geometry objects.
@@ -587,9 +616,7 @@ def _constructor(self):
 
     @property
     def _constructor_expanddim(self):
-        from geopandas import GeoDataFrame
-
-        return GeoDataFrame
+        return _geoseries_expanddim
 
     def _wrapped_pandas_method(self, mtd, *args, **kwargs):
         """Wrap a generic pandas method to ensure it returns a GeoSeries"""


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -334,6 +334,11 @@ def test_set_geometry_empty(self):
         assert isinstance(result, GeoDataFrame)
         assert isinstance(result.index, pd.DatetimeIndex)
 
+    def test_set_geometry_np_int(self):
+        self.df.loc[:, 0] = self.df.geometry
+        df = self.df.set_geometry(np.int64(0))
+        assert df.geometry.name == 0
+
     def test_get_geometry_invalid(self):
         df = GeoDataFrame()
         df["geom"] = self.df.geometry
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -7,7 +7,7 @@
 import numpy as np
 from numpy.testing import assert_array_equal
 import pandas as pd
-from pandas.util.testing import assert_index_equal
+from pandas.testing import assert_index_equal
 
 from pyproj import CRS
 from shapely.geometry import (
@@ -538,13 +538,38 @@ def test_from_series(self):
         assert s.index is g.index
 
     # GH 1216
-    def test_expanddim(self):
+    @pytest.mark.parametrize("name", [None, "geometry", "Points"])
+    @pytest.mark.parametrize("crs", [None, "epsg:4326"])
+    def test_reset_index(self, name, crs):
         s = GeoSeries(
-            [MultiPoint([(0, 0), (1, 1)]), MultiPoint([(2, 2), (3, 3), (4, 4)])]
+            [MultiPoint([(0, 0), (1, 1)]), MultiPoint([(2, 2), (3, 3), (4, 4)])],
+            name=name,
+            crs=crs,
         )
         s = s.explode(index_parts=True)
         df = s.reset_index()
         assert type(df) == GeoDataFrame
+        # name None -> 0, otherwise name preserved
+        assert df.geometry.name == (name if name is not None else 0)
+        assert df.crs == s.crs
+
+    @pytest.mark.parametrize("name", [None, "geometry", "Points"])
+    @pytest.mark.parametrize("crs", [None, "epsg:4326"])
+    def test_to_frame(self, name, crs):
+        s = GeoSeries([Point(0, 0), Point(1, 1)], name=name, crs=crs)
+        df = s.to_frame()
+        assert type(df) == GeoDataFrame
+        # name None -> 0, otherwise name preserved
+        expected_name = name if name is not None else 0
+        assert df.geometry.name == expected_name
+        assert df._geometry_column_name == expected_name
+        assert df.crs == s.crs
+
+        # if name is provided to to_frame, it should override
+        df2 = s.to_frame(name="geom")
+        assert type(df) == GeoDataFrame
+        assert df2.geometry.name == "geom"
+        assert df2.crs == s.crs
 
     def test_explode_without_multiindex(self):
         s = GeoSeries(
@@ -560,7 +585,6 @@ def test_explode_ignore_index(self):
         )
         s = s.explode(ignore_index=True)
         expected_index = pd.Index(range(len(s)))
-        print(expected_index)
         assert_index_equal(s.index, expected_index)
 
         # index_parts is ignored if ignore_index=True
diff --git a/geopandas/tests/test_merge.py b/geopandas/tests/test_merge.py
--- a/geopandas/tests/test_merge.py
+++ b/geopandas/tests/test_merge.py
@@ -1,10 +1,12 @@
 import pandas as pd
 import pytest
 from geopandas.testing import assert_geodataframe_equal
+from pandas.testing import assert_index_equal
 
 from shapely.geometry import Point
 
 from geopandas import GeoDataFrame, GeoSeries
+from geopandas import _compat as compat
 
 
 class TestMerging:
@@ -97,3 +99,23 @@ def test_concat_axis1_multiple_geodataframes(self):
         res3 = pd.concat([df2.set_crs("epsg:4326"), self.gdf], axis=1)
         # check metadata comes from first df
         self._check_metadata(res3, geometry_column_name="geom", crs="epsg:4326")
+
+    @pytest.mark.xfail(
+        not compat.PANDAS_GE_11,
+        reason="pandas <=1.0 hard codes concat([GeoSeries, GeoSeries]) -> "
+        "DataFrame or Union[DataFrame, SparseDataFrame] in 0.25",
+    )
+    def test_concat_axis1_geoseries(self):
+        gseries2 = GeoSeries([Point(i, i) for i in range(3, 6)], crs="epsg:4326")
+        result = pd.concat([gseries2, self.gseries], axis=1)
+        # Note this is not consistent with concat([gdf, gdf], axis=1) where the
+        # left metadata is set on the result. This is deliberate for now.
+        assert type(result) is GeoDataFrame
+        self._check_metadata(result, geometry_column_name=None, crs=None)
+        assert_index_equal(pd.Index([0, 1]), result.columns)
+
+        gseries2.name = "foo"
+        result2 = pd.concat([gseries2, self.gseries], axis=1)
+        assert type(result2) is GeoDataFrame
+        self._check_metadata(result2, geometry_column_name=None, crs=None)
+        assert_index_equal(pd.Index(["foo", 0]), result2.columns)
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
@@ -1,6 +1,7 @@
 import pandas as pd
 import pyproj
 import pytest
+import geopandas._compat as compat
 
 from shapely.geometry import Point
 
@@ -46,7 +47,9 @@ def _check_metadata_gs(gs, name="geometry", crs=crs_wgs):
     assert gs.crs == crs
 
 
-def assert_object(result, expected_type, geo_name="geometry", crs=crs_wgs):
+def assert_object(
+    result, expected_type, geo_name="geometry", crs=crs_wgs, check_none_name=False
+):
     """
     Helper method to make tests easier to read. Checks result is of the expected
     type. If result is a GeoDataFrame or GeoSeries, checks geo_name
@@ -60,6 +63,9 @@ def assert_object(result, expected_type, geo_name="geometry", crs=crs_wgs):
         if geo_name is not None:
             _check_metadata_gdf(result, geo_name=geo_name, crs=crs)
         else:
+            if check_none_name:  # TODO this is awkward
+                assert result._geometry_column_name is None
+
             if result._geometry_column_name is None:
                 msg = (
                     "You are calling a geospatial method on the GeoDataFrame, "
@@ -135,18 +141,11 @@ def test_squeeze(df):
 
 def test_to_frame(df):
     geo_name = df.geometry.name
-    # TODO this reflects current behaviour, but we should fix
-    #  GeoSeries._constructor_expanddim so this doesn't happen
     res1 = df[geo_name].to_frame()
-    if geo_name == "geometry":  # -> this should be doable for any geo_name
-        assert_object(res1, GeoDataFrame, geo_name)
-    assert res1._geometry_column_name == "geometry"  # -> should be geo_name
+    assert_object(res1, GeoDataFrame, geo_name, crs=df[geo_name].crs)
 
     res2 = df["geometry2"].to_frame()
-    assert type(res2) is GeoDataFrame
-    assert res2._geometry_column_name == "geometry"  # -> should be geometry2
-    assert res2.crs is None  # -> should be crs_osgb
-    # also res2.geometry should not crash because geometry isn't set
+    assert_object(res2, GeoDataFrame, "geometry2", crs=crs_osgb)
 
     res3 = df["value1"].to_frame()
     assert_object(res3, pd.DataFrame)
@@ -235,3 +234,57 @@ def identity(x):
         return x
 
     assert_object(df[["geometry2"]].apply(identity, axis=1), GeoDataFrame, None, None)
+
+
+def test_expanddim_in_apply():
+    # https://github.com/geopandas/geopandas/pull/2296#issuecomment-1021966443
+    s = GeoSeries.from_xy([0, 1], [0, 1])
+    result = s.apply(lambda x: pd.Series([x.x, x.y]))
+    assert_object(result, pd.DataFrame)
+
+
+@pytest.mark.xfail(
+    not compat.PANDAS_GE_11,
+    reason="pandas <1.1 don't preserve subclass through groupby ops",  # Pandas GH33884
+)
+def test_expandim_in_groupby_aggregate_multiple_funcs():
+    # https://github.com/geopandas/geopandas/pull/2296#issuecomment-1021966443
+    # There are two calls to _constructor_expanddim here
+    # SeriesGroupBy._aggregate_multiple_funcs() and
+    # SeriesGroupBy._wrap_series_output() len(output) > 1
+
+    s = GeoSeries.from_xy([0, 1, 2], [0, 1, 3])
+
+    def union(s):
+        return s.unary_union
+
+    def total_area(s):
+        return s.area.sum()
+
+    grouped = s.groupby([0, 1, 0])
+    agg = grouped.agg([total_area, union])
+    assert_object(agg, GeoDataFrame, None, None, check_none_name=True)
+    result = grouped.agg([union, total_area])
+    assert_object(result, GeoDataFrame, None, None, check_none_name=True)
+    assert_object(grouped.agg([total_area, total_area]), pd.DataFrame)
+    assert_object(grouped.agg([total_area]), pd.DataFrame)
+
+
+@pytest.mark.xfail(
+    not compat.PANDAS_GE_11,
+    reason="pandas <1.1 uses concat([Series]) in unstack",  # Pandas GH33356
+)
+def test_expanddim_in_unstack():
+    # https://github.com/geopandas/geopandas/pull/2296#issuecomment-1021966443
+    s = GeoSeries.from_xy(
+        [0, 1, 2],
+        [0, 1, 3],
+        index=pd.MultiIndex.from_tuples([("A", "a"), ("A", "b"), ("B", "a")]),
+    )
+    unstack = s.unstack()
+    assert_object(unstack, GeoDataFrame, None, None, False)
+
+    if compat.PANDAS_GE_12:
+        assert unstack._geometry_column_name is None
+    else:  # pandas GH37369, unstack doesn't call finalize
+        assert unstack._geometry_column_name == "geometry"