Repository: mindsdb/lightwood

Issue:
Fix encode/decode in TsArrayNumericEncoder
Current methods are functionally equivalent to `encode_one()` and `decode_one()`, which is confusing. We need to rename these and write the wrappers `encode()` and `decode()`, all the while documenting the encoder.


Discussion:

Resolving patch:
diff --git a/lightwood/data/encoded_ds.py b/lightwood/data/encoded_ds.py
--- a/lightwood/data/encoded_ds.py
+++ b/lightwood/data/encoded_ds.py
@@ -67,10 +67,11 @@ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
                 if hasattr(self.encoders[col], 'data_window'):
                     cols = [self.target] + [f'{self.target}_timestep_{i}'
                                             for i in range(1, self.encoders[col].data_window)]
+                    data = [self.data_frame[cols].iloc[idx].tolist()]
                 else:
                     cols = [col]
+                    data = self.data_frame[cols].iloc[idx].tolist()
 
-                data = self.data_frame[cols].iloc[idx].tolist()
                 encoded_tensor = self.encoders[col].encode(data, **kwargs)[0]
                 if torch.isnan(encoded_tensor).any() or torch.isinf(encoded_tensor).any():
                     raise Exception(f'Encoded tensor: {encoded_tensor} contains nan or inf values')
diff --git a/lightwood/encoder/numeric/ts_array_numeric.py b/lightwood/encoder/numeric/ts_array_numeric.py
--- a/lightwood/encoder/numeric/ts_array_numeric.py
+++ b/lightwood/encoder/numeric/ts_array_numeric.py
@@ -1,17 +1,23 @@
+from typing import List, Dict, Iterable, Optional
+
 import torch
 import torch.nn.functional as F
+
 from lightwood.encoder import BaseEncoder
 from lightwood.encoder.numeric import TsNumericEncoder
 
 
 class TsArrayNumericEncoder(BaseEncoder):
-    """
-    Variant of vanilla numerical encoder, supports dynamic mean re-scaling
-    """
-
     def __init__(self, timesteps: int, is_target: bool = False, positive_domain: bool = False, grouped_by=None):
+        """
+        This encoder handles arrays of numerical time series data by wrapping the numerical encoder with behavior specific to time series tasks.
+        
+        :param timesteps: length of forecasting horizon, as defined by TimeseriesSettings.window.
+        :param is_target: whether this encoder corresponds to the target column.
+        :param positive_domain: whether the column domain is expected to be positive numbers.
+        :param grouped_by: what columns, if any, are considered to group the original column and yield multiple time series.
+        """  # noqa
         super(TsArrayNumericEncoder, self).__init__(is_target=is_target)
-        # time series normalization params
         self.normalizers = None
         self.group_combinations = None
         self.dependencies = grouped_by
@@ -21,23 +27,49 @@ def __init__(self, timesteps: int, is_target: bool = False, positive_domain: boo
         self.output_size = self.data_window * self.sub_encoder.output_size
 
     def prepare(self, priming_data):
+        """
+        This method prepares the underlying time series numerical encoder.
+        """
         if self.is_prepared:
             raise Exception('You can only call "prepare" once for a given encoder.')
 
         self.sub_encoder.prepare(priming_data)
         self.is_prepared = True
 
-    def encode(self, data, dependency_data={}):
+    def encode(self, data: Iterable[Iterable], dependency_data: Optional[Dict[str, str]] = {}) -> torch.Tensor:
         """
-        :param dependency_data: dict with grouped_by column info, to retrieve the correct normalizer for each datum
-        :return: tensor with shape (batch, NxK) where N: self.data_window and K: sub-encoder # of output features
+        Encodes a list of time series arrays using the underlying time series numerical encoder.
+        
+        :param data: list of numerical values to encode. Its length is determined by the tss.window parameter, and all data points belong to the same time series.
+        :param dependency_data: dict with values of each group_by column for the time series, used to retrieve the correct normalizer.
+        
+        :return: list of encoded time series arrays. Tensor is (len(data), N x K)-shaped, where N: self.data_window and K: sub-encoder # of output features.
         """  # noqa
         if not self.is_prepared:
             raise Exception('You need to call "prepare" before calling "encode" or "decode".')
+        if self.sub_encoder.normalizers is None and self.normalizers is not None:
+            self.sub_encoder.normalizers = self.normalizers
         if not dependency_data:
             dependency_data = {'__default': [None] * len(data)}
 
         ret = []
+        for series in data:
+            ret.append(self.encode_one(series, dependency_data=dependency_data))
+
+        return torch.vstack(ret)
+
+    def encode_one(self, data: Iterable, dependency_data: Optional[Dict[str, str]] = {}) -> torch.Tensor:
+        """
+        Encodes a single windowed slice of any given time series.
+
+        :param data: windowed slice of a numerical time series.
+        :param dependency_data: used to determine the correct normalizer for the input.
+        
+        :return: an encoded time series array, as per the underlying `TsNumericEncoder` object. 
+        The output of this encoder for all time steps is concatenated, so the final shape of the tensor is (1, NxK), where N: self.data_window and K: sub-encoder # of output features. 
+        """  # noqa
+        ret = []
+
         for data_point in data:
             ret.append(self.sub_encoder.encode([data_point], dependency_data=dependency_data))
 
@@ -49,7 +81,15 @@ def encode(self, data, dependency_data={}):
 
         return ret
 
-    def decode(self, encoded_values, dependency_data=None, return_all=False):
+    def decode(self, encoded_values, dependency_data=None) -> List[List]:
+        """
+        Decodes a list of encoded arrays into values in their original domains.
+
+        :param encoded_values: encoded slices of numerical time series.
+        :param dependency_data: used to determine the correct normalizer for the input.
+
+        :return: a list of decoded time series arrays.
+        """
         if not self.is_prepared:
             raise Exception('You need to call "prepare" before calling "encode" or "decode".')
 
@@ -58,6 +98,21 @@ def decode(self, encoded_values, dependency_data=None, return_all=False):
                                                 self.sub_encoder.output_size)
 
         ret = []
-        for encoded_timestep in torch.split(encoded_values, 1, dim=1):
+        for tensor in torch.split(encoded_values, 1, dim=0):
+            ret.append(self.decode_one(tensor, dependency_data=dependency_data))
+
+        return ret
+
+    def decode_one(self, encoded_value, dependency_data={}) -> List:
+        """
+        Decodes a single window of a time series into its original domain.
+
+        :param encoded_value: encoded slice of a numerical time series.
+        :param dependency_data: used to determine the correct normalizer for the input.
+
+        :return: a list of length TimeseriesSettings.window with decoded values for the forecasted time series.
+        """
+        ret = []
+        for encoded_timestep in torch.split(encoded_value, 1, dim=1):
             ret.extend(self.sub_encoder.decode(encoded_timestep.squeeze(1), dependency_data=dependency_data))
         return ret
diff --git a/lightwood/mixer/neural.py b/lightwood/mixer/neural.py
--- a/lightwood/mixer/neural.py
+++ b/lightwood/mixer/neural.py
@@ -289,7 +289,9 @@ def fit(self, train_data: EncodedDs, dev_data: EncodedDs) -> None:
 
         if self.fit_on_dev:
             self.partial_fit(dev_data, train_data)
-        self._final_tuning(dev_data)
+
+        if not self.timeseries_settings.is_timeseries:
+            self._final_tuning(dev_data)
 
     def partial_fit(self, train_data: EncodedDs, dev_data: EncodedDs) -> None:
         """
@@ -341,10 +343,7 @@ def __call__(self, ds: EncodedDs,
                 else:
                     decoded_prediction = self.target_encoder.decode(Yh, **kwargs)
 
-                if not self.timeseries_settings.is_timeseries or self.timeseries_settings.nr_predictions == 1:
-                    decoded_predictions.extend(decoded_prediction)
-                else:
-                    decoded_predictions.append(decoded_prediction)
+                decoded_predictions.extend(decoded_prediction)
 
             ydf = pd.DataFrame({'prediction': decoded_predictions})
 


Test patch: