Repository: awslabs/gluonts

Issue:
Bug in loading quarterly and yearly time series data
## Description
It looks like recent changes (https://github.com/awslabs/gluon-ts/pull/253) introduced a bug in loading yearly, quarterly data. The loaded train and test datasets have monthly frequency instead of yearly ("12M") or quarterly ("3M") frequency.

This seems to be the culprit: https://github.com/awslabs/gluon-ts/blob/master/src/gluonts/dataset/common.py#L330

I actually came across this while doing evaluations for yearly data. Model trained with `DeepAREstimator` failed during evaluation with the following error. It shows the frequency here correctly in the error message since the estimator gets it from the metadata but the actual train and test datasets have wrong frequency.

```
AssertionError: Cannot extract prediction target since the index of forecast is outside the index of target
Index of forecast: DatetimeIndex(['1752-07-31', '1753-07-31', '1754-07-31', '1755-07-31',
               '1756-07-31', '1757-07-31'],
              dtype='datetime64[ns]', freq='12M')
 Index of target: DatetimeIndex(['1749-12-31', '1750-12-31', '1751-12-31', '1752-12-31',
               '1753-12-31', '1754-12-31', '1755-12-31', '1756-12-31',
               '1757-12-31', '1758-12-31', '1759-12-31', '1760-12-31',
               '1761-12-31', '1762-12-31', '1763-12-31', '1764-12-31',
               '1765-12-31', '1766-12-31', '1767-12-31', '1768-12-31',
               '1769-12-31', '1770-12-31', '1771-12-31', '1772-12-31',
               '1773-12-31', '1774-12-31', '1775-12-31', '1776-12-31',
               '1777-12-31', '1778-12-31', '1779-12-31', '1780-12-31',
               '1781-12-31', '1782-12-31', '1783-12-31', '1784-12-31',
               '1785-12-31'],
              dtype='datetime64[ns]', freq='12M')
```

## To Reproduce

```
In [1]: import gluonts                                                                                                                                                                                              

In [2]: from gluonts.dataset.repository.datasets import get_dataset                                                                                                                                                 

In [3]: dataset = get_dataset("m4_yearly", regenerate=False)                                                                                                                                                        

In [4]: next(iter(dataset.test))['start']                                                                                                                                                                           
Out[4]: Timestamp('1749-12-31 00:00:00', freq='M')

In [5]: dataset.metadata.freq
Out[5]: '12M'

In [6]: dataset = get_dataset("m4_quarterly", regenerate=False)                                                                                                                                                     

In [7]: next(iter(dataset.test))['start']                                                                                                                                                                           
Out[7]: Timestamp('1749-12-31 00:00:00', freq='M')
```


Discussion:
Since `start` field had wrong frequency [Forecast start date was wrongly set here](https://github.com/awslabs/gluon-ts/blob/master/src/gluonts/transform.py#L1081) by shifting 31 months instead of  instead of 31 years in case of m4_yearly data. This explains why the forecast index started at 1752-07-31 as shown in the error message instead of 1780-12-31.
It's not clear why `ProcessStartField.process` needs to have all that logic: we could use some documentation explaining why simply doing
```python
return pd.Timestamp(string, freq=freq)
```
is not sufficient
AFAIK, we do this processing, to align all timestamps within the same period.

E.g. all timestamps in August 2019 should to `2019-08-31`:

```python
In [32]: to_offset("M").rollback(pd.Timestamp.now())
Out[32]: Timestamp('2019-08-31 12:57:48.139117')
```

As we can see, rollback doesn't affect the time information, that's why we call `replace` on it to reset these values:

```python
In [34]: _32.replace(hour=0, minute=0, second=0, microsecond=0, nanosecond=0)
Out[34]: Timestamp('2019-08-31 00:00:00')
```

---

That being said, I think `12M` still only aligns the data on a monthly bases and not a yearly one:

```python
In [48]: to_offset("12M").rollback(pd.Timestamp.now())
Out[48]: Timestamp('2019-08-31 13:09:39.763214')
```

vs

```python
In [49]: to_offset("Y").rollback(pd.Timestamp.now())
Out[49]: Timestamp('2018-12-31 13:09:59.602807')
```


> It's not clear why `ProcessStartField.process` needs to have all that logic: we could use some documentation explaining why simply doing
> 
> ```python
> return pd.Timestamp(string, freq=freq)
> ```
> 
> is not sufficient

I've tried running the tests with your suggestion and the only tests failing are the ones, which explicitly test for the alignment. Maybe @vafl can tell us more, why we need to align the timestamps?
The main reason for aligning the time stamps are the time dependent features. These are quite expensive to compute and so we cache the feature values for all time points in a feature matrix:

https://github.com/awslabs/gluon-ts/blob/4d044a2dcd9f3f5282e72a5324db12a6b7b30c50/src/gluonts/transform.py#L840

When we add time features in the transformation, we do a lookup of the start time point and then just index into the feature matrix to return the corresponding part. This cache would not work if time points are not aligned with a regular grid.

Maybe we can improve the alignment logic taking into account the multiple?

Resolving patch:
diff --git a/src/gluonts/dataset/common.py b/src/gluonts/dataset/common.py
--- a/src/gluonts/dataset/common.py
+++ b/src/gluonts/dataset/common.py
@@ -34,6 +34,7 @@
 import pydantic
 import ujson as json
 from pandas.tseries.frequencies import to_offset
+from pandas.tseries.offsets import Tick
 
 # First-party imports
 from gluonts.core.exception import GluonTSDataError
@@ -310,30 +311,35 @@ def __call__(self, data: DataEntry) -> DataEntry:
     @staticmethod
     @lru_cache(maxsize=10000)
     def process(string: str, freq: str) -> pd.Timestamp:
-        rollback_cases = {
-            "B": "D",
-            "M": "M",
-            # 'W-SUN' is the standardized freqstr for W
-            "W-SUN": "W-SUN",
-            # 'A-DEC' is the standardized freqstr for Y
-            "A-DEC": "AS-JAN",
-        }
+        """Create timestamp and align it according to frequency.
+        """
 
         timestamp = pd.Timestamp(string, freq=freq)
-        if timestamp.freq.name in rollback_cases:
-            offset = to_offset(rollback_cases[timestamp.freq.name])
-            # rollback does not reset time information, thus we set
-            # these all to zero by hand
-            timestamp = timestamp.replace(
-                hour=0, minute=0, second=0, microsecond=0, nanosecond=0
-            )
+
+        # operate on time information (hours, minute, second)
+        if isinstance(timestamp.freq, Tick):
             return pd.Timestamp(
-                offset.rollback(timestamp), freq=offset.freqstr
+                timestamp.floor(timestamp.freq), timestamp.freq
             )
-        return pd.Timestamp(
-            timestamp.floor(timestamp.freq), freq=timestamp.freq
+
+        # since we are only interested in the data piece, we normalize the
+        # time information
+        timestamp = timestamp.replace(
+            hour=0, minute=0, second=0, microsecond=0, nanosecond=0
         )
 
+        # TODO: replace with rollforward
+        return timestamp.freq.rollback(timestamp)
+
+
+def rollback(timestamp):
+    offset = timestamp.freq
+    # if not offset.onOffset(timestamp):
+    return timestamp - offset.__class__(
+        offset.n, normalize=True, **offset.kwds
+    )
+    # return timestamp
+
 
 class ProcessTimeSeriesField:
     """


Test patch:
diff --git a/test/dataset/test_common.py b/test/dataset/test_common.py
--- a/test/dataset/test_common.py
+++ b/test/dataset/test_common.py
@@ -23,11 +23,12 @@
         ("B", "2019-11-01"),
         ("W", "2019-10-27"),
         ("M", "2019-10-31"),
-        ("Y", "2019"),
+        ("12M", "2019-10-31"),
+        ("A-DEC", "2018-12-31"),
     ],
 )
 def test_process_start_field(freq, expected):
     process = ProcessStartField.process
     given = "2019-11-01 12:34:56"
 
-    assert process(given, freq) == pd.Timestamp(expected)
+    assert process(given, freq) == pd.Timestamp(expected, freq)