Repository: geopandas/geopandas

Issue:
BUG: exploding gdfs with MultiIndex raises NotImplementedError 

#### Code Sample, a copy-pastable example

```python
from shapely.geometry import MultiPoint
import geopandas as gpd
import pandas as pd

index = pd.MultiIndex.from_arrays([[1, 1, 1], [1, 2, 3]], names=("first", 'second'))
df = gpd.GeoDataFrame({"vals": [1, 2, 3]}, geometry=[MultiPoint([(x, x), (x, 0)]) for x in range(3)], index=index)
df.explode()
```

#### Problem description

Calling `explode()` on a GeoDataFrame with MultiIndex fails on `NotImplementedError: isna is not defined for MultiIndex`. May be related to #1223.  See the traceback below:

<details>

```py
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-19-bc4232b19bee> in <module>
----> 1 df.explode()

~/Git/geopandas/geopandas/geodataframe.py in explode(self, column, **kwargs)
   1580             df_copy = df_copy.rename(columns={"level_1": "__level_1"})
   1581 
-> 1582         exploded_geom = df_copy.geometry.explode().reset_index(level=-1)
   1583         exploded_index = exploded_geom.columns[0]
   1584 

~/Git/geopandas/geopandas/geoseries.py in explode(self)
    831             outer_index = self.index.take(outer_idx)
    832 
--> 833             index = MultiIndex.from_arrays(
    834                 [outer_index, inner_index], names=self.index.names + [None]
    835             )

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/indexes/multi.py in from_arrays(cls, arrays, sortorder, names)
    460                 raise ValueError("all arrays must be same length")
    461 
--> 462         codes, levels = factorize_from_iterables(arrays)
    463         if names is lib.no_default:
    464             names = [getattr(arr, "name", None) for arr in arrays]

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/arrays/categorical.py in factorize_from_iterables(iterables)
   2630         # For consistency, it should return a list of 2 lists.
   2631         return [[], []]
-> 2632     return map(list, zip(*(factorize_from_iterable(it) for it in iterables)))

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/arrays/categorical.py in <genexpr>(.0)
   2630         # For consistency, it should return a list of 2 lists.
   2631         return [[], []]
-> 2632     return map(list, zip(*(factorize_from_iterable(it) for it in iterables)))

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/arrays/categorical.py in factorize_from_iterable(values)
   2602         # but only the resulting categories, the order of which is independent
   2603         # from ordered. Set ordered to False as default. See GH #15457
-> 2604         cat = Categorical(values, ordered=False)
   2605         categories = cat.categories
   2606         codes = cat.codes

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/arrays/categorical.py in __init__(self, values, categories, ordered, dtype, fastpath)
    357 
    358             # we're inferring from values
--> 359             dtype = CategoricalDtype(categories, dtype.ordered)
    360 
    361         elif is_categorical_dtype(values.dtype):

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/dtypes/dtypes.py in __init__(self, categories, ordered)
    158 
    159     def __init__(self, categories=None, ordered: Ordered = False):
--> 160         self._finalize(categories, ordered, fastpath=False)
    161 
    162     @classmethod

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/dtypes/dtypes.py in _finalize(self, categories, ordered, fastpath)
    312 
    313         if categories is not None:
--> 314             categories = self.validate_categories(categories, fastpath=fastpath)
    315 
    316         self._categories = categories

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/dtypes/dtypes.py in validate_categories(categories, fastpath)
    505         if not fastpath:
    506 
--> 507             if categories.hasnans:
    508                 raise ValueError("Categorical categories cannot be null")
    509 

pandas/_libs/properties.pyx in pandas._libs.properties.CachedProperty.__get__()

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/indexes/base.py in hasnans(self)
   2193         """
   2194         if self._can_hold_na:
-> 2195             return bool(self._isnan.any())
   2196         else:
   2197             return False

pandas/_libs/properties.pyx in pandas._libs.properties.CachedProperty.__get__()

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/indexes/base.py in _isnan(self)
   2172         """
   2173         if self._can_hold_na:
-> 2174             return isna(self)
   2175         else:
   2176             # shouldn't reach to this condition by checking hasnans beforehand

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/dtypes/missing.py in isna(obj)
    125     Name: 1, dtype: bool
    126     """
--> 127     return _isna(obj)
    128 
    129 

/opt/miniconda3/envs/geo_dev/lib/python3.9/site-packages/pandas/core/dtypes/missing.py in _isna(obj, inf_as_na)
    154     # hack (for now) because MI registers as ndarray
    155     elif isinstance(obj, ABCMultiIndex):
--> 156         raise NotImplementedError("isna is not defined for MultiIndex")
    157     elif isinstance(obj, type):
    158         return False

NotImplementedError: isna is not defined for MultiIndex
```

</details>

#### Expected Output

Pandas explode doesn't have an issue with this, we shouldn't either. Note that #1871 does not fix this.

#### Output of ``geopandas.show_versions()``

<details>

```
SYSTEM INFO
-----------
python     : 3.9.2 | packaged by conda-forge | (default, Feb 21 2021, 05:02:20)  [Clang 11.0.1 ]
executable : /opt/miniconda3/envs/geo_dev/bin/python
machine    : macOS-11.3-x86_64-i386-64bit

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.9.1
GEOS lib   : /opt/miniconda3/envs/geo_dev/lib/libgeos_c.dylib
GDAL       : 3.1.4
GDAL data dir: /opt/miniconda3/envs/geo_dev/share/gdal
PROJ       : 7.2.0
PROJ data dir: /opt/miniconda3/envs/geo_Dev/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.9.0+22.g1161343
pandas     : 1.2.4
fiona      : 1.8.18
numpy      : 1.20.1
shapely    : 1.7.1
rtree      : 0.9.7
pyproj     : 3.0.1
matplotlib : 3.3.4
mapclassify: 2.4.2
geopy      : 2.1.0
psycopg2   : None
geoalchemy2: None
pyarrow    : 3.0.0
pygeos     : 0.9+42.g92b49dd
```

</details>



Discussion:

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1582,9 +1582,11 @@ def explode(self, column=None, **kwargs):
         exploded_geom = df_copy.geometry.explode().reset_index(level=-1)
         exploded_index = exploded_geom.columns[0]
 
-        df = pd.concat(
-            [df_copy.drop(df_copy._geometry_column_name, axis=1), exploded_geom], axis=1
-        ).__finalize__(self)
+        df = (
+            df_copy.drop(df_copy._geometry_column_name, axis=1)
+            .join(exploded_geom)
+            .__finalize__(self)
+        )
 
         # reset to MultiIndex, otherwise df index is only first level of
         # exploded GeoSeries index.
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -830,10 +830,13 @@ def explode(self):
             # extract original index values based on integer index
             outer_index = self.index.take(outer_idx)
 
-            index = MultiIndex.from_arrays(
-                [outer_index, inner_index], names=self.index.names + [None]
-            )
+            index = zip(outer_index, inner_index)
+
+            # if self.index is a MultiIndex then index is a list of nested tuples
+            if isinstance(self.index, MultiIndex):
+                index = [tuple(outer) + (inner,) for outer, inner in index]
 
+            index = MultiIndex.from_tuples(index, names=self.index.names + [None])
             return GeoSeries(geometries, index=index, crs=self.crs).__finalize__(self)
 
         # else PyGEOS is not available or version <= 0.8
@@ -849,6 +852,11 @@ def explode(self):
                 idxs = [(idx, 0)]
             index.extend(idxs)
             geometries.extend(geoms)
+
+        # if self.index is a MultiIndex then index is a list of nested tuples
+        if isinstance(self.index, MultiIndex):
+            index = [tuple(outer) + (inner,) for outer, inner in index]
+
         index = MultiIndex.from_tuples(index, names=self.index.names + [None])
         return GeoSeries(geometries, index=index, crs=self.crs).__finalize__(self)
 


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -941,6 +941,41 @@ def test_explode_pandas_fallback_ignore_index(self):
         exploded_df = gdf.explode(column="col1", ignore_index=True)
         assert_geodataframe_equal(exploded_df, expected_df)
 
+    @pytest.mark.parametrize("outer_index", [1, (1, 2), "1"])
+    def test_explode_pandas_multi_index(self, outer_index):
+        index = MultiIndex.from_arrays(
+            [[outer_index, outer_index, outer_index], [1, 2, 3]],
+            names=("first", "second"),
+        )
+        df = GeoDataFrame(
+            {"vals": [1, 2, 3]},
+            geometry=[MultiPoint([(x, x), (x, 0)]) for x in range(3)],
+            index=index,
+        )
+
+        test_df = df.explode()
+
+        expected_s = GeoSeries(
+            [
+                Point(0, 0),
+                Point(0, 0),
+                Point(1, 1),
+                Point(1, 0),
+                Point(2, 2),
+                Point(2, 0),
+            ]
+        )
+        expected_df = GeoDataFrame({"vals": [1, 1, 2, 2, 3, 3], "geometry": expected_s})
+        expected_index = MultiIndex.from_tuples(
+            [
+                (outer_index, *pair)
+                for pair in [(1, 0), (1, 1), (2, 0), (2, 1), (3, 0), (3, 1)]
+            ],
+            names=["first", "second", None],
+        )
+        expected_df = expected_df.set_index(expected_index)
+        assert_frame_equal(test_df, expected_df)
+
     #
     # Test '&', '|', '^', and '-'
     #