Repository: opcode81/sensAI (language: python)

Issue:
Objects such as DFTs and Fgens should know whether they actually require fitting ...
As a user, I would object to receiving warnings that do not apply. Ideally, we should not warn that there might be a problem but _know_ if there is a problem (by having the components be aware of the fitting requirement) - and throw an exception iff so.

_Originally posted by @opcode81 in https://github.com/jambit/sensAI/pull/33#discussion_r539341929_


Discussion / hints:
... such that we can raise an exception where appropriate and drop the warning in RuleBasedModel whenever it doesn't apply.


Fix patch:
diff --git a/config.py b/config.py
index db64a6112..5681406d4 100644
--- a/config.py
+++ b/config.py
@@ -9,7 +9,7 @@
 
 __config_instance = None
 
-source_path = os.path.dirname(__file__)
+topLevelDirectory = os.path.dirname(__file__)
 
 
 class __Configuration:
@@ -33,7 +33,7 @@ def __init__(self, config_files: List[str] = None):
             config_files = ["config.json", "config_local.json"]
         self.config = {}
         for filename in config_files:
-            file_path = os.path.join(source_path, filename)
+            file_path = os.path.join(topLevelDirectory, filename)
             if os.path.exists(file_path):
                 self.log.info("Reading configuration from %s" % file_path)
                 with open(file_path, 'r') as f:
diff --git a/docs/sensai/util/pandas.rst b/docs/sensai/util/pandas.rst
new file mode 100644
index 000000000..ae5405f18
--- /dev/null
+++ b/docs/sensai/util/pandas.rst
@@ -0,0 +1,6 @@
+pandas
+======
+
+.. automodule:: sensai.util.pandas
+   :members:
+   :undoc-members:
diff --git a/notebooks/Intro to sensAI.ipynb b/notebooks/Intro to sensAI.ipynb
index 67b0a90b9..d90ea621e 100644
--- a/notebooks/Intro to sensAI.ipynb
+++ b/notebooks/Intro to sensAI.ipynb
@@ -269,7 +269,7 @@
     "            )\n",
     "        )\n",
     "\n",
-    "    def fit(self, X: pd.DataFrame, Y: pd.DataFrame, ctx=None):\n",
+    "    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame, ctx=None):\n",
     "        self.threshold = X[self.tax_column].median()\n",
     "\n",
     "    def compensate_for_fraud(self, tax: float):\n",
@@ -445,9 +445,9 @@
     "## Model Evaluation\n",
     "\n",
     "sensAI has extensive support for evaluating different types of models with different methods, including\n",
-    "cross validation. The evaluation has native support for experiment tracking frameworks, like trains or MLflow.\n",
-    "Here we will use trains, so after running this notebook you will be able to see the result in the\n",
-    "trains demo-server.\n",
+    "cross validation. The evaluation has native support for experiment tracking frameworks, like clearML or MLflow.\n",
+    "Here we will use clearML, so after running this notebook you will be able to see the result in the\n",
+    "clearML demo-server.\n",
     "\n",
     "The evaluation is generally based on the following structure: an `Evaluator` object holds a dataset.\n",
     "An `Evaluator` can evaluate multiple models by calling `Evaluator.evalModel(model)`,\n",
@@ -471,10 +471,10 @@
    "source": [
     "io_data = InputOutputData(X, y)\n",
     "\n",
-    "trains_experiment = ClearMLExperiment(projectName=\"sensai_demo\", taskName=\"custom_model\")\n",
+    "clearml_experiment = ClearMLExperiment(projectName=\"sensai_demo\", taskName=\"custom_model\")\n",
     "evaluator = createVectorModelEvaluator(io_data, isRegression=custom_model.isRegressionModel(),\n",
     "                                       testFraction=0.2)\n",
-    "evaluator.setTrackedExperiment(trains_experiment)"
+    "evaluator.setTrackedExperiment(clearml_experiment)"
    ],
    "metadata": {
     "collapsed": false,
diff --git a/src/sensai/data_transformation.py b/src/sensai/data_transformation.py
index 4a29e83bc..af64bf573 100644
--- a/src/sensai/data_transformation.py
+++ b/src/sensai/data_transformation.py
@@ -7,8 +7,11 @@
 import numpy as np
 import pandas as pd
 from sklearn.preprocessing import OneHotEncoder
+from typing_extensions import Protocol
 
 from .columngen import ColumnGenerator
+from .util import flattenArguments
+from .util.pandas import DataFrameColumnChangeTracker
 from .util.string import orRegexGroup
 
 log = logging.getLogger(__name__)
@@ -20,15 +23,65 @@ class DataFrameTransformer(ABC):
     (possibly applying the transformation to the original data frame - in-place transformation).
     A data frame transformer may require being fitted using training data.
     """
+    def __init__(self):
+        self._name = f"{self.__class__.__name__}-{id(self)}"
+        self._isFitted = False
+        self._columnChangeTracker: Optional[DataFrameColumnChangeTracker] = None
+        self._paramInfo = {}  # arguments passed to init that are not saved otherwise can be persisted here
+
+    # for backwards compatibility with persisted DFTs based on code prior to commit 7088cbbe
+    # They lack the __isFitted attribute and we assume that each such DFT was fitted
+    def __setstate__(self, d):
+        d["_name"] = d.get("_name", f"{self.__class__.__name__}-{id(self)}")
+        d["_isFitted"] = d.get("_isFitted", True)
+        d["_columnChangeTracker"] = d.get("_columnChangeTracker", None)
+        d["_paramInfo"] = d.get("_paramInfo", {})
+        self.__dict__ = d
+
+    def getName(self) -> str:
+        """
+        :return: the name of this dft transformer, which may be a default name if the name has not been set.
+        """
+        return self._name
+
+    def setName(self, name):
+        self._name = name
 
     @abstractmethod
-    def fit(self, df: pd.DataFrame):
+    def _fit(self, df: pd.DataFrame):
         pass
 
     @abstractmethod
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         pass
 
+    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+        self._columnChangeTracker = DataFrameColumnChangeTracker(df)
+        if not self.isFitted():
+            raise Exception(f"Cannot apply a DataFrameTransformer which is not fitted: "
+                            f"the df transformer {self.getName()} requires fitting")
+        df = self._apply(df)
+        self._columnChangeTracker.trackChange(df)
+        return df
+
+    def info(self):
+        return {
+            "name": self.getName(),
+            "changeInColumnNames": self._columnChangeTracker.columnChangeString() if self._columnChangeTracker is not None else None,
+            "isFitted": self.isFitted(),
+        }
+
+    def fit(self, df: pd.DataFrame):
+        self._fit(df)
+        self._isFitted = True
+
+    def isFitted(self):
+        return self._isFitted
+
+    def fitApply(self, df: pd.DataFrame) -> pd.DataFrame:
+        self.fit(df)
+        return self.apply(df)
+
 
 class InvertibleDataFrameTransformer(DataFrameTransformer, ABC):
     @abstractmethod
@@ -39,34 +92,51 @@ def applyInverse(self, df: pd.DataFrame) -> pd.DataFrame:
 class RuleBasedDataFrameTransformer(DataFrameTransformer, ABC):
     """Base class for transformers whose logic is entirely based on rules and does not need to be fitted to data"""
 
+    def _fit(self, df: pd.DataFrame):
+        pass
+
     def fit(self, df: pd.DataFrame):
         pass
 
+    def isFitted(self):
+        return True
 
-class DataFrameTransformerChain:
-    """Supports the application of a chain of data frame transformers"""
 
-    def __init__(self, dataFrameTransformers: Sequence[DataFrameTransformer]):
-        self.dataFrameTransformers = dataFrameTransformers
+class DataFrameTransformerChain(DataFrameTransformer):
+    """
+    Supports the application of a chain of data frame transformers.
+    During fit and apply each transformer in the chain receives the transformed output of its predecessor.
+    """
 
-    def apply(self, df: pd.DataFrame, fit=False) -> pd.DataFrame:
-        """
-        Applies the chain of transformers to the given DataFrame, optionally fitting each transformer before applying it.
-        Each transformer in the chain receives the transformed output of its predecessor.
+    def __init__(self, *dataFrameTransformers: Union[DataFrameTransformer, List[DataFrameTransformer]]):
+        super().__init__()
+        self.dataFrameTransformers = flattenArguments(dataFrameTransformers)
 
-        :param df: the data frame
-        :param fit: whether to fit the transformers before applying them
-        :return: the transformed data frame
-        """
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         for transformer in self.dataFrameTransformers:
-            if fit:
-                transformer.fit(df)
             df = transformer.apply(df)
         return df
 
-    def fit(self, df: pd.DataFrame):
-        for transformer in self.dataFrameTransformers:
-            transformer.fit(df)
+    def _fit(self, df: pd.DataFrame):
+        if len(self.dataFrameTransformers) == 0:
+            return
+        for transformer in self.dataFrameTransformers[:-1]:
+            df = transformer.fitApply(df)
+        self.dataFrameTransformers[-1].fit(df)
+
+    def isFitted(self):
+        return all([dft.isFitted() for dft in self.dataFrameTransformers])
+
+    def getNames(self) -> List[str]:
+        """
+        :return: the list of names of all contained feature generators
+        """
+        return [transf.getName() for transf in self.dataFrameTransformers]
+
+    def info(self):
+        info = super().info()
+        info["chainedDFTTransformerNames"] = self.getNames()
+        return info
 
 
 class DFTRenameColumns(RuleBasedDataFrameTransformer):
@@ -74,9 +144,10 @@ def __init__(self, columnsMap: Dict[str, str]):
         """
         :param columnsMap: dictionary mapping old column names to new names
         """
+        super().__init__()
         self.columnsMap = columnsMap
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df.rename(columns=self.columnsMap)
 
 
@@ -86,63 +157,81 @@ class DFTConditionalRowFilterOnColumn(RuleBasedDataFrameTransformer):
     for which the function returns True
     """
     def __init__(self, column: str, condition: Callable[[Any], bool]):
+        super().__init__()
         self.column = column
         self.condition = condition
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[df[self.column].apply(self.condition)]
 
 
 class DFTInSetComparisonRowFilterOnColumn(RuleBasedDataFrameTransformer):
     """
-    Filters a data frame by applying a boolean function to one of the columns and retaining only the rows
-    for which the function returns True
+    Filters a data frame on the selected column and retains only the rows for which the value is in the setToKeep
     """
     def __init__(self, column: str, setToKeep: Set):
+        super().__init__()
         self.setToKeep = setToKeep
         self.column = column
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[df[self.column].isin(self.setToKeep)]
 
+    def info(self):
+        info = super().info()
+        info["column"] = self.column
+        info["setToKeep"] = self.setToKeep
+        return info
+
 
 class DFTNotInSetComparisonRowFilterOnColumn(RuleBasedDataFrameTransformer):
     """
-    Filters a data frame by applying a boolean function to one of the columns and retaining only the rows
-    for which the function returns True
+    Filters a data frame on the selected column and retains only the rows for which the value is not in the setToDrop
     """
     def __init__(self, column: str, setToDrop: Set):
+        super().__init__()
         self.setToDrop = setToDrop
         self.column = column
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[~df[self.column].isin(self.setToDrop)]
 
+    def info(self):
+        info = super().info()
+        info["column"] = self.column
+        info["setToDrop"] = self.setToDrop
+        return info
 
-class DFTVectorizedConditionalRowFilterOnColumn(RuleBasedDataFrameTransformer):
-    def __init__(self, column: str, vectorizedCondition: Callable[[Any], Sequence[bool]]):
-        """
 
-        :param column:
-        :param vectorizedCondition:
-        """
+class DFTVectorizedConditionalRowFilterOnColumn(RuleBasedDataFrameTransformer):
+    """
+    Filters a data frame by applying a vectorized condition on the selected column and retaining only the rows
+    for which it returns True
+    """
+    def __init__(self, column: str, vectorizedCondition: Callable[[pd.Series], Sequence[bool]]):
+        super().__init__()
         self.column = column
         self.vectorizedCondition = vectorizedCondition
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[self.vectorizedCondition(df[self.column])]
 
+    def info(self):
+        info = super().info()
+        info["column"] = self.column
+        return info
+
 
 class DFTRowFilter(RuleBasedDataFrameTransformer):
+    """
+    Filters a data frame by applying a condition function to each row and retaining only the rows
+    for which it returns True
+    """
     def __init__(self, condition: Callable[[Any], bool]):
-        """
-        Filters a data frame by applying a boolean function to each row and retaining only the rows
-        for which the function returns True
-        :param condition:
-        """
+        super().__init__()
         self.condition = condition
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[df.apply(self.condition, axis=1)]
 
 
@@ -153,23 +242,25 @@ def __init__(self, column: str, columnTransform: Callable):
         :param column:
         :param columnTransform:
         """
+        super().__init__()
         self.columnTransform = columnTransform
         self.column = column
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         df[self.column] = df[self.column].apply(self.columnTransform)
         return df
 
 
 class DFTModifyColumnVectorized(DFTModifyColumn):
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         df[self.column] = self.columnTransform(df[self.column].values)
         return df
 
 
 class DFTOneHotEncoder(DataFrameTransformer):
-    def __init__(self, columns: Optional[Union[str, Sequence[str]]], categories: Union[List[np.ndarray], Dict[str, np.ndarray]] = None, inplace=False, ignoreUnknown=False):
+    def __init__(self, columns: Optional[Union[str, Sequence[str]]],
+             categories: Union[List[np.ndarray], Dict[str, np.ndarray]] = None, inplace=False, ignoreUnknown=False):
         """
         One hot encode categorical variables
 
@@ -181,6 +272,9 @@ def __init__(self, columns: Optional[Union[str, Sequence[str]]], categories: Uni
         :param ignoreUnknown: if True and an unknown category is encountered during transform, the resulting one-hot
             encoded columns for this feature will be all zeros. if False, an unknown category will raise an error.
         """
+        super().__init__()
+        self._paramInfo["columns"] = columns
+        self._paramInfo["inferCategories"] = categories is None
         self.oneHotEncoders = None
         if columns is None:
             self._columnsToEncode = []
@@ -201,7 +295,7 @@ def __init__(self, columns: Optional[Union[str, Sequence[str]]], categories: Uni
                     raise ValueError(f"Given categories must have the same length as columns to process")
                 self.oneHotEncoders = {col: OneHotEncoder(categories=[np.sort(categories)], sparse=False, handle_unknown=self.handleUnknown) for col, categories in zip(columns, categories)}
 
-    def fit(self, df: pd.DataFrame):
+    def _fit(self, df: pd.DataFrame):
         if self._columnsToEncode is None:
             self._columnsToEncode = [c for c in df.columns if re.fullmatch(self._columnNameRegex, c) is not None]
             if len(self._columnsToEncode) == 0:
@@ -211,7 +305,7 @@ def fit(self, df: pd.DataFrame):
         for columnName in self._columnsToEncode:
             self.oneHotEncoders[columnName].fit(df[[columnName]])
 
-    def apply(self, df: pd.DataFrame):
+    def _apply(self, df: pd.DataFrame):
         if len(self._columnsToEncode) == 0:
             return df
 
@@ -224,17 +318,24 @@ def apply(self, df: pd.DataFrame):
                 df["%s_%d" % (columnName, i)] = encodedArray[:, i]
         return df
 
+    def info(self):
+        info = super().info()
+        info["inplace"] = self.inplace
+        info["handleUnknown"] = self.handleUnknown
+        info.update(self._paramInfo)
+        return info
+
 
 class DFTColumnFilter(RuleBasedDataFrameTransformer):
     """
     A DataFrame transformer that filters columns by retaining or dropping specified columns
     """
-
     def __init__(self, keep: Union[str, Sequence[str]] = None, drop: Union[str, Sequence[str]] = None):
+        super().__init__()
         self.keep = [keep] if type(keep) == str else keep
         self.drop = drop
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         df = df.copy()
         if self.keep is not None:
             df = df[self.keep]
@@ -242,19 +343,25 @@ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
             df = df.drop(columns=self.drop)
         return df
 
+    def info(self):
+        info = super().info()
+        info["keep"] = self.keep
+        info["drop"] = self.drop
+        return info
 
-class DFTKeepColumns(DFTColumnFilter):
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+class DFTKeepColumns(DFTColumnFilter):
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[self.keep]
 
 
 class DFTDRowFilterOnIndex(RuleBasedDataFrameTransformer):
     def __init__(self, keep: Set = None, drop: Set = None):
+        super().__init__()
         self.drop = drop
         self.keep = keep
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         df = df.copy()
         if self.keep is not None:
             df = df.loc[self.keep]
@@ -337,13 +444,14 @@ def __init__(self, rules: Sequence[Rule], defaultTransformerFactory=None, requir
         :param requireAllHandled: whether to raise an exception if not all columns are matched by a rule
         :param inplace: whether to apply data frame transformations in-place
         """
+        super().__init__()
         self.requireAllHandled = requireAllHandled
         self.inplace = inplace
         self._userRules = rules
         self._defaultTransformerFactory = defaultTransformerFactory
         self._rules = None
 
-    def fit(self, df: pd.DataFrame):
+    def _fit(self, df: pd.DataFrame):
         matchedRulesByColumn = {}
         self._rules = []
         for rule in self._userRules:
@@ -383,7 +491,7 @@ def _checkUnhandledColumns(self, df, matchedRulesByColumn):
             if len(unhandledColumns) > 0:
                 raise Exception(f"The following columns are not handled by any rules: {unhandledColumns}")
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         if not self.inplace:
             df = df.copy()
         matchedRulesByColumn = {}
@@ -395,13 +503,20 @@ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
         self._checkUnhandledColumns(df, matchedRulesByColumn)
         return df
 
+    def info(self):
+        info = super().info()
+        info["requireAllHandled"] = self.requireAllHandled
+        info["inplace"] = self.inplace
+        return info
+
 
 class DFTFromColumnGenerators(RuleBasedDataFrameTransformer):
     def __init__(self, columnGenerators: Sequence[ColumnGenerator], inplace=False):
+        super().__init__()
         self.columnGenerators = columnGenerators
         self.inplace = inplace
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         if not self.inplace:
             df = df.copy()
         for cg in self.columnGenerators:
@@ -409,53 +524,90 @@ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
             df[series.name] = series
         return df
 
+    def info(self):
+        info = super().info()
+        info["inplace"] = self.inplace
+        return info
+
 
 class DFTCountEntries(RuleBasedDataFrameTransformer):
+    """
+    Adds a new column with counts of the values on a selected column
+    """
     def __init__(self, columnForEntryCount: str, columnNameForResultingCounts: str = "counts"):
+        super().__init__()
         self.columnNameForResultingCounts = columnNameForResultingCounts
         self.columnForEntryCount = columnForEntryCount
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         series = df[self.columnForEntryCount].value_counts()
         return pd.DataFrame({self.columnForEntryCount: series.index, self.columnNameForResultingCounts: series.values})
 
+    def info(self):
+        info = super().info()
+        info["columnNameForResultingCounts"] = self.columnNameForResultingCounts
+        info["columnForEntryCount"] = self.columnForEntryCount
+        return info
+
 
 class DFTAggregationOnColumn(RuleBasedDataFrameTransformer):
     def __init__(self, columnForAggregation: str, aggregation: Callable):
+        super().__init__()
         self.columnForAggregation = columnForAggregation
         self.aggregation = aggregation
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df.groupby(self.columnForAggregation).agg(self.aggregation)
 
 
 class DFTRoundFloats(RuleBasedDataFrameTransformer):
+    def __init__(self, decimals=0):
+        super().__init__()
+        self.decimals = decimals
 
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
-        return pd.DataFrame(np.round(df.values), columns=df.columns, index=df.index)
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
+        return pd.DataFrame(np.round(df.values, self.decimals), columns=df.columns, index=df.index)
+
+    def info(self):
+        info = super().info()
+        info["decimals"] = self.decimals
+        return info
+
+
+class SklearnTransformerProtocol(Protocol):
+    def inverse_transform(self, arr: np.ndarray) -> np.ndarray:
+        pass
+
+    def transform(self, arr: np.ndarray) -> np.ndarray:
+        pass
+
+    def fit(self, arr: np.ndarray):
+        pass
 
 
 class DFTSkLearnTransformer(InvertibleDataFrameTransformer):
     """
     Applies a transformer from sklearn.preprocessing to (a subset of the columns of) a data frame
     """
-    def __init__(self, sklearnTransformer, columns: Optional[List[str]] = None, inplace=False):
+    def __init__(self, sklearnTransformer: SklearnTransformerProtocol, columns: Optional[List[str]] = None, inplace=False):
         """
         :param sklearnTransformer: the transformer instance (from sklearn.preprocessing) to use (which will be fitted & applied)
         :param columns: the set of column names to which the transformation shall apply; if None, apply it to all columns
         :param inplace: whether to apply the transformation in-place
         """
+        super().__init__()
+        self.setName(f"{self.__class__.__name__}_wrapped_{sklearnTransformer.__class__.__name__}")
         self.sklearnTransformer = sklearnTransformer
         self.columns = columns
         self.inplace = inplace
 
-    def fit(self, df: pd.DataFrame):
+    def _fit(self, df: pd.DataFrame):
         cols = self.columns
         if cols is None:
             cols = df.columns
         self.sklearnTransformer.fit(df[cols].values)
 
-    def _apply(self, df: pd.DataFrame, inverse: bool) -> pd.DataFrame:
+    def _apply_transformer(self, df: pd.DataFrame, inverse: bool) -> pd.DataFrame:
         if not self.inplace:
             df = df.copy()
         cols = self.columns
@@ -467,16 +619,23 @@ def _apply(self, df: pd.DataFrame, inverse: bool) -> pd.DataFrame:
             df[cols] = self.sklearnTransformer.transform(df[cols].values)
         return df
 
-    def apply(self, df):
-        return self._apply(df, False)
+    def _apply(self, df):
+        return self._apply_transformer(df, False)
 
     def applyInverse(self, df):
-        return self._apply(df, True)
+        return self._apply_transformer(df, True)
+
+    def info(self):
+        info = super().info()
+        info["columns"] = self.columns
+        info["inplace"] = self.inplace
+        info["sklearnTransformerClass"] = self.sklearnTransformer.__class__.__name__
+        return info
 
 
 class DFTSortColumns(RuleBasedDataFrameTransformer):
     """
     Sorts a data frame's columns in ascending order
     """
-    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
         return df[sorted(df.columns)]
diff --git a/src/sensai/featuregen.py b/src/sensai/featuregen.py
index 9896ee626..b68231771 100644
--- a/src/sensai/featuregen.py
+++ b/src/sensai/featuregen.py
@@ -7,8 +7,9 @@
 import pandas as pd
 
 from . import util, data_transformation
-from .util.string import orRegexGroup
 from .columngen import ColumnGenerator
+from .util import flattenArguments
+from .util.string import orRegexGroup
 
 if TYPE_CHECKING:
     from .vector_model import VectorModel
@@ -45,6 +46,7 @@ def __init__(self, categoricalFeatureNames: Optional[Union[Sequence[str], str]]
             raise ValueError(f"normalisationRules should be empty when a normalisationRuleTemplate is provided")
 
         self._generatedColumnNames = None
+        self.__categoricalFeatureNames = categoricalFeatureNames
 
         if type(categoricalFeatureNames) == str:
             categoricalFeatureNameRegex = categoricalFeatureNames
@@ -69,6 +71,13 @@ def __init__(self, categoricalFeatureNames: Optional[Union[Sequence[str], str]]
                 self._categoricalFeatureRules.append(data_transformation.DFTNormalisation.Rule(categoricalFeatureNameRegex + r"_\d+", skip=True))  # rule for one-hot transformation
 
         self._name = None
+        self._isFitted = False
+
+    # for backwards compatibility with persisted Featuregens based on code prior to commit 7088cbbe
+    # They lack the __isFitted attribute and we assume that each such Featuregen was fitted
+    def __setstate__(self, d):
+        d["_isFitted"] = d.get("_isFitted", True)
+        self.__dict__ = d
 
     def getName(self) -> str:
         """
@@ -82,6 +91,15 @@ def getName(self) -> str:
     def setName(self, name):
         self._name = name
 
+    def info(self):
+        return {
+            "name": self.getName(),
+            "categoricalFeatureNames": self.__categoricalFeatureNames,
+            "generatedColumnNames": self.getGeneratedColumnNames(),
+            "isFitted": self.isFitted(),
+            "normalisationRules": self.getNormalisationRules(),
+        }
+
     def getNormalisationRules(self, includeGeneratedCategoricalRules=True) -> List[data_transformation.DFTNormalisation.Rule]:
         if includeGeneratedCategoricalRules:
             return self._normalisationRules + self._categoricalFeatureRules
@@ -104,7 +122,7 @@ def getGeneratedColumnNames(self) -> Optional[List[str]]:
         return self._generatedColumnNames
 
     @abstractmethod
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         """
         Fits the feature generator based on the given data
 
@@ -116,6 +134,22 @@ def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         """
         pass
 
+    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+        """
+        Fits the feature generator based on the given data
+
+        :param X: the input/features data frame for the learning problem
+        :param Y: the corresponding output data frame for the learning problem
+            (which will typically contain regression or classification target columns)
+        :param ctx: a context object whose functionality may be required for feature generation;
+            this is typically the model instance that this feature generator is to generate inputs for
+        """
+        self._fit(X, Y=Y, ctx=ctx)
+        self._isFitted = True
+
+    def isFitted(self):
+        return self._isFitted
+
     def generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
         """
         Generates features for the data points in the given data frame
@@ -125,6 +159,10 @@ def generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
             this is typically the model instance that this feature generator is to generate inputs for
         :return: a data frame containing the generated features, which uses the same index as X (and Y)
         """
+        if not self.isFitted():
+            raise Exception(f"Cannot generate features from a FeatureGenerator which is not fitted: "
+                            f"the feature generator {self.getName()} requires fitting")
+
         log.debug(f"Generating features with {self}")
         resultDF = self._generate(df, ctx=ctx)
 
@@ -194,17 +232,22 @@ class RuleBasedFeatureGenerator(FeatureGenerator, ABC):
     def fit(self, X, Y=None, ctx=None):
         pass
 
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+        pass
+
+    def isFitted(self):
+        return True
+
 
 class MultiFeatureGenerator(FeatureGenerator):
     """
     Wrapper for multiple feature generators. Calling generate here applies all given feature generators independently and
     returns the concatenation of their outputs
     """
-    def __init__(self, featureGenerators: Sequence[FeatureGenerator]):
-        """
-        :param featureGenerators:
-        """
-        self.featureGenerators = featureGenerators
+    def __init__(self, *featureGenerators: Union[FeatureGenerator, List[FeatureGenerator]]):
+        self.featureGenerators = flattenArguments(featureGenerators)
+        if len(self.featureGenerators) == 0:
+            log.info("Creating an empty MultiFeatureGenerator. It will generate a data frame without columns.")
         categoricalFeatureNameRegexes = [regex for regex in [fg.getCategoricalFeatureNameRegex() for fg in featureGenerators] if regex is not None]
         if len(categoricalFeatureNameRegexes) > 0:
             categoricalFeatureNames = "|".join(categoricalFeatureNameRegexes)
@@ -234,10 +277,18 @@ def generateFeatures(fg: FeatureGenerator):
             return fg.fitGenerate(X, Y, ctx)
         return self._generateFromMultiple(generateFeatures, X.index)
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         for fg in self.featureGenerators:
             fg.fit(X, Y)
 
+    def isFitted(self):
+        return all([fg.isFitted() for fg in self.featureGenerators])
+
+    def info(self):
+        info = super(MultiFeatureGenerator, self).info()
+        info["featureGeneratorNames"] = self.getNames()
+        return info
+
     def getNames(self) -> List[str]:
         """
         :return: the list of names of all contained feature generators
@@ -310,6 +361,12 @@ def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
 
         return df[columnsToTake]
 
+    def info(self):
+        info = super().info()
+        info["columns"] = self.columns
+        info["exceptColumns"] = self.exceptColumns
+        return info
+
 
 class FeatureGeneratorFlattenColumns(RuleBasedFeatureGenerator):
     """
@@ -351,6 +408,11 @@ def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
             resultDf[new_columns] = pd.DataFrame(values, index=df.index)
         return resultDf
 
+    def info(self):
+        info = super().info()
+        info["columns"] = self.columns
+        return info
+
 
 class FeatureGeneratorFromColumnGenerator(RuleBasedFeatureGenerator):
     """
@@ -378,6 +440,12 @@ def __init__(self, columnGen: ColumnGenerator, takeInputColumnIfPresent=False, i
         self.takeInputColumnIfPresent = takeInputColumnIfPresent
         self.columnGen = columnGen
 
+    def info(self):
+        info = super().info()
+        info["takeInputColumnIfPresent"] = self.takeInputColumnIfPresent
+        info["generatedColName"] = self.columnGen.generatedColumnName
+        return info
+
     def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
         colName = self.columnGen.generatedColumnName
         if self.takeInputColumnIfPresent and colName in df.columns:
@@ -394,31 +462,44 @@ class ChainedFeatureGenerator(FeatureGenerator):
     Chains feature generators such that they are executed one after another. The output of generator i>=1 is the input of
     generator i+1 in the generator sequence.
     """
-    def __init__(self, *featureGenerators: FeatureGenerator):
+    def __init__(self, *featureGenerators: Union[FeatureGenerator, List[FeatureGenerator]]):
         """
         :param featureGenerators: feature generators to apply in order; the properties of the last feature generator
             determine the relevant meta-data such as categorical feature names and normalisation rules
         """
+        self.featureGenerators = flattenArguments(featureGenerators)
         if len(featureGenerators) == 0:
             raise ValueError("Empty list of feature generators")
-        lastFG: FeatureGenerator = featureGenerators[-1]
+        lastFG: FeatureGenerator = self.featureGenerators[-1]
         super().__init__(categoricalFeatureNames=lastFG.getCategoricalFeatureNameRegex(), normalisationRules=lastFG.getNormalisationRules(),
             addCategoricalDefaultRules=False)
-        self.featureGenerators = featureGenerators
 
     def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
         for featureGen in self.featureGenerators:
             df = featureGen.generate(df, ctx)
         return df
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         self.fitGenerate(X, Y, ctx)
 
+    def isFitted(self):
+        return all([fg.isFitted() for fg in self.featureGenerators])
+
+    def info(self):
+        info = super().info()
+        info["chainedFeatureGeneratorNames"] = self.getNames()
+
     def fitGenerate(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None) -> pd.DataFrame:
         for fg in self.featureGenerators:
             X = fg.fitGenerate(X, Y, ctx)
         return X
 
+    def getNames(self) -> List[str]:
+        """
+        :return: the list of names of all contained feature generators
+        """
+        return [fg.getName() for fg in self.featureGenerators]
+
 
 class FeatureGeneratorTargetDistribution(FeatureGenerator):
     """
@@ -455,7 +536,7 @@ def __init__(self, columns: Union[str, Sequence[str]], targetColumn: str,
             columns = [columns]
         self.columns = columns
         self.targetColumn = targetColumn
-        self.targetColumnInInputDf = targetColumnInFeaturesDf
+        self.targetColumnInFeaturesDf = targetColumnInFeaturesDf
         self.targetColumnBins = targetColumnBins
         if self.flatten:
             normalisationRuleTemplate = data_transformation.DFTNormalisation.RuleTemplate(skip=True)
@@ -464,13 +545,21 @@ def __init__(self, columns: Union[str, Sequence[str]], targetColumn: str,
         super().__init__(normalisationRuleTemplate=normalisationRuleTemplate)
         self._targetColumnValues = None
         # This will hold the mapping: column -> featureValue -> targetValue -> targetValueEmpiricalProbability
-        self._discreteTargetDistributionsByColumn: Dict[str, Dict[Any, Dict[Any, float]]] = None
+        self._discreteTargetDistributionsByColumn: Optional[Dict[str, Dict[Any, Dict[Any, float]]]] = None
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+    def info(self):
+        info = super().info()
+        info["columns"] = self.columns
+        info["targetColumn"] = self.targetColumn
+        info["targetColumnBins"] = self.targetColumnBins
+        info["flatten"] = self.flatten
+        return info
+
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         """
         This will persist the empirical target probability distributions for all unique values in the specified columns
         """
-        if self.targetColumnInInputDf:
+        if self.targetColumnInFeaturesDf:
             target = X[self.targetColumn]
         else:
             target = Y[self.targetColumn]
@@ -626,7 +715,7 @@ def _createMultiFeatureGenerator(self):
                 featureGenerators.append(self._registry.getFeatureGenerator(f))
             else:
                 raise ValueError(f"Unexpected type {type(f)} in list of features")
-        return MultiFeatureGenerator(featureGenerators)
+        return MultiFeatureGenerator(*featureGenerators)
 
 
 class FeatureGeneratorFromVectorModel(FeatureGenerator):
@@ -658,7 +747,7 @@ def __init__(self, vectorModel: "VectorModel", targetFeatureGenerator: FeatureGe
         self.useTargetFeatureGeneratorForTraining = useTargetFeatureGeneratorForTraining
         self.vectorModel = vectorModel
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         targetDF = self.targetFeatureGenerator.fitGenerate(X, Y)
         if self.inputFeatureGenerator:
             X = self.inputFeatureGenerator.fitGenerate(X, Y)
@@ -674,6 +763,11 @@ def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
             log.info(f"Generating target features via {self.vectorModel.__class__.__name__}")
             return self.vectorModel.predict(df)
 
+    def info(self):
+        info = super().info()
+        info["wrappedModel"] = str(self.vectorModel)
+        return info
+
 
 def flattenedFeatureGenerator(fgen: FeatureGenerator, columnsToFlatten: List[str] = None,
                             normalisationRules=(), normalisationRuleTemplate: data_transformation.DFTNormalisation.RuleTemplate = None):
@@ -707,4 +801,4 @@ def flattenedFeatureGenerator(fgen: FeatureGenerator, columnsToFlatten: List[str
         return ChainedFeatureGenerator(fgen, flatteningGenerator)
     else:
         return ChainedFeatureGenerator(fgen,
-            MultiFeatureGenerator([flatteningGenerator, FeatureGeneratorTakeColumns(exceptColumns=columnsToFlatten)]))
+            MultiFeatureGenerator(flatteningGenerator, FeatureGeneratorTakeColumns(exceptColumns=columnsToFlatten)))
diff --git a/src/sensai/nearest_neighbors.py b/src/sensai/nearest_neighbors.py
index d34bf16ee..2101e05ef 100644
--- a/src/sensai/nearest_neighbors.py
+++ b/src/sensai/nearest_neighbors.py
@@ -1,20 +1,19 @@
 import collections
-import typing
-
 import datetime
 import logging
+import typing
 from abc import ABC, abstractmethod
-from typing import Callable, List, Iterable
+from typing import Callable, List, Iterable, Optional
 
 import numpy as np
 import pandas as pd
 
 from . import distance_metric, util, data_transformation
-from .vector_model import VectorClassificationModel, VectorRegressionModel
 from .distance_metric import DistanceMetric
 from .featuregen import FeatureGeneratorFromNamedTuples
 from .util.string import objectRepr
 from .util.typing import PandasNamedTuple
+from .vector_model import VectorClassificationModel, VectorRegressionModel
 
 _log = logging.getLogger(__name__)
 
@@ -361,7 +360,7 @@ def __init__(self, numNeighbors: int,
         self.distanceMetric = distanceMetric
         self.neighborProviderFactory = neighborProviderFactory
         self.numNeighbors = numNeighbors
-        self._knnFinder: KNearestNeighboursFinder = None
+        self._knnFinder: Optional[KNearestNeighboursFinder] = None
         self._trainX = None
 
     def _generate(self, df: pd.DataFrame, ctx=None):
@@ -380,5 +379,5 @@ def _generateFeatureDict(self, namedTuple) -> typing.Dict[str, typing.Any]:
                 result[f"n{i}_{attr}"] = getattr(neighbor.value, attr)
         return result
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame, ctx=None):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
         self._trainX = X
diff --git a/src/sensai/util/__init__.py b/src/sensai/util/__init__.py
index 949f9f9ac..611737932 100644
--- a/src/sensai/util/__init__.py
+++ b/src/sensai/util/__init__.py
@@ -1,6 +1,6 @@
 import logging
 import time
-from typing import Sequence, TypeVar, List
+from typing import Sequence, TypeVar, List, Union
 
 from . import cache
 
@@ -21,6 +21,23 @@ def dict2OrderedTuples(d: dict):
     return tuple(keys), tuple(values)
 
 
+def flattenArguments(args: Sequence[Union[T, Sequence[T]]]) -> List[T]:
+    """
+    Main use case is to support both interfaces of the type f(T1, T2, ...) and f([T1, T2, ...]) simultaneously.
+    It is assumed that if the latter form is passed, the arguments are either in a list or a tuple. Moreover,
+    T cannot be a tuple or a list itself.
+
+    Overall this function is not all too safe and one should be aware of what one is doing when using it
+    """
+    result = []
+    for arg in args:
+        if isinstance(arg, list) or isinstance(arg, tuple):
+            result.extend(arg)
+        else:
+            result.append(arg)
+    return result
+
+
 class LogTime:
     def __init__(self, name):
         self.name = name
diff --git a/src/sensai/util/pandas.py b/src/sensai/util/pandas.py
new file mode 100644
index 000000000..c211c98c7
--- /dev/null
+++ b/src/sensai/util/pandas.py
@@ -0,0 +1,59 @@
+from copy import copy
+
+import pandas as pd
+
+
+class DataFrameColumnChangeTracker:
+    """
+    A simple class for keeping track of changes in columns between an initial data frame and some other data frame
+    (usually the result of some transformations performed on the initial one).
+
+    Example:
+
+    >>> from sensai.util.pandas import DataFrameColumnChangeTracker
+    >>> import pandas as pd
+
+    >>> df = pd.DataFrame({"bar": [1, 2]})
+    >>> columnChangeTracker = DataFrameColumnChangeTracker(df)
+    >>> df["foo"] = [4, 5]
+    >>> columnChangeTracker.trackChange(df)
+    >>> columnChangeTracker.getRemovedColumns()
+    set()
+    >>> columnChangeTracker.getAddedColumns()
+    {'foo'}
+    """
+    def __init__(self, initialDF: pd.DataFrame):
+        self.initialColumns = copy(initialDF.columns)
+        self.finalColumns = None
+
+    def trackChange(self, changedDF: pd.DataFrame):
+        self.finalColumns = copy(changedDF.columns)
+
+    def getRemovedColumns(self):
+        self.assertChangeWasTracked()
+        return set(self.initialColumns).difference(self.finalColumns)
+
+    def getAddedColumns(self):
+        """
+        Returns the columns in the last entry of the history that were not present the first one
+        """
+        self.assertChangeWasTracked()
+        return set(self.finalColumns).difference(self.initialColumns)
+
+    def columnChangeString(self):
+        """
+        Returns a string representation of the change
+        """
+        self.assertChangeWasTracked()
+        if list(self.initialColumns) == list(self.finalColumns):
+            return "none"
+        removedCols, addedCols = self.getRemovedColumns(), self.getAddedColumns()
+        if removedCols == addedCols == set():
+            return f"reordered {list(self.finalColumns)}"
+
+        return f"added={list(addedCols)}, removed={list(removedCols)}"
+
+    def assertChangeWasTracked(self):
+        if self.finalColumns is None:
+            raise Exception(f"No change was tracked yet. "
+                            f"Did you forget to call trackChange on the resulting data frame?")
diff --git a/src/sensai/vector_model.py b/src/sensai/vector_model.py
index 85d5d05e6..474b2a1a5 100644
--- a/src/sensai/vector_model.py
+++ b/src/sensai/vector_model.py
@@ -6,7 +6,7 @@
 
 import logging
 from abc import ABC, abstractmethod
-from typing import Sequence, List, Any, Optional, Union, TypeVar, Type
+from typing import List, Any, Optional, Union, Type
 
 import numpy as np
 import pandas as pd
@@ -18,17 +18,14 @@
 log = logging.getLogger(__name__)
 
 
-T = TypeVar('T')
-
-
 class PredictorModel(PickleLoadSaveMixin, ABC):
     """
     Base class for models that map data frames to predictions
     """
     def __init__(self):
         self._featureGenerator: Optional[FeatureGenerator] = None
-        self._inputTransformerChain = DataFrameTransformerChain(())
-        self._outputTransformerChain = DataFrameTransformerChain(())
+        self._inputTransformerChain = DataFrameTransformerChain()
+        self._outputTransformerChain = DataFrameTransformerChain()
         self._name = None
 
     @abstractmethod
@@ -43,16 +40,6 @@ def getPredictedVariableNames(self):
     def isRegressionModel(self) -> bool:
         pass
 
-    @staticmethod
-    def _flattened(l: Sequence[Union[T, List[T]]]) -> List[T]:
-        result = []
-        for x in l:
-            if type(x) == list:
-                result.extend(x)
-            else:
-                result.append(x)
-        return result
-
     def withInputTransformers(self, *inputTransformers: Union[DataFrameTransformer, List[DataFrameTransformer]]) -> __qualname__:
         """
         Makes the model use the given input transformers.
@@ -60,18 +47,19 @@ def withInputTransformers(self, *inputTransformers: Union[DataFrameTransformer,
         :param inputTransformers: DataFrameTransformers for the transformation of inputs
         :return: self
         """
-        self._inputTransformerChain = DataFrameTransformerChain(self._flattened(inputTransformers))
+        self._inputTransformerChain = DataFrameTransformerChain(*inputTransformers)
         return self
 
     def withOutputTransformers(self, *outputTransformers: Union[DataFrameTransformer, List[DataFrameTransformer]]) -> __qualname__:
         """
-        Makes the model use the given output transformers.
+        Makes the model use the given output transformers. For models that can be fitted, they are ignored during
+        the fit phase.
 
         :param outputTransformers: DataFrameTransformers for the transformation of outputs
             (after the model has been applied)
         :return: self
         """
-        self._outputTransformerChain = DataFrameTransformerChain(self._flattened(outputTransformers))
+        self._outputTransformerChain = DataFrameTransformerChain(*outputTransformers)
         return self
 
     def withFeatureGenerator(self, featureGenerator: Optional[FeatureGenerator]) -> __qualname__:
@@ -118,6 +106,12 @@ def getInputTransformer(self, cls: Type[DataFrameTransformer]):
                 return it
         return None
 
+    def getInputTransformerChain(self):
+        return self._inputTransformerChain
+
+    def getOutputTransformerChain(self):
+        return self._outputTransformerChain
+
     def setName(self, name):
         self._name = name
 
@@ -132,12 +126,22 @@ def setFeatureGenerator(self, featureGenerator: Optional[FeatureGenerator]):
     def getFeatureGenerator(self) -> Optional[FeatureGenerator]:
         return self._featureGenerator
 
+    def _prePostProcessorsAreFitted(self):
+        result = self._inputTransformerChain.isFitted() and self._outputTransformerChain.isFitted()
+        if self.getFeatureGenerator() is not None:
+            result = result and self.getFeatureGenerator().isFitted()
+        return result
+
 
 class FittableModel(PredictorModel, ABC):
     @abstractmethod
     def fit(self, X: pd.DataFrame, Y: pd.DataFrame):
         pass
 
+    @abstractmethod
+    def isFitted(self) -> bool:
+        pass
+
 
 class RuleBasedModel(FittableModel, ABC):
     """
@@ -147,13 +151,14 @@ class RuleBasedModel(FittableModel, ABC):
     """
     def __init__(self):
         super().__init__()
-        self._isFitted = False
 
     def fit(self, X: pd.DataFrame, Y: pd.DataFrame = None):
         if self._featureGenerator is not None:
             self._featureGenerator.fit(X, Y=Y, ctx=self)
         self._inputTransformerChain.fit(X)
-        self._isFitted = True
+
+    def isFitted(self):
+        return self._prePostProcessorsAreFitted()
 
     def predict(self, x: pd.DataFrame) -> pd.DataFrame:
         """
@@ -162,12 +167,9 @@ def predict(self, x: pd.DataFrame) -> pd.DataFrame:
         :param x: the input data
         :return: a DataFrame with the same index as the input
         """
-        if not self._isFitted:
-            log.warning(f"The input transformers and feature generators of {self} might never have been fitted. "
-                        f"Ignore this warning if they don't require fitting or have been fitted separately from {self}")
         if self._featureGenerator is not None:
             x = self._featureGenerator.generate(x, self)
-        x = self._inputTransformerChain.apply(x, fit=False)
+        x = self._inputTransformerChain.apply(x)
         x = self._predict(x)
         x = self._outputTransformerChain.apply(x)
         return x
@@ -189,7 +191,7 @@ def __init__(self, checkInputColumns=True):
             e.g. in ensemble models.
         """
         super().__init__()
-        self._isFitted = False
+        self._isFitted = False  # Note: this keeps track only of the actual model being fitted, not the pre/postprocessors
         self._predictedVariableNames = None
         self._modelInputVariableNames = None
         self._modelOutputVariableNames = ["UNKNOWN"]
@@ -211,19 +213,26 @@ def withTargetTransformer(self, targetTransformer: Optional[InvertibleDataFrameT
         self._targetTransformer = targetTransformer
         return self
 
+    def getTargetTransformer(self):
+        return self._targetTransformer
+
     def isFitted(self):
-        return self._isFitted
+        result = self._isFitted and self._prePostProcessorsAreFitted()
+        if self._targetTransformer is not None:
+            result = result and self._targetTransformer.isFitted()
+        return result
 
     def _computeInputs(self, X: pd.DataFrame, Y: pd.DataFrame = None, fit=False) -> pd.DataFrame:
-        if self._featureGenerator is not None:
-            if fit:
+        if fit:
+            if self._featureGenerator is not None:
                 X = self._featureGenerator.fitGenerate(X, Y, self)
-            else:
-                X = self._featureGenerator.generate(X, self)
-        X = self._inputTransformerChain.apply(X, fit=fit)
-        if not fit:
+            X = self._inputTransformerChain.fitApply(X)
+        else:
             if not self.isFitted():
                 raise Exception(f"Model has not been fitted")
+            if self._featureGenerator is not None:
+                X = self._featureGenerator.generate(X, self)
+            X = self._inputTransformerChain.apply(X)
             if self.checkInputColumns and list(X.columns) != self._modelInputVariableNames:
                 raise Exception(f"Inadmissible input data frame: expected columns {self._modelInputVariableNames}, got {list(X.columns)}")
         return X
@@ -247,19 +256,27 @@ def predict(self, x: pd.DataFrame) -> pd.DataFrame:
     def _predict(self, x: pd.DataFrame) -> pd.DataFrame:
         pass
 
-    def fit(self, X: pd.DataFrame, Y: pd.DataFrame):
+    def fit(self, X: pd.DataFrame, Y: pd.DataFrame, fitPreprocessors=True, fitTargetTransformer=True):
         """
         Fits the model using the given data
 
         :param X: a data frame containing input data
         :param Y: a data frame containing output data
+        :param fitPreprocessors: if False, the model's feature generator and input transformer will not be fitted.
+            If a preprocessor requires fitting, was not separately fit before and this option is set to False,
+            an exception will be raised.
+        :param fitTargetTransformer: if False, the model's target transformer will not be fitted.
+            If it requires fitting, was not separately fit before and this option is set to False,
+            an exception will be raised.
         """
         log.info(f"Training {self.__class__.__name__}")
         self._predictedVariableNames = list(Y.columns)
-        X = self._computeInputs(X, Y=Y, fit=True)
+        X = self._computeInputs(X, Y=Y, fit=fitPreprocessors)
         if self._targetTransformer is not None:
-            self._targetTransformer.fit(Y)
-            Y = self._targetTransformer.apply(Y)
+            if fitTargetTransformer:
+                Y = self._targetTransformer.fitApply(Y)
+            else:
+                Y = self._targetTransformer.apply(Y)
         self._modelInputVariableNames = list(X.columns)
         self._modelOutputVariableNames = list(Y.columns)
         log.info(f"Training with outputs[{len(self._modelOutputVariableNames)}]={self._modelOutputVariableNames}, inputs[{len(self._modelInputVariableNames)}]=[{', '.join([n + '/' + X[n].dtype.name for n in self._modelInputVariableNames])}]")


Test patch:
diff --git a/tests/backwardscompat/test_models.py b/tests/backwardscompat/test_models.py
new file mode 100644
index 000000000..42e2a348a
--- /dev/null
+++ b/tests/backwardscompat/test_models.py
@@ -0,0 +1,13 @@
+import os
+
+import sensai
+from sensai import VectorModel
+
+
+def test_modelCanBeLoaded(testResources, irisClassificationTestCase):
+    # The model file was generated with tests/frameworks/torch/test_torch.test_MLPClassifier at commit f93c6b11d
+    modelPath = os.path.join(testResources, "torch_mlp.pickle")
+    model = VectorModel.load(modelPath)
+    assert isinstance(model, sensai.torch.models.MultiLayerPerceptronVectorClassificationModel)
+    irisClassificationTestCase.testMinAccuracy(model, 0.8, fit=False)
+
diff --git a/tests/base/test_data_transformation.py b/tests/base/test_data_transformation.py
new file mode 100644
index 000000000..454781769
--- /dev/null
+++ b/tests/base/test_data_transformation.py
@@ -0,0 +1,51 @@
+import pandas as pd
+
+from sensai.data_transformation import DataFrameTransformer, RuleBasedDataFrameTransformer, DataFrameTransformerChain
+
+
+class TestDFTTransformerBasics:
+    class TestDFT(DataFrameTransformer):
+        def _fit(self, df: pd.DataFrame):
+            pass
+
+        def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
+            return pd.DataFrame({"foo": [1, 2], "baz": [1, 2]})
+
+    class RuleBasedTestDFT(RuleBasedDataFrameTransformer):
+        def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
+            return df
+
+    testdf = pd.DataFrame({"foo": [1, 2], "bar": [1, 2]})
+
+    def test_basicProperties(self):
+        testdft = self.TestDFT()
+
+        assert not testdft.isFitted()
+        assert testdft.info()["changeInColumnNames"] is None
+        testdft.fit(self.testdf)
+        assert testdft.isFitted()
+        assert all(testdft.apply(self.testdf) == pd.DataFrame({"foo": [1, 2], "baz": [1, 2]}))
+        assert testdft.info()["changeInColumnNames"] is not None
+
+        # testing apply with no change in columns
+        testdft.apply(pd.DataFrame({"foo": [1, 2], "baz": [1, 2]}))
+        assert testdft.info()["changeInColumnNames"] == "none"
+
+    def test_ruleBasedAlwaysFitted(self):
+        assert self.RuleBasedTestDFT().isFitted()
+
+    def test_emptyChainFitted(self):
+        assert DataFrameTransformerChain().isFitted()
+
+    def test_combinationFittedIffEachMemberFitted(self):
+        # if one of the fgens is not fitted, the combination is not fitted either
+        dftChain = DataFrameTransformerChain(self.TestDFT(), self.RuleBasedTestDFT())
+        assert dftChain.dataFrameTransformers[1].isFitted()
+        assert not dftChain.isFitted()
+        dftChain.fit(self.testdf)
+        assert dftChain.isFitted()
+        assert dftChain.dataFrameTransformers[0].isFitted()
+
+        # if all fgens are fitted, the combination is also fitted, even if fit was not called
+        dftChain = DataFrameTransformerChain([self.RuleBasedTestDFT(), self.RuleBasedTestDFT()])
+        assert dftChain.isFitted()
diff --git a/tests/base/test_featuregen.py b/tests/base/test_featuregen.py
index dc112c8c2..b0194f877 100644
--- a/tests/base/test_featuregen.py
+++ b/tests/base/test_featuregen.py
@@ -2,7 +2,8 @@
 import pandas as pd
 import pytest
 
-from sensai.featuregen import FeatureGeneratorFlattenColumns, FeatureGeneratorTakeColumns, flattenedFeatureGenerator
+from sensai.featuregen import FeatureGeneratorFlattenColumns, FeatureGeneratorTakeColumns, flattenedFeatureGenerator, \
+    FeatureGenerator, RuleBasedFeatureGenerator, MultiFeatureGenerator, ChainedFeatureGenerator
 
 
 def test_take_columns():
@@ -36,3 +37,62 @@ def test_getFlattenedFeatureGenerator():
     inputDf = pd.DataFrame({"a": [np.array([1, 2])], "b": [np.array([5, 6])]})
     fgen1 = flattenedFeatureGenerator(FeatureGeneratorTakeColumns("a"))
     assert fgen1.generate(inputDf).equals(pd.DataFrame({"a_0": [1], "a_1": [2]}))
+
+
+class TestFgen(FeatureGenerator):
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+        pass
+
+    def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
+        return df
+
+
+class RuleBasedTestFgen(RuleBasedFeatureGenerator):
+    def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
+        return df
+
+
+class TestFgenBasics:
+    def __init__(self):
+        self.testdf = pd.DataFrame({"foo": [1, 2], "bar": [1, 2]})
+
+    def test_basicProperties(self):
+        testfgen = TestFgen()
+
+        assert not testfgen.isFitted()
+        assert testfgen.getGeneratedColumnNames() is None
+        testfgen.fit(self.testdf)
+        assert testfgen.isFitted()
+        testfgen.generate(self.testdf)
+        assert set(testfgen.getGeneratedColumnNames()) == {"foo", "bar"}
+    
+    @pytest.mark.parametrize("fgen", [TestFgen(), RuleBasedTestFgen(), MultiFeatureGenerator(TestFgen()),
+        ChainedFeatureGenerator(TestFgen())])
+    def test_Naming(self, fgen):
+        assert isinstance(fgen.getName(), str)
+        fgen.setName("bar")
+        assert fgen.getName() == "bar"
+
+    def test_ruleBasedAlwaysFitted(self):
+        assert RuleBasedTestFgen().isFitted()
+
+    def test_emptyChainRaisesError(self):
+        with pytest.raises(ValueError):
+            ChainedFeatureGenerator()
+
+    def test_combinationFittedIffEachMemberFitted(self):
+        # if one of the fgens is not fitted, the combination is not fitted either
+        multifgen = MultiFeatureGenerator(TestFgen(), RuleBasedTestFgen())
+        chainfgen = ChainedFeatureGenerator(TestFgen(), RuleBasedTestFgen())
+        assert chainfgen.featureGenerators[1].isFitted() and multifgen.featureGenerators[1].isFitted()
+        assert not multifgen.isFitted() and not chainfgen.isFitted()
+        chainfgen.fit(self.testdf)
+        multifgen.fit(self.testdf)
+        assert multifgen.isFitted() and chainfgen.isFitted()
+        assert chainfgen.featureGenerators[0].isFitted() and multifgen.featureGenerators[0].isFitted()
+
+        # if all fgens are fitted, the combination is also fitted, even if fit was not called
+        multifgen = MultiFeatureGenerator(RuleBasedTestFgen(), RuleBasedTestFgen())
+        chainfgen = ChainedFeatureGenerator(RuleBasedTestFgen(), RuleBasedTestFgen())
+        assert multifgen.isFitted() and chainfgen.isFitted()
+
diff --git a/tests/base/test_vector_model.py b/tests/base/test_vector_model.py
new file mode 100644
index 000000000..12fdb1b23
--- /dev/null
+++ b/tests/base/test_vector_model.py
@@ -0,0 +1,150 @@
+from copy import copy
+from typing import Optional
+
+import pandas as pd
+import pytest
+
+from sensai.data_transformation import DFTDRowFilterOnIndex, \
+    InvertibleDataFrameTransformer
+from sensai.featuregen import FeatureGeneratorTakeColumns, FeatureGenerator
+from sensai.vector_model import RuleBasedModel, VectorModel
+
+
+class FittableFgen(FeatureGenerator):
+
+    def _fit(self, X: pd.DataFrame, Y: pd.DataFrame = None, ctx=None):
+        pass
+
+    def _generate(self, df: pd.DataFrame, ctx=None) -> pd.DataFrame:
+        return df
+
+
+class FittableDFT(InvertibleDataFrameTransformer):
+
+    def applyInverse(self, df: pd.DataFrame) -> pd.DataFrame:
+        return df
+
+    def _fit(self, df: pd.DataFrame):
+        pass
+
+    def _apply(self, df: pd.DataFrame) -> pd.DataFrame:
+        return df
+
+
+class SampleRuleBasedModel(RuleBasedModel):
+
+    def _predict(self, X: pd.DataFrame) -> pd.DataFrame:
+        return pd.DataFrame({"prediction": 1}, index=X.index)
+
+    def getPredictedVariableNames(self):
+        return ["prediction"]
+
+    def isRegressionModel(self) -> bool:
+        return True
+
+
+class SampleVectorModel(VectorModel):
+
+    def _predict(self, x: pd.DataFrame) -> pd.DataFrame:
+        return pd.DataFrame({"prediction": 1}, index=x.index)
+
+    def _fit(self, X: pd.DataFrame, Y: Optional[pd.DataFrame]):
+        pass
+
+    def isRegressionModel(self) -> bool:
+        return True
+
+
+@pytest.fixture()
+def ruleBasedFgen():
+    return FeatureGeneratorTakeColumns()
+
+
+@pytest.fixture()
+def fittableFgen():
+    return FittableFgen()
+
+
+@pytest.fixture()
+def ruleBasedDFT():
+    return DFTDRowFilterOnIndex()
+
+
+@pytest.fixture()
+def fittableDFT():
+    return FittableDFT()
+
+
+@pytest.fixture()
+def ruleBasedModel():
+    return SampleRuleBasedModel()
+
+
+@pytest.fixture()
+def vectorModel():
+    return SampleVectorModel()
+
+
+testX = pd.DataFrame({"bar": [1, 2, 3]})
+testY = pd.DataFrame({"prediction": [0, 0, 0]})
+
+
+def fittedVectorModel():
+    model = SampleVectorModel()
+    model.fit(testX, testY)
+    return model
+
+
+class TestIsFitted:
+    @pytest.mark.parametrize("model", [SampleRuleBasedModel(), fittedVectorModel()])
+    def test_isFittedWhenPreprocessorsRuleBased(self, model, ruleBasedDFT, ruleBasedFgen):
+        assert model.isFitted()
+        model.withFeatureGenerator(ruleBasedFgen)
+        model.withInputTransformers(ruleBasedDFT)
+        assert model.isFitted()
+
+    @pytest.mark.parametrize("modelConstructor", [SampleRuleBasedModel, fittedVectorModel])
+    def test_isFittedWithFittableProcessors(self, modelConstructor, fittableDFT, fittableFgen):
+        # is fitted after fit with model
+        model = modelConstructor().withInputTransformers(fittableDFT)
+        assert not model.isFitted()
+        model.fit(testX, testY)
+        assert model.isFitted()
+        
+        # is fitted if DFT is fitted
+        fittedDFT = copy(fittableDFT)
+        fittedDFT.fit(testX)
+        model = modelConstructor().withInputTransformers(fittedDFT)
+        assert model.isFitted()
+
+        # same for fgen
+        model = modelConstructor().withFeatureGenerator(fittableFgen)
+        assert not model.isFitted()
+        model.fit(testX, testY)
+        assert model.isFitted()
+
+        fittedFgen = copy(fittableFgen)
+        fittedFgen.fit(testX)
+        model = modelConstructor().withFeatureGenerator(fittedFgen)
+        assert model.isFitted()
+
+    def test_isFittedWithTargetTransformer(self, vectorModel, fittableDFT):
+        assert not vectorModel.isFitted()
+        vectorModel.fit(testX, testY)
+        assert vectorModel.isFitted()
+        vectorModel.withTargetTransformer(fittableDFT)
+
+        # test fitting separately
+        assert not vectorModel.isFitted()
+        vectorModel.getTargetTransformer().fit(testX)
+        assert vectorModel.isFitted()
+
+        # test fitting together
+        vectorModel = SampleVectorModel().withTargetTransformer(FittableDFT())
+        assert not vectorModel.isFitted()
+        vectorModel.fit(testX, testY)
+        assert vectorModel.isFitted()
+        assert vectorModel.getTargetTransformer().isFitted()
+
+
+
diff --git a/tests/conftest.py b/tests/conftest.py
index fa949752b..2c7ab5df7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
 import logging
-
+import sys
+import os
 import pandas as pd
 import pytest
 import sklearn.datasets
@@ -7,9 +8,17 @@
 from sensai import InputOutputData, VectorClassificationModel
 from sensai.evaluation import VectorClassificationModelEvaluator
 
+sys.path.append(os.path.abspath("."))
+from config import topLevelDirectory
+
 log = logging.getLogger(__name__)
 
 
+@pytest.fixture(scope="session")
+def testResources():
+    return os.path.join(topLevelDirectory, "tests", "resources")
+
+
 class IrisDataSet:
     _iod = None
 
@@ -28,9 +37,10 @@ class ClassificationTestCase:
     def __init__(self, data: InputOutputData):
         self.data = data
 
-    def testMinAccuracy(self, model: VectorClassificationModel, minAccuracy: float):
+    def testMinAccuracy(self, model: VectorClassificationModel, minAccuracy: float, fit=True):
         ev = VectorClassificationModelEvaluator(self.data, testFraction=0.2)
-        ev.fitModel(model)
+        if fit:
+            ev.fitModel(model)
         resultData = ev.evalModel(model)
         stats = resultData.getEvalStats()
         #stats.plotConfusionMatrix().savefig("cmat.png")
diff --git a/tests/frameworks/torch/test_torch.py b/tests/frameworks/torch/test_torch.py
index 387ff916d..2c5ace9f6 100644
--- a/tests/frameworks/torch/test_torch.py
+++ b/tests/frameworks/torch/test_torch.py
@@ -1,4 +1,5 @@
 import re
+import os
 
 import sklearn
 import torch
@@ -11,13 +12,14 @@
 from sensai.torch.torch_data import TorchDataSetFromTensors
 from sensai.torch.torch_modules import MultiLayerPerceptron
 from sensai.torch.torch_opt import NNLossEvaluatorClassification
+from sensai.featuregen import FeatureGeneratorTakeColumns
 
 
-def test_MLPClassifier(irisDataSet, irisClassificationTestCase):
+def test_MLPClassifier(irisDataSet, irisClassificationTestCase, testResources):
     featureNames = irisDataSet.getInputOutputData().inputs.columns
     dftNorm = DFTNormalisation([DFTNormalisation.Rule(re.escape(f)) for f in featureNames], defaultTransformerFactory=sklearn.preprocessing.StandardScaler)
     model = sensai.torch.models.MultiLayerPerceptronVectorClassificationModel(hiddenDims=(50,25,8), cuda=False, epochs=100, optimiser="adam",
-        batchSize=200, normalisationMode=NormalisationMode.NONE, hidActivationFunction=torch.tanh).withName("torchMLPClassifier").withInputTransformers([dftNorm])
+        batchSize=200, normalisationMode=NormalisationMode.NONE, hidActivationFunction=torch.tanh).withName("torchMLPClassifier").withInputTransformers([dftNorm]).withFeatureGenerator(FeatureGeneratorTakeColumns())
     irisClassificationTestCase.testMinAccuracy(model, 0.8)
 
 
diff --git a/tests/resources/torch_mlp.pickle b/tests/resources/torch_mlp.pickle
new file mode 100644
index 000000000..eeb26204b
Binary files /dev/null and b/tests/resources/torch_mlp.pickle differ