Repository: geopandas/geopandas

Issue:
BUG: CRS lost after GeoSeries.shift()
- [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.

- [ ] (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
import pandas as pd
import geopandas as gpd
print(gpd.__version__)
df = pd.DataFrame(
    {'City': ['Buenos Aires', 'Brasilia', 'Santiago', 'Bogota', 'Caracas'],
     'Country': ['Argentina', 'Brazil', 'Chile', 'Colombia', 'Venezuela'],
     'Latitude': [-34.58, -15.78, -33.45, 4.60, 10.48],
     'Longitude': [-58.66, -47.91, -70.66, -74.08, -66.86]})
gdf = gpd.GeoDataFrame(
    df, geometry=gpd.points_from_xy(df.Longitude, df.Latitude),
    crs='epsg:4326')
gdf.geometry.distance(gdf.geometry.shift())
```

#### Problem description

The pandas `shift()` method should be supported by geopandas to also copy the CRS - thus, avoiding the missing CRS warning when computing distance.

#### Expected Output

Not missing CRS warning.

#### Output of ``geopandas.show_versions()``

<details>
SYSTEM INFO
-----------
python     : 3.8.2 (default, Mar 26 2020, 15:53:00)  [GCC 7.3.0]
executable : /home/fogh/miniconda3/envs/py38_v3/bin/python
machine    : Linux-5.4.0-48-generic-x86_64-with-glibc2.10

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.8.0
GEOS lib   : /home/fogh/miniconda3/envs/py38_v3/lib/libgeos_c.so
GDAL       : 3.0.2
GDAL data dir: /home/fogh/miniconda3/envs/py38_v3/share/gdal
PROJ       : 6.2.1
PROJ data dir: /home/fogh/miniconda3/envs/py38_v3/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.8.1
pandas     : 1.1.1
fiona      : 1.8.13.post1
numpy      : 1.19.1
shapely    : 1.7.1
rtree      : 0.9.4
pyproj     : 2.6.1.post1
matplotlib : 3.3.1
mapclassify: 2.3.0
geopy      : 2.0.0
psycopg2   : None
geoalchemy2: None
pyarrow    : 1.0.1
</details>



Discussion:
Thanks for the report. I can confirm that after doing `gdf.geometry.shift()`, the resulting geoseries does not have CRS, even on master. This is something we should fix.
Thanks for the catch! 

Pandas provides a base implementation for the `shift` method of the array:

https://github.com/pandas-dev/pandas/blob/653f6944eba664d19e8d93e850340ac039ec452e/pandas/core/arrays/base.py#L677-L692

And you can see this creates an "empty" array from scalars, which will not actually preserve the CRS. 

And together with our implementation of `_concat_same_type`:

https://github.com/geopandas/geopandas/blob/aa9f9c1ed8637bf38ea8f8d1a01b4668326e3f01/geopandas/array.py#L992-L1006

which takes the CRS from the first object passed to it, this means we loose the CRS for `shift` with a positive number ..

Resolving patch:
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -17,6 +17,7 @@
 PANDAS_GE_025 = str(pd.__version__) >= LooseVersion("0.25.0")
 PANDAS_GE_10 = str(pd.__version__) >= LooseVersion("1.0.0")
 PANDAS_GE_11 = str(pd.__version__) >= LooseVersion("1.1.0")
+PANDAS_GE_115 = str(pd.__version__) >= LooseVersion("1.1.5")
 PANDAS_GE_12 = str(pd.__version__) >= LooseVersion("1.2.0")
 
 
diff --git a/geopandas/array.py b/geopandas/array.py
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -1077,6 +1077,41 @@ def unique(self):
     def nbytes(self):
         return self.data.nbytes
 
+    def shift(self, periods=1, fill_value=None):
+        """
+        Shift values by desired number.
+
+        Newly introduced missing values are filled with
+        ``self.dtype.na_value``.
+
+        Parameters
+        ----------
+        periods : int, default 1
+            The number of periods to shift. Negative values are allowed
+            for shifting backwards.
+
+        fill_value : object, optional (default None)
+            The scalar value to use for newly introduced missing values.
+            The default is ``self.dtype.na_value``.
+
+        Returns
+        -------
+        GeometryArray
+            Shifted.
+
+        Notes
+        -----
+        If ``self`` is empty or ``periods`` is 0, a copy of ``self`` is
+        returned.
+
+        If ``periods > len(self)``, then an array of size
+        len(self) is returned, with all values filled with
+        ``self.dtype.na_value``.
+        """
+        shifted = super(GeometryArray, self).shift(periods, fill_value)
+        shifted.crs = self.crs
+        return shifted
+
     # -------------------------------------------------------------------------
     # ExtensionArray specific
     # -------------------------------------------------------------------------
@@ -1142,7 +1177,7 @@ def _from_factorized(cls, values, original):
         pandas.factorize
         ExtensionArray.factorize
         """
-        return from_wkb(values)
+        return from_wkb(values, crs=original.crs)
 
     def _values_for_argsort(self):
         # type: () -> np.ndarray


Test patch:
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -886,6 +886,23 @@ def test_isna_pdNA():
     assert t1[0] is None
 
 
+def test_shift_has_crs():
+    t = T.copy()
+    t.crs = 4326
+    assert t.shift(1).crs == t.crs
+    assert t.shift(0).crs == t.crs
+    assert t.shift(-1).crs == t.crs
+
+
+@pytest.mark.skipif(
+    not compat.PANDAS_GE_115, reason="crs only preserved in unique after pandas 1.1.5"
+)
+def test_unique_has_crs():
+    t = T.copy()
+    t.crs = 4326
+    assert t.unique().crs == t.crs
+
+
 class TestEstimateUtmCrs:
     def setup_method(self):
         self.esb = shapely.geometry.Point(-73.9847, 40.7484)