Repository: Aarhus-Psychiatry-Research/timeseriesflattener

Issue:
feat: drop if insufficient lookbehind or lookahead
Based on min in values_df?

Should be based on max in values_df.

1. Refactor to collect all specs before adding
2. Have one shared mechanism of adding specs, and allow it to take a list or a single spec at a time
3. Get the latest required date for sufficient lookbehind (get min from all PredSpec values_df's, then get max of that)
4. Get the earliest required date for sufficient lookahead (get max from all OutcomeSpec values_df's, then get min of that) 
5. Drop based on required dates 

Discussion:

Patch:
diff --git a/src/timeseriesflattener/feature_spec_objects.py b/src/timeseriesflattener/feature_spec_objects.py
index c010b0a..c4a3e81 100644
--- a/src/timeseriesflattener/feature_spec_objects.py
+++ b/src/timeseriesflattener/feature_spec_objects.py
@@ -135,8 +135,17 @@ class AnySpec(BaseModel):
     def __init__(self, **kwargs: Any):
         kwargs = resolve_values_df(kwargs)
 
+        # Check that required columns exist
         check_that_col_names_in_kwargs_exist_in_df(kwargs, df=kwargs["values_df"])
 
+        if (
+            "input_col_name_override" not in kwargs
+            and "value" not in kwargs["values_df"].columns
+        ):
+            raise KeyError(
+                f"The values_df must have a column named 'value' or an input_col_name_override must be specified. Columns in values_df: {list(kwargs['values_df'].columns)}",
+            )
+
         if in_dict_and_not_none(d=kwargs, key="output_col_name_override"):
             # If an output_col_name_override is specified, don't prepend a prefix to it
             kwargs["prefix"] = ""
@@ -159,11 +168,14 @@ class AnySpec(BaseModel):
         Trying to run `spec in list_of_specs` works for all attributes except for df, since the truth value of a dataframe is ambiguous.
         To remedy this, we use pandas' .equals() method for comparing the dfs, and get the combined truth value.
         """
-        other_attributes_equal = all(
-            getattr(self, attr) == getattr(other, attr)
-            for attr in self.__dict__
-            if attr != "values_df"
-        )
+        try:
+            other_attributes_equal = all(
+                getattr(self, attr) == getattr(other, attr)
+                for attr in self.__dict__
+                if attr != "values_df"
+            )
+        except AttributeError:
+            return False
 
         dfs_equal = self.values_df.equals(other.values_df)  # type: ignore
 
@@ -218,6 +230,25 @@ class TemporalSpec(AnySpec):
 
         super().__init__(**data)
 
+        timestamp_col_type = self.values_df[self.timestamp_col_name].dtype  # type: ignore
+
+        if timestamp_col_type not in ("Timestamp", "datetime64[ns]"):
+            # Convert column dtype to datetime64[ns] if it isn't already
+            log.info(
+                f"{self.feature_name}: Converting timestamp column to datetime64[ns]",
+            )
+
+            self.values_df[self.timestamp_col_name] = pd.to_datetime(
+                self.values_df[self.timestamp_col_name],
+            )
+
+            min_timestamp = min(self.values_df[self.timestamp_col_name])
+
+            if min_timestamp < pd.Timestamp("1971-01-01"):
+                log.warning(
+                    f"{self.feature_name}: Minimum timestamp is {min_timestamp} - perhaps ints were coerced to timestamps?",
+                )
+
         self.resolve_multiple_fn = data["resolve_multiple_fn"]
 
         # override fallback strings with objects
@@ -249,6 +280,20 @@ class PredictorSpec(TemporalSpec):
 
         super().__init__(**data)
 
+    def get_cutoff_date(self) -> pd.Timestamp:
+        """Get the cutoff date from a spec.
+
+        A cutoff date is the earliest date that a prediction time can get data from the values_df.
+        We do not want to include those prediction times, as we might make incorrect inferences.
+        For example, if a spec says to look 5 years into the future, but we only have one year of data,
+        there will necessarily be fewer outcomes - without that reflecting reality. This means our model won't generalise.
+
+        Returns:
+            pd.Timestamp: A cutoff date.
+        """
+        min_val_date = self.values_df[self.timestamp_col_name].min()  # type: ignore
+        return min_val_date + pd.Timedelta(days=self.lookbehind_days)
+
 
 class OutcomeSpec(TemporalSpec):
     """Specification for a single predictor, where the df has been resolved."""
@@ -290,6 +335,21 @@ class OutcomeSpec(TemporalSpec):
 
         return len(self.values_df[col_name].unique()) <= 2  # type: ignore
 
+    def get_cutoff_date(self) -> pd.Timestamp:
+        """Get the cutoff date from a spec.
+
+        A cutoff date is the earliest date that a prediction time can get data from the values_df.
+        We do not want to include those prediction times, as we might make incorrect inferences.
+        For example, if a spec says to look 5 years into the future, but we only have one year of data,
+        there will necessarily be fewer outcomes - without that reflecting reality. This means our model won't generalise.
+
+        Returns:
+            pd.Timestamp: A cutoff date.
+        """
+        max_val_date = self.values_df[self.timestamp_col_name].max()  # type: ignore
+
+        return max_val_date - pd.Timedelta(days=self.lookahead_days)
+
 
 class MinGroupSpec(BaseModel):
     """Minimum specification for a group of features, whether they're looking
diff --git a/src/timeseriesflattener/flattened_dataset.py b/src/timeseriesflattener/flattened_dataset.py
index 42a1d86..555a731 100644
--- a/src/timeseriesflattener/flattened_dataset.py
+++ b/src/timeseriesflattener/flattened_dataset.py
@@ -10,7 +10,7 @@ import time
 from collections.abc import Callable
 from datetime import timedelta
 from multiprocessing import Pool
-from typing import Optional
+from typing import Optional, Union
 
 import coloredlogs
 import numpy as np
@@ -19,22 +19,38 @@ import tqdm
 from catalogue import Registry  # noqa # pylint: disable=unused-import
 from dask.diagnostics import ProgressBar
 from pandas import DataFrame
+from pydantic import BaseModel as PydanticBaseModel
 
 from timeseriesflattener.feature_cache.abstract_feature_cache import FeatureCache
 from timeseriesflattener.feature_spec_objects import (
     AnySpec,
     OutcomeSpec,
     PredictorSpec,
+    StaticSpec,
     TemporalSpec,
 )
 from timeseriesflattener.flattened_ds_validator import ValidateInitFlattenedDataset
 from timeseriesflattener.resolve_multiple_functions import resolve_multiple_fns
+from timeseriesflattener.utils import print_df_dimensions_diff
 
 ProgressBar().register()
 
 log = logging.getLogger(__name__)
 
 
+class SpecCollection(PydanticBaseModel):
+    """A collection of specs."""
+
+    outcome_specs: list[OutcomeSpec] = []
+    predictor_specs: list[PredictorSpec] = []
+    static_specs: list[AnySpec] = []
+
+    def __len__(self):
+        return (
+            len(self.outcome_specs) + len(self.predictor_specs) + len(self.static_specs)
+        )
+
+
 class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
     """Turn a set of time-series into tabular prediction-time data."""
 
@@ -46,6 +62,9 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
 
         Avoids duplicate specification.
         """
+        if self.cache is None:
+            raise ValueError("Cache is None, cannot override attributes")
+
         if (
             not hasattr(self.cache, "prediction_times_df")
             or self.cache.prediction_times_df is None
@@ -57,7 +76,7 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
             )
             self.cache.prediction_times_df = prediction_times_df
 
-        for attr in ["pred_time_uuid_col_name", "timestamp_col_name", "id_col_name"]:
+        for attr in ("pred_time_uuid_col_name", "timestamp_col_name", "id_col_name"):
             if hasattr(self.cache, attr) and getattr(self.cache, attr) is not None:
                 if getattr(self.cache, attr) != getattr(self, attr):
                     log.warning(
@@ -68,13 +87,14 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
     def __init__(  # pylint: disable=too-many-arguments
         self,
         prediction_times_df: DataFrame,
+        drop_pred_times_with_insufficient_look_distance: bool,  # noqa
         cache: Optional[FeatureCache] = None,
         id_col_name: str = "dw_ek_borger",
         timestamp_col_name: str = "timestamp",
         predictor_col_name_prefix: str = "pred",
         outcome_col_name_prefix: str = "outc",
         n_workers: int = 60,
-        log_to_stdout: bool = True,
+        log_to_stdout: bool = True,  # noqa
     ):
         """Class containing a time-series, flattened.
 
@@ -108,6 +128,10 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
             outcome_col_name_prefix (str, optional): Prefix for outcome col names. Defaults to "outc_".
             n_workers (int): Number of subprocesses to spawn for parallelization. Defaults to 60.
             log_to_stdout (bool): Whether to log to stdout. Either way, also logs to the __name__ namespace, which you can capture with a root logger. Defaults to True.
+            drop_pred_times_with_insufficient_look_distance (bool): Whether to drop prediction times with insufficient look distance.
+                For example, say your feature has a lookbehind of 2 years, and your first datapoint is 2013-01-01.
+                The first prediction time that has sufficient look distance will be on 2015-01-1.
+                Otherwise, your feature will imply that you've looked two years into the past, even though you have less than two years of data to look at.
 
         Raises:
             ValueError: If timestamp_col_name or id_col_name is not in prediction_times_df
@@ -121,6 +145,10 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
         self.outcome_col_name_prefix = outcome_col_name_prefix
         self.pred_time_uuid_col_name = "prediction_time_uuid"
         self.cache = cache
+        self.unprocessed_specs: SpecCollection = SpecCollection()
+        self.drop_pred_times_with_insufficient_look_distance = (
+            drop_pred_times_with_insufficient_look_distance
+        )
 
         if self.cache:
             self._override_cache_attributes_with_self_attributes(prediction_times_df)
@@ -152,12 +180,125 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
                 fmt="%(asctime)s [%(levelname)s] %(message)s",
             )
 
+    @staticmethod
+    def _add_back_prediction_times_without_value(
+        df: DataFrame,
+        pred_times_with_uuid: DataFrame,
+        pred_time_uuid_colname: str,
+    ) -> DataFrame:
+        """Ensure all prediction times are represented in the returned
+
+        dataframe.
+
+        Args:
+            df (DataFrame): Dataframe with prediction times but without uuid.
+            pred_times_with_uuid (DataFrame): Dataframe with prediction times and uuid.
+            pred_time_uuid_colname (str): Name of uuid column in both df and pred_times_with_uuid.
+
+        Returns:
+            DataFrame: A merged dataframe with all prediction times.
+        """
+        return pd.merge(
+            pred_times_with_uuid,
+            df,
+            how="left",
+            on=pred_time_uuid_colname,
+            suffixes=("", "_temp"),
+        ).drop(["timestamp_pred"], axis=1)
+
+    @staticmethod
+    def _resolve_multiple_values_within_interval_days(
+        resolve_multiple: Callable,
+        df: DataFrame,
+        pred_time_uuid_colname: str,
+    ) -> DataFrame:
+        """Apply the resolve_multiple function to prediction_times where there
+
+        are multiple values within the interval_days lookahead.
+
+        Args:
+            resolve_multiple (Callable): Takes a grouped df and collapses each group to one record (e.g. sum, count etc.).
+            df (DataFrame): Source dataframe with all prediction time x val combinations.
+            pred_time_uuid_colname (str): Name of uuid column in df.
+
+        Returns:
+            DataFrame: DataFrame with one row pr. prediction time.
+        """
+        # Convert timestamp val to numeric that can be used for resolve_multiple functions
+        # Numeric value amounts to days passed since 1/1/1970
+        try:
+            df["timestamp_val"] = (
+                df["timestamp_val"] - dt.datetime(1970, 1, 1)
+            ).dt.total_seconds() / 86400
+        except TypeError:
+            log.info("All values are NaT, returning empty dataframe")
+
+        # Sort by timestamp_pred in case resolve_multiple needs dates
+        df = df.sort_values(by="timestamp_val").groupby(pred_time_uuid_colname)
+
+        if isinstance(resolve_multiple, str):
+            resolve_multiple = resolve_multiple_fns.get(resolve_multiple)
+
+        if callable(resolve_multiple):
+            df = resolve_multiple(df).reset_index()
+        else:
+            raise ValueError("resolve_multiple must be or resolve to a Callable")
+
+        return df
+
+    @staticmethod
+    def _drop_records_outside_interval_days(
+        df: DataFrame,
+        direction: str,
+        interval_days: float,
+        timestamp_pred_colname: str,
+        timestamp_value_colname: str,
+    ) -> DataFrame:
+        """Filter by time from from predictions to values.
+
+        Drop if distance from timestamp_pred to timestamp_value is outside interval_days. Looks in `direction`.
+
+        Args:
+            direction (str): Whether to look ahead or behind.
+            interval_days (float): How far to look
+            df (DataFrame): Source dataframe
+            timestamp_pred_colname (str): Name of timestamp column for predictions in df.
+            timestamp_value_colname (str): Name of timestamp column for values in df.
+
+        Raises:
+            ValueError: If direction is niether ahead nor behind.
+
+        Returns:
+            DataFrame
+        """
+        df["time_from_pred_to_val_in_days"] = (
+            (df[timestamp_value_colname] - df[timestamp_pred_colname])
+            / (np.timedelta64(1, "s"))
+            / 86_400
+        )
+        # Divide by 86.400 seconds/day
+
+        if direction == "ahead":
+            df["is_in_interval"] = (
+                df["time_from_pred_to_val_in_days"] <= interval_days
+            ) & (df["time_from_pred_to_val_in_days"] > 0)
+        elif direction == "behind":
+            df["is_in_interval"] = (
+                df["time_from_pred_to_val_in_days"] >= -interval_days
+            ) & (df["time_from_pred_to_val_in_days"] < 0)
+        else:
+            raise ValueError("direction can only be 'ahead' or 'behind'")
+
+        return df[df["is_in_interval"]].drop(
+            ["is_in_interval", "time_from_pred_to_val_in_days"],
+            axis=1,
+        )
+
     @staticmethod
     def _flatten_temporal_values_to_df(  # noqa pylint: disable=too-many-locals
         prediction_times_with_uuid_df: DataFrame,
         output_spec: AnySpec,
         id_col_name: str,
-        timestamp_col_name: str,
         pred_time_uuid_col_name: str,
         verbose: bool = False,  # noqa
     ) -> DataFrame:
@@ -182,12 +323,6 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
         Returns:
             DataFrame
         """
-        for col_name in (timestamp_col_name, id_col_name):
-            if col_name not in output_spec.values_df.columns:  # type: ignore
-                raise ValueError(
-                    f"{col_name} does not exist in df_prediction_times, change the df or set another argument",
-                )
-
         # Generate df with one row for each prediction time x event time combination
         # Drop dw_ek_borger for faster merge
         df = pd.merge(
@@ -278,7 +413,6 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
                 ]
             ],
             id_col_name=self.id_col_name,
-            timestamp_col_name=self.timestamp_col_name,
             pred_time_uuid_col_name=self.pred_time_uuid_col_name,
             output_spec=feature_spec,
         )
@@ -351,84 +485,31 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
         log.info("Merging with original df")
         self._df = self._df.merge(right=new_features, on=self.pred_time_uuid_col_name)
 
-    def add_temporal_predictor_batch(  # pylint: disable=too-many-branches
+    def _add_temporal_batch(  # pylint: disable=too-many-branches
         self,
-        predictor_batch: list[PredictorSpec],
+        temporal_batch: list[TemporalSpec],
     ):
         """Add predictors to the flattened dataframe from a list."""
         # Shuffle predictor specs to avoid IO contention
-        random.shuffle(predictor_batch)
+        random.shuffle(temporal_batch)
+
+        n_workers = min(self.n_workers, len(temporal_batch))
 
-        with Pool(self.n_workers) as p:
+        with Pool(n_workers) as p:
             flattened_predictor_dfs = list(
                 tqdm.tqdm(
-                    p.imap(func=self._get_temporal_feature, iterable=predictor_batch),
-                    total=len(predictor_batch),
+                    p.imap(func=self._get_temporal_feature, iterable=temporal_batch),
+                    total=len(temporal_batch),
                 ),
             )
 
-        log.info("Feature generation complete, concatenating")
+        log.info("Processing complete, concatenating")
 
         self._concatenate_flattened_timeseries(
             flattened_predictor_dfs=flattened_predictor_dfs,
         )
 
-    def add_age_and_birth_year(
-        self,
-        id2date_of_birth: DataFrame,
-        input_date_of_birth_col_name: Optional[str] = "date_of_birth",
-        output_prefix: str = "pred",
-        birth_year_as_predictor: bool = False,  # noqa
-    ):
-        """Add age at prediction time as predictor.
-
-        Also add patient's birth date. Has its own function because of its very frequent use.
-
-        Args:
-            id2date_of_birth (DataFrame): Two columns, id and date_of_birth.
-            input_date_of_birth_col_name (str, optional): Name of the date_of_birth column in id2date_of_birth.
-                Defaults to "date_of_birth".
-            output_prefix (str, optional): Prefix for the output column. Defaults to "pred".
-            birth_year_as_predictor (bool, optional): Whether to add birth year as a predictor. Defaults to False.
-        """
-        if id2date_of_birth[input_date_of_birth_col_name].dtype != "<M8[ns]":
-            try:
-                id2date_of_birth[input_date_of_birth_col_name] = pd.to_datetime(
-                    id2date_of_birth[input_date_of_birth_col_name],
-                    format="%Y-%m-%d",
-                )
-            except ValueError as e:
-                raise ValueError(
-                    f"Conversion of {input_date_of_birth_col_name} to datetime failed, doesn't match format %Y-%m-%d. Recommend converting to datetime before adding.",
-                ) from e
-
-        output_age_col_name = f"{output_prefix}_age_in_years"
-
-        self.add_static_info(
-            static_spec=AnySpec(
-                values_df=id2date_of_birth,
-                input_col_name_override=input_date_of_birth_col_name,
-                prefix=output_prefix,
-                # We typically don't want to use date of birth as a predictor,
-                # but might want to use transformations - e.g. "year of birth" or "age at prediction time".
-                feature_name=input_date_of_birth_col_name,
-            ),
-        )
-
-        data_of_birth_col_name = f"{output_prefix}_{input_date_of_birth_col_name}"
-
-        self._df[output_age_col_name] = (
-            (
-                self._df[self.timestamp_col_name] - self._df[data_of_birth_col_name]
-            ).dt.days
-            / (365.25)
-        ).round(2)
-
-        if birth_year_as_predictor:
-            # Convert datetime to year
-            self._df["pred_birth_year"] = self._df[data_of_birth_col_name].dt.year
-
-    def add_static_info(
+    def _add_static_info(
         self,
         static_spec: AnySpec,
     ):
@@ -482,6 +563,15 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
             validate="m:1",
         )
 
+    def _process_static_specs(self):
+        """Process static specs."""
+        for spec in self.unprocessed_specs.static_specs:
+            self._add_static_info(
+                static_spec=spec,
+            )
+
+            self.unprocessed_specs.static_specs.remove(spec)
+
     def _add_incident_outcome(
         self,
         outcome_spec: OutcomeSpec,
@@ -526,196 +616,189 @@ class TimeseriesFlattener:  # pylint: disable=too-many-instance-attributes
 
         self._df = df
 
-    def _add_temporal_col_to_flattened_dataset(
-        self,
-        output_spec: AnySpec,
-    ):
-        """Add a column to the dataset.
+    @print_df_dimensions_diff
+    def _drop_pred_time_if_insufficient_look_distance(self, df: pd.DataFrame):
+        """Drop prediction times if there is insufficient look distance.
 
-        Either predictor or outcome depending on the type of specification.
+        A prediction time has insufficient look distance if the feature spec
+            tries to look beyond the boundary of the data.
+        For example, if a predictor specifies two years of lookbehind,
+            but you only have one year of data prior to the prediction time.
 
-        Args:
-            output_spec (Union[OutcomeSpec, PredictorSpec]): Specification of the output column.
+        Takes a dataframe as input to conform to a standard filtering interface,
+            which we can easily decorate.
         """
-        timestamp_col_type = output_spec.values_df[self.timestamp_col_name].dtype  # type: ignore
+        spec_batch = (
+            self.unprocessed_specs.outcome_specs
+            + self.unprocessed_specs.predictor_specs
+        )
 
-        if timestamp_col_type not in ("Timestamp", "datetime64[ns]"):
-            # Convert dtype to timestamp
-            raise ValueError(
-                f"{self.timestamp_col_name} is of type {timestamp_col_type}, not 'Timestamp' from Pandas. Will cause problems. Convert before initialising FlattenedDataset.",
-            )
+        # Find the latest cutoff date for predictors
+        cutoff_date_behind = pd.Timestamp("1700-01-01")
 
-        df = TimeseriesFlattener._flatten_temporal_values_to_df(
-            prediction_times_with_uuid_df=self._df[
-                [
-                    self.id_col_name,
-                    self.timestamp_col_name,
-                    self.pred_time_uuid_col_name,
-                ]
-            ],
-            output_spec=output_spec,
-            id_col_name=self.id_col_name,
-            timestamp_col_name=self.timestamp_col_name,
-            pred_time_uuid_col_name=self.pred_time_uuid_col_name,
-        )
+        # Find the earliest cutoff date for outcomes
+        cutoff_date_ahead = pd.Timestamp("2200-01-01")
 
-        self._df = self._df.merge(
-            right=df,
-            on=self.pred_time_uuid_col_name,
-            validate="1:1",
-        )
+        for spec in spec_batch:
+            spec_cutoff_date = spec.get_cutoff_date()
 
-    def add_temporal_outcome(
-        self,
-        output_spec: OutcomeSpec,
-    ):
-        """Add an outcome-column to the dataset.
+            if isinstance(spec, OutcomeSpec):
+                cutoff_date_ahead = min(cutoff_date_ahead, spec_cutoff_date)
+            elif isinstance(spec, PredictorSpec):
+                cutoff_date_behind = max(cutoff_date_behind, spec_cutoff_date)
 
-        Args:
-            output_spec (OutcomeSpec): OutcomeSpec object.
-        """
+        # Drop all prediction times that are outside the cutoffs window
+        output_df = df[
+            (df[self.timestamp_col_name] >= cutoff_date_behind)
+            & (df[self.timestamp_col_name] <= cutoff_date_ahead)
+        ]
 
-        if output_spec.incident:
-            self._add_incident_outcome(
-                outcome_spec=output_spec,
+        if output_df.shape[0] == 0:
+            raise ValueError(
+                "No records left after dropping records outside look distance",
             )
 
-        else:
-            self._add_temporal_col_to_flattened_dataset(
-                output_spec=output_spec,
-            )
+        return output_df
 
-    def add_temporal_predictor(
-        self,
-        output_spec: PredictorSpec,
-    ):
-        """Add a column with predictor values to the flattened dataset.
+    def _process_temporal_specs(self):
+        """Process outcome specs."""
 
-        Args:
-            output_spec (Union[PredictorSpec]): Specification of the output column.
-        """
-        self._add_temporal_col_to_flattened_dataset(
-            output_spec=output_spec,
-        )
+        for spec in self.unprocessed_specs.outcome_specs:
+            # Handle incident specs separately, since their operations can be vectorised,
+            # making them much faster
+            if spec.incident:
+                self._add_incident_outcome(
+                    outcome_spec=spec,
+                )
 
-    @staticmethod
-    def _add_back_prediction_times_without_value(
-        df: DataFrame,
-        pred_times_with_uuid: DataFrame,
-        pred_time_uuid_colname: str,
-    ) -> DataFrame:
-        """Ensure all prediction times are represented in the returned
+                self.unprocessed_specs.outcome_specs.remove(spec)
 
-        dataframe.
+        temporal_batch = self.unprocessed_specs.outcome_specs
+        temporal_batch += self.unprocessed_specs.predictor_specs
 
-        Args:
-            df (DataFrame): Dataframe with prediction times but without uuid.
-            pred_times_with_uuid (DataFrame): Dataframe with prediction times and uuid.
-            pred_time_uuid_colname (str): Name of uuid column in both df and pred_times_with_uuid.
+        if self.drop_pred_times_with_insufficient_look_distance:
+            self._df = self._drop_pred_time_if_insufficient_look_distance(df=self._df)
 
-        Returns:
-            DataFrame: A merged dataframe with all prediction times.
-        """
-        return pd.merge(
-            pred_times_with_uuid,
-            df,
-            how="left",
-            on=pred_time_uuid_colname,
-            suffixes=("", "_temp"),
-        ).drop(["timestamp_pred"], axis=1)
+        if len(temporal_batch) > 0:
+            self._add_temporal_batch(temporal_batch=temporal_batch)
 
-    @staticmethod
-    def _resolve_multiple_values_within_interval_days(
-        resolve_multiple: Callable,
-        df: DataFrame,
-        pred_time_uuid_colname: str,
-    ) -> DataFrame:
-        """Apply the resolve_multiple function to prediction_times where there
+    def _check_that_spec_df_has_required_columns(self, spec: AnySpec):
+        """Check that df has required columns."""
+        # Find all attributes in self that contain col_name
+        required_columns = [self.id_col_name]
 
-        are multiple values within the interval_days lookahead.
+        if not isinstance(spec, StaticSpec):
+            required_columns += [self.timestamp_col_name]
 
-        Args:
-            resolve_multiple (Callable): Takes a grouped df and collapses each group to one record (e.g. sum, count etc.).
-            df (DataFrame): Source dataframe with all prediction time x val combinations.
-            pred_time_uuid_colname (str): Name of uuid column in df.
+        for col in required_columns:
+            if col not in spec.values_df.columns:  # type: ignore
+                raise ValueError(f"Missing required column: {col}")
 
-        Returns:
-            DataFrame: DataFrame with one row pr. prediction time.
+    def add_spec(
+        self,
+        spec: Union[list[AnySpec], AnySpec],
+    ):
+        """Add a specification to the flattened dataset.
+
+        This adds it to a queue of unprocessed specs, which are not processed
+        until you call the .compute() or .get_df() methods. This allows us to
+        more effectiely parallelise the processing of the specs.
+
+        Most of the complexity lies in the OutcomeSpec and PredictorSpec objects.
+        For further documentation, see those objects and the tutorial.
         """
-        # Convert timestamp val to numeric that can be used for resolve_multiple functions
-        # Numeric value amounts to days passed since 1/1/1970
-        try:
-            df["timestamp_val"] = (
-                df["timestamp_val"] - dt.datetime(1970, 1, 1)
-            ).dt.total_seconds() / 86400
-        except TypeError:
-            log.info("All values are NaT, returning empty dataframe")
+        if isinstance(spec, AnySpec):
+            specs_to_process: list[AnySpec] = [spec]
+        else:
+            specs_to_process = spec
 
-        # Sort by timestamp_pred in case resolve_multiple needs dates
-        df = df.sort_values(by="timestamp_val").groupby(pred_time_uuid_colname)
+        for spec_i in specs_to_process:
+            allowed_spec_types = (OutcomeSpec, PredictorSpec, StaticSpec)
 
-        if isinstance(resolve_multiple, str):
-            resolve_multiple = resolve_multiple_fns.get(resolve_multiple)
+            if not isinstance(spec_i, allowed_spec_types):
+                raise ValueError(
+                    f"Input is not allowed. Must be one of: {allowed_spec_types}",
+                )
 
-        if callable(resolve_multiple):
-            df = resolve_multiple(df).reset_index()
-        else:
-            raise ValueError("resolve_multiple must be or resolve to a Callable")
+            self._check_that_spec_df_has_required_columns(spec=spec_i)
 
-        return df
+            if isinstance(spec_i, OutcomeSpec):
+                self.unprocessed_specs.outcome_specs.append(spec_i)
+            elif isinstance(spec_i, PredictorSpec):
+                self.unprocessed_specs.predictor_specs.append(spec_i)
+            elif isinstance(spec_i, StaticSpec):
+                self.unprocessed_specs.static_specs.append(spec_i)
 
-    @staticmethod
-    def _drop_records_outside_interval_days(
-        df: DataFrame,
-        direction: str,
-        interval_days: float,
-        timestamp_pred_colname: str,
-        timestamp_value_colname: str,
-    ) -> DataFrame:
-        """Keep only rows where timestamp_value is within interval_days in
+    def add_age(
+        self,
+        date_of_birth_df: DataFrame,
+        date_of_birth_col_name: Optional[str] = "date_of_birth",
+        output_prefix: str = "pred",
+    ):
+        """Add age at prediction time as predictor.
 
-        direction of timestamp_pred.
+        Has its own function because of its very frequent use.
 
         Args:
-            direction (str): Whether to look ahead or behind.
-            interval_days (float): How far to look
-            df (DataFrame): Source dataframe
-            timestamp_pred_colname (str): Name of timestamp column for predictions in df.
-            timestamp_value_colname (str): Name of timestamp column for values in df.
+            date_of_birth_df (DataFrame): Two columns, one matching self.id_col_name and one containing date_of_birth.
+            date_of_birth_col_name (str, optional): Name of the date_of_birth column in date_of_birth_df.
+                Defaults to "date_of_birth".
+            output_prefix (str, optional): Prefix for the output column. Defaults to "pred".
+        """
+        if date_of_birth_df[date_of_birth_col_name].dtype != "<M8[ns]":
+            try:
+                date_of_birth_df[date_of_birth_col_name] = pd.to_datetime(
+                    date_of_birth_df[date_of_birth_col_name],
+                    format="%Y-%m-%d",
+                )
+            except ValueError as e:
+                raise ValueError(
+                    f"Conversion of {date_of_birth_col_name} to datetime failed, doesn't match format %Y-%m-%d. Recommend converting to datetime before adding.",
+                ) from e
 
-        Raises:
-            ValueError: If direction is niether ahead nor behind.
+        output_age_col_name = f"{output_prefix}_age_in_years"
 
-        Returns:
-            DataFrame
-        """
-        df["time_from_pred_to_val_in_days"] = (
-            (df[timestamp_value_colname] - df[timestamp_pred_colname])
-            / (np.timedelta64(1, "s"))
-            / 86_400
+        self._add_static_info(
+            static_spec=AnySpec(
+                values_df=date_of_birth_df,
+                input_col_name_override=date_of_birth_col_name,
+                prefix="temp",
+                # We typically don't want to use date of birth as a predictor,
+                # but might want to use transformations - e.g. "year of birth" or "age at prediction time".
+                feature_name=date_of_birth_col_name,
+            ),
         )
-        # Divide by 86.400 seconds/day
 
-        if direction == "ahead":
-            df["is_in_interval"] = (
-                df["time_from_pred_to_val_in_days"] <= interval_days
-            ) & (df["time_from_pred_to_val_in_days"] > 0)
-        elif direction == "behind":
-            df["is_in_interval"] = (
-                df["time_from_pred_to_val_in_days"] >= -interval_days
-            ) & (df["time_from_pred_to_val_in_days"] < 0)
-        else:
-            raise ValueError("direction can only be 'ahead' or 'behind'")
+        tmp_date_of_birth_col_name = f"temp_{date_of_birth_col_name}"
 
-        return df[df["is_in_interval"]].drop(
-            ["is_in_interval", "time_from_pred_to_val_in_days"],
-            axis=1,
-        )
+        self._df[output_age_col_name] = (
+            (
+                self._df[self.timestamp_col_name] - self._df[tmp_date_of_birth_col_name]
+            ).dt.days
+            / (365.25)
+        ).round(2)
+
+        # Remove date of birth column
+        self._df.drop(columns=tmp_date_of_birth_col_name, inplace=True)
+
+    def compute(self):
+        """Compute the flattened dataset."""
+        if len(self.unprocessed_specs) == 0:
+            log.warning("No unprocessed specs, skipping")
+            return
+
+        self._process_temporal_specs()
+        self._process_static_specs()
 
     def get_df(self) -> DataFrame:
-        """Get the flattened dataframe.
+        """Get the flattened dataframe. Computes if any unprocessed specs are present.
 
         Returns:
             DataFrame: Flattened dataframe.
         """
+        if len(self.unprocessed_specs) > 0:
+            log.info("There were unprocessed specs, computing...")
+            self.compute()
+
+        # Process
         return self._df
diff --git a/src/timeseriesflattener/utils.py b/src/timeseriesflattener/utils.py
index ed98fdf..a602b18 100644
--- a/src/timeseriesflattener/utils.py
+++ b/src/timeseriesflattener/utils.py
@@ -3,8 +3,10 @@
 utilities. If this file grows, consider splitting it up.
 """
 
+import functools
+import logging
 import os
-from collections.abc import Hashable
+from collections.abc import Callable, Hashable
 from pathlib import Path
 from typing import Any, Optional
 
@@ -18,6 +20,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
 
 def format_dict_for_printing(d: dict) -> str:
     """Format a dictionary for printing. Removes extra apostrophes, formats
+
     colon to dashes, separates items with underscores and removes curly
     brackets.
 
@@ -155,3 +158,57 @@ def assert_no_duplicate_dicts_in_list(predictor_spec_list: list[dict[str, Any]])
 
     if len(duplicates) > 0:
         raise ValueError(f"Found duplicates in list of dicts: {duplicates}")
+
+
+def print_df_dimensions_diff(
+    func: Callable,
+    print_when_starting: bool = False,
+    print_when_no_diff=False,
+):
+    """Print the difference in rows between the input and output dataframes."""
+
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        log = logging.getLogger(__name__)
+
+        if print_when_starting:
+            log.info(f"{func.__name__}: Starting")
+
+        arg_dfs = [arg for arg in args if isinstance(arg, pd.DataFrame)]
+        kwargs_dfs = [arg for arg in kwargs.values() if isinstance(arg, pd.DataFrame)]
+        potential_dfs = arg_dfs + kwargs_dfs
+
+        if len(potential_dfs) > 1:
+            raise ValueError("More than one DataFrame found in args or kwargs")
+
+        df = potential_dfs[0]
+
+        for dim in ("rows", "columns"):
+            dim_int = 0 if dim == "rows" else 1
+
+            n_in_dim_before_func = df.shape[dim_int]
+
+            if print_when_no_diff:
+                log.info(
+                    f"{func.__name__}: {n_in_dim_before_func} {dim} before function",
+                )
+
+            result = func(*args, **kwargs)
+
+            diff = n_in_dim_before_func - result.shape[dim_int]
+
+            if diff != 0:
+                percent_diff = round(
+                    (n_in_dim_before_func - result.shape[dim_int])
+                    / n_in_dim_before_func,
+                    2,
+                )
+
+                log.info(f"{func.__name__}: Dropped {diff} ({percent_diff}%) {dim}")
+            else:
+                if print_when_no_diff:
+                    log.info(f"{func.__name__}: No {dim} dropped")
+
+        return result
+
+    return wrapper
diff --git a/tutorials/01_basic.ipynb b/tutorials/01_basic.ipynb
index 83f0e71..07bcb84 100644
--- a/tutorials/01_basic.ipynb
+++ b/tutorials/01_basic.ipynb
@@ -887,11 +887,128 @@
    "cell_type": "code",
    "execution_count": 7,
    "metadata": {},
-   "outputs": [],
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>dw_ek_borger</th>\n",
+       "      <th>female</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>0</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>1</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>2</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>3</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>4</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>...</th>\n",
+       "      <td>...</td>\n",
+       "      <td>...</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>9994</th>\n",
+       "      <td>9995</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>9995</th>\n",
+       "      <td>9996</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>9996</th>\n",
+       "      <td>9997</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>9997</th>\n",
+       "      <td>9998</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>9998</th>\n",
+       "      <td>9999</td>\n",
+       "      <td>0</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "<p>9999 rows × 2 columns</p>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "      dw_ek_borger  female\n",
+       "0                0       0\n",
+       "1                1       1\n",
+       "2                2       1\n",
+       "3                3       1\n",
+       "4                4       0\n",
+       "...            ...     ...\n",
+       "9994          9995       0\n",
+       "9995          9996       0\n",
+       "9996          9997       1\n",
+       "9997          9998       1\n",
+       "9998          9999       0\n",
+       "\n",
+       "[9999 rows x 2 columns]"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
    "source": [
     "sex_predictor_spec = StaticSpec(\n",
-    "    values_df=df_synth_sex, feature_name=\"female\", prefix=\"pred\"\n",
-    ")"
+    "   values_df=df_synth_sex, feature_name=\"female\", prefix=\"pred\", input_col_name_override=\"female\"\n",
+    ")\n",
+    "\n",
+    "df_synth_sex"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note that we also specify the \"input_col_name_override\", because the df_synth_sex df has its values in the \"female\" column. By default, tsflattener looks for a column names \"value\"."
    ]
   },
   {
@@ -910,9 +1027,21 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 8,
+   "execution_count": 9,
    "metadata": {},
    "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2022-12-07 13:52:04 [INFO] There were unprocessed specs, computing...\n",
+      "100%|██████████| 2/2 [00:00<00:00,  2.37it/s]\n",
+      "2022-12-07 13:52:05 [INFO] Processing complete, concatenating\n",
+      "2022-12-07 13:52:05 [INFO] Starting concatenation. Will take some time on performant systems, e.g. 30s for 100 features. This is normal.\n",
+      "2022-12-07 13:52:05 [INFO] Concatenation took 0.002 seconds\n",
+      "2022-12-07 13:52:05 [INFO] Merging with original df\n"
+     ]
+    },
     {
      "data": {
       "text/html": [
@@ -921,31 +1050,31 @@
        "│ ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓ ┏━━━━━━━━━━━━━┳━━━━━━━┓                                                          │\n",
        "│ ┃<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\"> dataframe         </span>┃<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\"> Values </span>┃ ┃<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\"> Column Type </span>┃<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\"> Count </span>┃                                                          │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩ ┡━━━━━━━━━━━━━╇━━━━━━━┩                                                          │\n",
-       "│ │ Number of rows    │ 10000  │ │ int64       │ 2     │                                                          │\n",
+       "│ │ Number of rows    │ 4001   │ │ int64       │ 2     │                                                          │\n",
        "│ │ Number of columns │ 6      │ │ float64     │ 2     │                                                          │\n",
        "│ └───────────────────┴────────┘ │ datetime64  │ 1     │                                                          │\n",
        "│                                │ string      │ 1     │                                                          │\n",
        "│                                └─────────────┴───────┘                                                          │\n",
        "│ <span style=\"font-style: italic\">                                                    number                                                    </span>  │\n",
-       "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━┓  │\n",
-       "│ ┃<span style=\"font-weight: bold\"> column_name               </span>┃<span style=\"font-weight: bold\"> NA     </span>┃<span style=\"font-weight: bold\"> NA %   </span>┃<span style=\"font-weight: bold\"> mean    </span>┃<span style=\"font-weight: bold\"> sd     </span>┃<span style=\"font-weight: bold\"> p0      </span>┃<span style=\"font-weight: bold\"> p25   </span>┃<span style=\"font-weight: bold\"> p75   </span>┃<span style=\"font-weight: bold\"> p100   </span>┃<span style=\"font-weight: bold\"> hist   </span>┃  │\n",
-       "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━┩  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">dw_ek_borger             </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   5000</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  2900</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 2500</span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 7400</span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 10000</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">█████▇</span> │  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">pred_female              </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0.49</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0.5</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    1</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     1</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">█    █</span> │  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">pred_predictor_name_     </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  1100</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    11</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      5</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   1.8</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.015</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  3.9</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  6.2</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   9.9</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">▁▃██▃▁</span> │  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">outc_outcome_name_wi     </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.056</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.23</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     1</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">  █   </span> │  │\n",
-       "│ └───────────────────────────┴────────┴────────┴─────────┴────────┴─────────┴───────┴───────┴────────┴────────┘  │\n",
+       "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓  │\n",
+       "│ ┃<span style=\"font-weight: bold\"> column_name                </span>┃<span style=\"font-weight: bold\"> NA  </span>┃<span style=\"font-weight: bold\"> NA %   </span>┃<span style=\"font-weight: bold\"> mean    </span>┃<span style=\"font-weight: bold\"> sd     </span>┃<span style=\"font-weight: bold\"> p0      </span>┃<span style=\"font-weight: bold\"> p25    </span>┃<span style=\"font-weight: bold\"> p75   </span>┃<span style=\"font-weight: bold\"> p100   </span>┃<span style=\"font-weight: bold\"> hist    </span>┃  │\n",
+       "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">dw_ek_borger              </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   5000</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  2900</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      3</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  2600</span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 7500</span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 10000</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">█████▇ </span> │  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">outc_outcome_name_wi      </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.064</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.25</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     1</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">█    ▁ </span> │  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">pred_predictor_name_      </span> │ <span style=\"color: #008080; text-decoration-color: #008080\"> 72</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   1.8</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      5</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   1.6</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0.097</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   3.9</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    6</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   9.9</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">▁▃██▃▁ </span> │  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">pred_female               </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">  0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0.49</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0.5</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    1</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">     1</span> │ <span style=\"color: #008000; text-decoration-color: #008000\">█    █ </span> │  │\n",
+       "│ └────────────────────────────┴─────┴────────┴─────────┴────────┴─────────┴────────┴───────┴────────┴─────────┘  │\n",
        "│ <span style=\"font-style: italic\">                                                   datetime                                                   </span>  │\n",
        "│ ┏━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓  │\n",
        "│ ┃<span style=\"font-weight: bold\"> column_name      </span>┃<span style=\"font-weight: bold\"> NA   </span>┃<span style=\"font-weight: bold\"> NA %    </span>┃<span style=\"font-weight: bold\"> first                      </span>┃<span style=\"font-weight: bold\"> last                       </span>┃<span style=\"font-weight: bold\"> frequency    </span>┃  │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">timestamp       </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #800000; text-decoration-color: #800000\">   1965-01-02 09:35:00    </span> │ <span style=\"color: #800000; text-decoration-color: #800000\">   1969-12-31 21:42:00    </span> │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">None        </span> │  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">timestamp       </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">   0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">      0</span> │ <span style=\"color: #800000; text-decoration-color: #800000\">   1967-01-02 01:16:00    </span> │ <span style=\"color: #800000; text-decoration-color: #800000\">   1968-12-31 04:39:00    </span> │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">None        </span> │  │\n",
        "│ └──────────────────┴──────┴─────────┴────────────────────────────┴────────────────────────────┴──────────────┘  │\n",
        "│ <span style=\"font-style: italic\">                                                    string                                                    </span>  │\n",
        "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓  │\n",
        "│ ┃<span style=\"font-weight: bold\"> column_name                           </span>┃<span style=\"font-weight: bold\"> NA    </span>┃<span style=\"font-weight: bold\"> NA %      </span>┃<span style=\"font-weight: bold\"> words per row            </span>┃<span style=\"font-weight: bold\"> total words         </span>┃  │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩  │\n",
-       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">prediction_time_uuid                 </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">        0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">                       1</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">              10000</span> │  │\n",
+       "│ │ <span style=\"color: #af87ff; text-decoration-color: #af87ff\">prediction_time_uuid                 </span> │ <span style=\"color: #008080; text-decoration-color: #008080\">    0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">        0</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">                       1</span> │ <span style=\"color: #008080; text-decoration-color: #008080\">               4000</span> │  │\n",
        "│ └───────────────────────────────────────┴───────┴───────────┴──────────────────────────┴─────────────────────┘  │\n",
        "╰────────────────────────────────────────────────────── End ──────────────────────────────────────────────────────╯\n",
        "</pre>\n"
@@ -956,31 +1085,31 @@
        "│ ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓ ┏━━━━━━━━━━━━━┳━━━━━━━┓                                                          │\n",
        "│ ┃\u001b[1;36m \u001b[0m\u001b[1;36mdataframe        \u001b[0m\u001b[1;36m \u001b[0m┃\u001b[1;36m \u001b[0m\u001b[1;36mValues\u001b[0m\u001b[1;36m \u001b[0m┃ ┃\u001b[1;36m \u001b[0m\u001b[1;36mColumn Type\u001b[0m\u001b[1;36m \u001b[0m┃\u001b[1;36m \u001b[0m\u001b[1;36mCount\u001b[0m\u001b[1;36m \u001b[0m┃                                                          │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩ ┡━━━━━━━━━━━━━╇━━━━━━━┩                                                          │\n",
-       "│ │ Number of rows    │ 10000  │ │ int64       │ 2     │                                                          │\n",
+       "│ │ Number of rows    │ 4001   │ │ int64       │ 2     │                                                          │\n",
        "│ │ Number of columns │ 6      │ │ float64     │ 2     │                                                          │\n",
        "│ └───────────────────┴────────┘ │ datetime64  │ 1     │                                                          │\n",
        "│                                │ string      │ 1     │                                                          │\n",
        "│                                └─────────────┴───────┘                                                          │\n",
        "│ \u001b[3m                                                    number                                                    \u001b[0m  │\n",
-       "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━┓  │\n",
-       "│ ┃\u001b[1m \u001b[0m\u001b[1mcolumn_name              \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA    \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA %  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mmean   \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1msd    \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp0     \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp25  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp75  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp100  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mhist  \u001b[0m\u001b[1m \u001b[0m┃  │\n",
-       "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━┩  │\n",
-       "│ │ \u001b[38;5;141mdw_ek_borger             \u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m   5000\u001b[0m │ \u001b[36m  2900\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[36m 2500\u001b[0m │ \u001b[36m 7400\u001b[0m │ \u001b[36m 10000\u001b[0m │ \u001b[32m█████▇\u001b[0m │  │\n",
-       "│ │ \u001b[38;5;141mpred_female              \u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m   0.49\u001b[0m │ \u001b[36m   0.5\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m    1\u001b[0m │ \u001b[36m     1\u001b[0m │ \u001b[32m█    █\u001b[0m │  │\n",
-       "│ │ \u001b[38;5;141mpred_predictor_name_     \u001b[0m │ \u001b[36m  1100\u001b[0m │ \u001b[36m    11\u001b[0m │ \u001b[36m      5\u001b[0m │ \u001b[36m   1.8\u001b[0m │ \u001b[36m  0.015\u001b[0m │ \u001b[36m  3.9\u001b[0m │ \u001b[36m  6.2\u001b[0m │ \u001b[36m   9.9\u001b[0m │ \u001b[32m▁▃██▃▁\u001b[0m │  │\n",
-       "│ │ \u001b[38;5;141moutc_outcome_name_wi     \u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m  0.056\u001b[0m │ \u001b[36m  0.23\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m     1\u001b[0m │ \u001b[32m  █   \u001b[0m │  │\n",
-       "│ └───────────────────────────┴────────┴────────┴─────────┴────────┴─────────┴───────┴───────┴────────┴────────┘  │\n",
+       "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓  │\n",
+       "│ ┃\u001b[1m \u001b[0m\u001b[1mcolumn_name               \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA %  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mmean   \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1msd    \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp0     \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp25   \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp75  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mp100  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mhist   \u001b[0m\u001b[1m \u001b[0m┃  │\n",
+       "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩  │\n",
+       "│ │ \u001b[38;5;141mdw_ek_borger              \u001b[0m │ \u001b[36m  0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m   5000\u001b[0m │ \u001b[36m  2900\u001b[0m │ \u001b[36m      3\u001b[0m │ \u001b[36m  2600\u001b[0m │ \u001b[36m 7500\u001b[0m │ \u001b[36m 10000\u001b[0m │ \u001b[32m█████▇ \u001b[0m │  │\n",
+       "│ │ \u001b[38;5;141moutc_outcome_name_wi      \u001b[0m │ \u001b[36m  0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m  0.064\u001b[0m │ \u001b[36m  0.25\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m     1\u001b[0m │ \u001b[32m█    ▁ \u001b[0m │  │\n",
+       "│ │ \u001b[38;5;141mpred_predictor_name_      \u001b[0m │ \u001b[36m 72\u001b[0m │ \u001b[36m   1.8\u001b[0m │ \u001b[36m      5\u001b[0m │ \u001b[36m   1.6\u001b[0m │ \u001b[36m  0.097\u001b[0m │ \u001b[36m   3.9\u001b[0m │ \u001b[36m    6\u001b[0m │ \u001b[36m   9.9\u001b[0m │ \u001b[32m▁▃██▃▁ \u001b[0m │  │\n",
+       "│ │ \u001b[38;5;141mpred_female               \u001b[0m │ \u001b[36m  0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m   0.49\u001b[0m │ \u001b[36m   0.5\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[36m     0\u001b[0m │ \u001b[36m    1\u001b[0m │ \u001b[36m     1\u001b[0m │ \u001b[32m█    █ \u001b[0m │  │\n",
+       "│ └────────────────────────────┴─────┴────────┴─────────┴────────┴─────────┴────────┴───────┴────────┴─────────┘  │\n",
        "│ \u001b[3m                                                   datetime                                                   \u001b[0m  │\n",
        "│ ┏━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓  │\n",
        "│ ┃\u001b[1m \u001b[0m\u001b[1mcolumn_name     \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA  \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA %   \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mfirst                     \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mlast                      \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mfrequency   \u001b[0m\u001b[1m \u001b[0m┃  │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩  │\n",
-       "│ │ \u001b[38;5;141mtimestamp       \u001b[0m │ \u001b[36m   0\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[31m   1965-01-02 09:35:00    \u001b[0m │ \u001b[31m   1969-12-31 21:42:00    \u001b[0m │ \u001b[38;5;141mNone        \u001b[0m │  │\n",
+       "│ │ \u001b[38;5;141mtimestamp       \u001b[0m │ \u001b[36m   0\u001b[0m │ \u001b[36m      0\u001b[0m │ \u001b[31m   1967-01-02 01:16:00    \u001b[0m │ \u001b[31m   1968-12-31 04:39:00    \u001b[0m │ \u001b[38;5;141mNone        \u001b[0m │  │\n",
        "│ └──────────────────┴──────┴─────────┴────────────────────────────┴────────────────────────────┴──────────────┘  │\n",
        "│ \u001b[3m                                                    string                                                    \u001b[0m  │\n",
        "│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓  │\n",
        "│ ┃\u001b[1m \u001b[0m\u001b[1mcolumn_name                          \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA   \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mNA %     \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mwords per row           \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mtotal words        \u001b[0m\u001b[1m \u001b[0m┃  │\n",
        "│ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩  │\n",
-       "│ │ \u001b[38;5;141mprediction_time_uuid                 \u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m        0\u001b[0m │ \u001b[36m                       1\u001b[0m │ \u001b[36m              10000\u001b[0m │  │\n",
+       "│ │ \u001b[38;5;141mprediction_time_uuid                 \u001b[0m │ \u001b[36m    0\u001b[0m │ \u001b[36m        0\u001b[0m │ \u001b[36m                       1\u001b[0m │ \u001b[36m               4000\u001b[0m │  │\n",
        "│ └───────────────────────────────────────┴───────┴───────────┴──────────────────────────┴─────────────────────┘  │\n",
        "╰────────────────────────────────────────────────────── End ──────────────────────────────────────────────────────╯\n"
       ]
@@ -1012,9 +1141,9 @@
        "      <th>dw_ek_borger</th>\n",
        "      <th>timestamp</th>\n",
        "      <th>prediction_time_uuid</th>\n",
-       "      <th>pred_female</th>\n",
-       "      <th>pred_predictor_name_within_730_days_None_fallback_nan</th>\n",
        "      <th>outc_outcome_name_within_365_days_None_fallback_0_dichotomous</th>\n",
+       "      <th>pred_predictor_name_within_730_days_None_fallback_nan</th>\n",
+       "      <th>pred_female</th>\n",
        "    </tr>\n",
        "  </thead>\n",
        "  <tbody>\n",
@@ -1023,45 +1152,45 @@
        "      <td>9903</td>\n",
        "      <td>1968-05-09 21:24:00</td>\n",
        "      <td>9903-1968-05-09-21-24-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>0.990763</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>0.990763</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>1</th>\n",
-       "      <td>7465</td>\n",
-       "      <td>1966-05-24 01:23:00</td>\n",
-       "      <td>7465-1966-05-24-01-23-00</td>\n",
-       "      <td>1</td>\n",
-       "      <td>0.819872</td>\n",
-       "      <td>0.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
        "      <td>6447</td>\n",
        "      <td>1967-09-25 18:08:00</td>\n",
        "      <td>6447-1967-09-25-18-08-00</td>\n",
-       "      <td>1</td>\n",
+       "      <td>0.0</td>\n",
        "      <td>5.582745</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>4927</td>\n",
+       "      <td>1968-06-30 12:13:00</td>\n",
+       "      <td>4927-1968-06-30-12-13-00</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>4.957251</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>3</th>\n",
-       "      <td>2121</td>\n",
-       "      <td>1966-05-05 20:52:00</td>\n",
-       "      <td>2121-1966-05-05-20-52-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>7.627190</td>\n",
+       "      <td>5475</td>\n",
+       "      <td>1967-01-09 03:09:00</td>\n",
+       "      <td>5475-1967-01-09-03-09-00</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>5.999336</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
-       "      <td>4927</td>\n",
-       "      <td>1968-06-30 12:13:00</td>\n",
-       "      <td>4927-1968-06-30-12-13-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>4.957251</td>\n",
+       "      <td>9793</td>\n",
+       "      <td>1968-12-15 12:59:00</td>\n",
+       "      <td>9793-1968-12-15-12-59-00</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>7.294038</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>...</th>\n",
@@ -1073,99 +1202,99 @@
        "      <td>...</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>9995</th>\n",
-       "      <td>7159</td>\n",
-       "      <td>1966-12-12 16:32:00</td>\n",
-       "      <td>7159-1966-12-12-16-32-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>7.060570</td>\n",
+       "      <th>3996</th>\n",
+       "      <td>6542</td>\n",
+       "      <td>1967-04-15 14:37:00</td>\n",
+       "      <td>6542-1967-04-15-14-37-00</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>7.137424</td>\n",
+       "      <td>1</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>9996</th>\n",
-       "      <td>147</td>\n",
-       "      <td>1965-03-12 05:32:00</td>\n",
-       "      <td>147-1965-03-12-05-32-00</td>\n",
-       "      <td>1</td>\n",
-       "      <td>NaN</td>\n",
+       "      <th>3997</th>\n",
+       "      <td>4228</td>\n",
+       "      <td>1967-02-26 05:45:00</td>\n",
+       "      <td>4228-1967-02-26-05-45-00</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>3.792014</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>9997</th>\n",
+       "      <th>3998</th>\n",
+       "      <td>3385</td>\n",
+       "      <td>1967-07-17 19:18:00</td>\n",
+       "      <td>3385-1967-07-17-19-18-00</td>\n",
+       "      <td>0.0</td>\n",
+       "      <td>5.769484</td>\n",
+       "      <td>1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3999</th>\n",
        "      <td>1421</td>\n",
        "      <td>1968-04-15 15:53:00</td>\n",
        "      <td>1421-1968-04-15-15-53-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>7.732447</td>\n",
        "      <td>0.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>9998</th>\n",
-       "      <td>3353</td>\n",
-       "      <td>1966-01-15 10:04:00</td>\n",
-       "      <td>3353-1966-01-15-10-04-00</td>\n",
+       "      <td>7.732447</td>\n",
        "      <td>0</td>\n",
-       "      <td>7.658063</td>\n",
-       "      <td>0.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>9999</th>\n",
+       "      <th>4000</th>\n",
        "      <td>1940</td>\n",
        "      <td>1968-05-17 10:49:00</td>\n",
        "      <td>1940-1968-05-17-10-49-00</td>\n",
-       "      <td>0</td>\n",
-       "      <td>4.846514</td>\n",
        "      <td>0.0</td>\n",
+       "      <td>4.846514</td>\n",
+       "      <td>0</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
-       "<p>10000 rows × 6 columns</p>\n",
+       "<p>4001 rows × 6 columns</p>\n",
        "</div>"
       ],
       "text/plain": [
-       "      dw_ek_borger           timestamp      prediction_time_uuid  pred_female  \\\n",
-       "0             9903 1968-05-09 21:24:00  9903-1968-05-09-21-24-00            0   \n",
-       "1             7465 1966-05-24 01:23:00  7465-1966-05-24-01-23-00            1   \n",
-       "2             6447 1967-09-25 18:08:00  6447-1967-09-25-18-08-00            1   \n",
-       "3             2121 1966-05-05 20:52:00  2121-1966-05-05-20-52-00            0   \n",
-       "4             4927 1968-06-30 12:13:00  4927-1968-06-30-12-13-00            0   \n",
-       "...            ...                 ...                       ...          ...   \n",
-       "9995          7159 1966-12-12 16:32:00  7159-1966-12-12-16-32-00            0   \n",
-       "9996           147 1965-03-12 05:32:00   147-1965-03-12-05-32-00            1   \n",
-       "9997          1421 1968-04-15 15:53:00  1421-1968-04-15-15-53-00            0   \n",
-       "9998          3353 1966-01-15 10:04:00  3353-1966-01-15-10-04-00            0   \n",
-       "9999          1940 1968-05-17 10:49:00  1940-1968-05-17-10-49-00            0   \n",
+       "      dw_ek_borger           timestamp      prediction_time_uuid  \\\n",
+       "0             9903 1968-05-09 21:24:00  9903-1968-05-09-21-24-00   \n",
+       "1             6447 1967-09-25 18:08:00  6447-1967-09-25-18-08-00   \n",
+       "2             4927 1968-06-30 12:13:00  4927-1968-06-30-12-13-00   \n",
+       "3             5475 1967-01-09 03:09:00  5475-1967-01-09-03-09-00   \n",
+       "4             9793 1968-12-15 12:59:00  9793-1968-12-15-12-59-00   \n",
+       "...            ...                 ...                       ...   \n",
+       "3996          6542 1967-04-15 14:37:00  6542-1967-04-15-14-37-00   \n",
+       "3997          4228 1967-02-26 05:45:00  4228-1967-02-26-05-45-00   \n",
+       "3998          3385 1967-07-17 19:18:00  3385-1967-07-17-19-18-00   \n",
+       "3999          1421 1968-04-15 15:53:00  1421-1968-04-15-15-53-00   \n",
+       "4000          1940 1968-05-17 10:49:00  1940-1968-05-17-10-49-00   \n",
        "\n",
-       "      pred_predictor_name_within_730_days_None_fallback_nan  \\\n",
-       "0                                              0.990763       \n",
-       "1                                              0.819872       \n",
-       "2                                              5.582745       \n",
-       "3                                              7.627190       \n",
-       "4                                              4.957251       \n",
-       "...                                                 ...       \n",
-       "9995                                           7.060570       \n",
-       "9996                                                NaN       \n",
-       "9997                                           7.732447       \n",
-       "9998                                           7.658063       \n",
-       "9999                                           4.846514       \n",
+       "      outc_outcome_name_within_365_days_None_fallback_0_dichotomous  \\\n",
+       "0                                                   0.0               \n",
+       "1                                                   0.0               \n",
+       "2                                                   0.0               \n",
+       "3                                                   0.0               \n",
+       "4                                                   0.0               \n",
+       "...                                                 ...               \n",
+       "3996                                                0.0               \n",
+       "3997                                                0.0               \n",
+       "3998                                                0.0               \n",
+       "3999                                                0.0               \n",
+       "4000                                                0.0               \n",
        "\n",
-       "      outc_outcome_name_within_365_days_None_fallback_0_dichotomous  \n",
-       "0                                                   0.0              \n",
-       "1                                                   0.0              \n",
-       "2                                                   0.0              \n",
-       "3                                                   0.0              \n",
-       "4                                                   0.0              \n",
-       "...                                                 ...              \n",
-       "9995                                                0.0              \n",
-       "9996                                                0.0              \n",
-       "9997                                                0.0              \n",
-       "9998                                                0.0              \n",
-       "9999                                                0.0              \n",
+       "      pred_predictor_name_within_730_days_None_fallback_nan  pred_female  \n",
+       "0                                              0.990763                0  \n",
+       "1                                              5.582745                1  \n",
+       "2                                              4.957251                0  \n",
+       "3                                              5.999336                0  \n",
+       "4                                              7.294038                0  \n",
+       "...                                                 ...              ...  \n",
+       "3996                                           7.137424                1  \n",
+       "3997                                           3.792014                0  \n",
+       "3998                                           5.769484                1  \n",
+       "3999                                           7.732447                0  \n",
+       "4000                                           4.846514                0  \n",
        "\n",
-       "[10000 rows x 6 columns]"
+       "[4001 rows x 6 columns]"
       ]
      },
-     "execution_count": 8,
+     "execution_count": 9,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -1178,11 +1307,10 @@
     "    id_col_name=\"dw_ek_borger\",\n",
     "    timestamp_col_name=\"timestamp\",\n",
     "    n_workers=1,\n",
+    "    drop_pred_times_with_insufficient_look_distance=True,\n",
     ")\n",
     "\n",
-    "ts_flattener.add_static_info(static_spec=sex_predictor_spec)\n",
-    "ts_flattener.add_temporal_predictor(output_spec=temporal_predictor_spec)\n",
-    "ts_flattener.add_temporal_outcome(output_spec=outcome_spec)\n",
+    "ts_flattener.add_spec([sex_predictor_spec, temporal_predictor_spec, outcome_spec])\n",
     "\n",
     "df = ts_flattener.get_df()\n",
     "\n",
@@ -1199,9 +1327,7 @@
     "2. Timestamps for each prediction time\n",
     "3. A unique identifier for each prediciton-time\n",
     "4. Our predictor columns, prefixed with `pred_` and\n",
-    "5. Our outcome columns, prefixed with `outc_`\n",
-    "\n",
-    "Note that the high proportion of NAs is due to the way we generate synthetic data. Specifically, we randomly generate entity IDs, so some IDs won't be represented in our predictors."
+    "5. Our outcome columns, prefixed with `outc_`"
    ]
   }
  ],


Test patch:
diff --git a/src/timeseriesflattener/testing/utils_for_testing.py b/src/timeseriesflattener/testing/utils_for_testing.py
index 5cb773c..0970c9d 100644
--- a/src/timeseriesflattener/testing/utils_for_testing.py
+++ b/src/timeseriesflattener/testing/utils_for_testing.py
@@ -88,10 +88,11 @@ def assert_flattened_data_as_expected(
     flattened_ds = TimeseriesFlattener(
         prediction_times_df=prediction_times_df,
         n_workers=4,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
-    flattened_ds._add_temporal_col_to_flattened_dataset(  # pylint: disable=protected-access
-        output_spec=output_spec,
+    flattened_ds.add_spec(  # pylint: disable=protected-access
+        spec=output_spec,
     )
 
     if expected_df:
@@ -145,7 +146,13 @@ def synth_prediction_times():
     return load_synth_prediction_times()
 
 
+@pytest.fixture(scope="function")
+def synth_predictor():
+    """Load the synth outcome times."""
+    return load_synth_outcome(n_rows=1_000)
+
+
 @pytest.fixture(scope="function")
 def synth_outcome():
     """Load the synth outcome times."""
-    return load_synth_outcome()
+    return load_synth_outcome(n_rows=1_000)
diff --git a/tests/test_feature_cache/test_cache_to_disk.py b/tests/test_feature_cache/test_cache_to_disk.py
index c315747..6355c6b 100644
--- a/tests/test_feature_cache/test_cache_to_disk.py
+++ b/tests/test_feature_cache/test_cache_to_disk.py
@@ -1,5 +1,7 @@
 """Testing of the DiskCache class."""
 
+from pathlib import Path
+
 import numpy as np
 import pandas as pd
 from pandas.testing import assert_frame_equal
@@ -7,9 +9,15 @@ from pandas.testing import assert_frame_equal
 from timeseriesflattener.feature_cache.cache_to_disk import DiskCache
 from timeseriesflattener.feature_spec_objects import PredictorSpec
 from timeseriesflattener.resolve_multiple_functions import latest
+from timeseriesflattener.testing.utils_for_testing import synth_outcome, synth_predictor
+
+# Avoid ruff auto-removing unused imports
+fixtures = [synth_predictor, synth_outcome]
 
 
-def test_write_and_check_feature(tmp_path):
+def test_write_and_check_feature(
+    tmp_path: Path,
+):
     """Test that write_feature writes a feature to disk."""
 
     cache = DiskCache(
@@ -27,6 +35,7 @@ def test_write_and_check_feature(tmp_path):
             "dw_ek_borger": [1, 2, 3],
             "pred_time_uuid": [1, 2, 3],
             "timestamp": [1, 2, 3],
+            "value": [1, 2, 3],
         },
     )
 
@@ -81,6 +90,7 @@ def test_read_feature(tmp_path):
         {
             "dw_ek_borger": [1, 2, 3, 4, 5],
             "timestamp": [1, 2, 3, 4, 5],
+            "value": [1, 2, 3, 4, 5],
         },
     )
 
diff --git a/tests/test_timeseriesflattener/test_add_values.py b/tests/test_timeseriesflattener/test_add_values.py
index c490359..0d3a5a8 100644
--- a/tests/test_timeseriesflattener/test_add_values.py
+++ b/tests/test_timeseriesflattener/test_add_values.py
@@ -7,7 +7,11 @@ import pandas as pd
 import pytest
 
 from timeseriesflattener import TimeseriesFlattener
-from timeseriesflattener.feature_spec_objects import AnySpec, OutcomeSpec, PredictorSpec
+from timeseriesflattener.feature_spec_objects import (
+    OutcomeSpec,
+    PredictorSpec,
+    StaticSpec,
+)
 from timeseriesflattener.testing.utils_for_testing import (
     assert_flattened_data_as_expected,
     str_to_df,
@@ -129,7 +133,7 @@ def test_event_before_prediction():
             values_df=str_to_df(outcome_df_str),
             lookahead_days=2,
             resolve_multiple_fn="max",
-            incident=True,
+            incident=False,
             fallback=np.NaN,
             feature_name="value",
         ),
@@ -157,7 +161,7 @@ def test_multiple_citizens_outcome():
             values_df=str_to_df(outcome_df_str),
             lookahead_days=2,
             resolve_multiple_fn="max",
-            incident=True,
+            incident=False,
             fallback=np.NaN,
             feature_name="value",
         ),
@@ -179,7 +183,7 @@ def test_citizen_without_outcome():
             values_df=str_to_df(outcome_df_str),
             lookahead_days=2,
             resolve_multiple_fn="max",
-            incident=True,
+            incident=False,
             fallback=np.NaN,
             feature_name="value",
         ),
@@ -201,9 +205,13 @@ def test_static_predictor():
                         1,1994-12-31 00:00:01
                         """
 
-    dataset = TimeseriesFlattener(prediction_times_df=str_to_df(prediction_times_df))
-    dataset.add_static_info(
-        static_spec=AnySpec(
+    dataset = TimeseriesFlattener(
+        prediction_times_df=str_to_df(prediction_times_df),
+        drop_pred_times_with_insufficient_look_distance=False,
+    )
+
+    dataset.add_spec(
+        StaticSpec(
             values_df=str_to_df(static_predictor),
             feature_name=feature_name,
             prefix=prefix,
@@ -238,15 +246,17 @@ def test_add_age():
                         1,1994-12-31 00:00:00
                         """
 
-    dataset = TimeseriesFlattener(prediction_times_df=str_to_df(prediction_times_df))
+    dataset = TimeseriesFlattener(
+        prediction_times_df=str_to_df(prediction_times_df),
+        drop_pred_times_with_insufficient_look_distance=False,
+    )
 
     output_prefix = "eval"
 
-    dataset.add_age_and_birth_year(
-        id2date_of_birth=str_to_df(static_predictor),
-        input_date_of_birth_col_name="date_of_birth",
+    dataset.add_age(
+        date_of_birth_df=str_to_df(static_predictor),
+        date_of_birth_col_name="date_of_birth",
         output_prefix=output_prefix,
-        birth_year_as_predictor=True,
     )
 
     expected_values = pd.DataFrame(
@@ -276,12 +286,15 @@ def test_add_age_error():
                         1,94-12-31 00:00:00
                         """
 
-    dataset = TimeseriesFlattener(prediction_times_df=str_to_df(prediction_times_df))
+    dataset = TimeseriesFlattener(
+        prediction_times_df=str_to_df(prediction_times_df),
+        drop_pred_times_with_insufficient_look_distance=False,
+    )
 
     with pytest.raises(ValueError):
-        dataset.add_age_and_birth_year(
-            id2date_of_birth=str_to_df(static_predictor),
-            input_date_of_birth_col_name="date_of_birth",
+        dataset.add_age(
+            date_of_birth_df=str_to_df(static_predictor),
+            date_of_birth_col_name="date_of_birth",
         )
 
 
@@ -314,10 +327,11 @@ def test_incident_outcome_removing_prediction_times():
         timestamp_col_name="timestamp",
         id_col_name="dw_ek_borger",
         n_workers=4,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
-    flattened_dataset.add_temporal_outcome(
-        output_spec=OutcomeSpec(
+    flattened_dataset.add_spec(
+        spec=OutcomeSpec(
             values_df=event_times_df,
             interval_days=2,
             incident=True,
@@ -378,6 +392,7 @@ def test_add_multiple_static_predictors():
         timestamp_col_name="timestamp",
         id_col_name="dw_ek_borger",
         n_workers=4,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
     output_spec = OutcomeSpec(
@@ -389,16 +404,21 @@ def test_add_multiple_static_predictors():
         feature_name="value",
     )
 
-    flattened_dataset.add_temporal_outcome(
-        output_spec=output_spec,
+    flattened_dataset.add_spec(
+        spec=[
+            output_spec,
+            StaticSpec(
+                values_df=male_df,
+                feature_name="male",
+                prefix="pred",
+                input_col_name_override="male",
+            ),
+        ],
     )
 
-    flattened_dataset.add_age_and_birth_year(
-        input_date_of_birth_col_name="date_of_birth",
-        id2date_of_birth=birthdates_df,
-    )
-    flattened_dataset.add_static_info(
-        static_spec=AnySpec(values_df=male_df, feature_name="male", prefix="pred"),
+    flattened_dataset.add_age(
+        date_of_birth_col_name="date_of_birth",
+        date_of_birth_df=birthdates_df,
     )
 
     outcome_df = flattened_dataset.get_df()
@@ -449,30 +469,28 @@ def test_add_temporal_predictors_then_temporal_outcome():
         timestamp_col_name="timestamp",
         id_col_name="dw_ek_borger",
         n_workers=4,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
-    predictor_spec_list = PredictorSpec(
-        values_df=predictors_df,
-        interval_days=365,
-        resolve_multiple_fn="min",
-        fallback=np.nan,
-        allowed_nan_value_prop=0,
-        feature_name="value",
-    )
-
-    flattened_dataset.add_temporal_predictor_batch(
-        predictor_batch=[predictor_spec_list],
-    )
-
-    flattened_dataset.add_temporal_outcome(
-        output_spec=OutcomeSpec(
-            values_df=event_times_df,
-            interval_days=2,
-            resolve_multiple_fn="max",
-            fallback=0,
-            incident=True,
-            feature_name="value",
-        ),
+    flattened_dataset.add_spec(
+        spec=[
+            PredictorSpec(
+                values_df=predictors_df,
+                interval_days=365,
+                resolve_multiple_fn="min",
+                fallback=np.nan,
+                allowed_nan_value_prop=0,
+                feature_name="value",
+            ),
+            OutcomeSpec(
+                values_df=event_times_df,
+                interval_days=2,
+                resolve_multiple_fn="max",
+                fallback=0,
+                incident=True,
+                feature_name="value",
+            ),
+        ],
     )
 
     outcome_df = flattened_dataset.get_df().set_index("dw_ek_borger").sort_index()
@@ -511,10 +529,11 @@ def test_add_temporal_incident_binary_outcome():
         timestamp_col_name="timestamp",
         id_col_name="dw_ek_borger",
         n_workers=4,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
-    flattened_dataset.add_temporal_outcome(
-        output_spec=OutcomeSpec(
+    flattened_dataset.add_spec(
+        spec=OutcomeSpec(
             values_df=event_times_df,
             interval_days=2,
             incident=True,
@@ -532,4 +551,5 @@ def test_add_temporal_incident_binary_outcome():
             # which is not a meaningful error here. So we force the dtype.
             if df[col].dtype == "int64":
                 df[col] = df[col].astype("int32")
+
         pd.testing.assert_series_equal(outcome_df[col], expected_df[col])
diff --git a/tests/test_timeseriesflattener/test_errors.py b/tests/test_timeseriesflattener/test_errors.py
index 8287c5e..024b46b 100644
--- a/tests/test_timeseriesflattener/test_errors.py
+++ b/tests/test_timeseriesflattener/test_errors.py
@@ -23,6 +23,7 @@ def test_col_does_not_exist_in_prediction_times():
             prediction_times_df=prediction_times_df,
             timestamp_col_name="timestamp",
             id_col_name="dw_ek_borger",
+            drop_pred_times_with_insufficient_look_distance=False,
         )
 
 
@@ -43,11 +44,12 @@ def test_col_does_not_exist():
         prediction_times_df=prediction_times_df,
         timestamp_col_name="timestamp",
         id_col_name="dw_ek_borger",
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
     with pytest.raises(KeyError):
-        flattened_df.add_temporal_predictor(
-            output_spec=PredictorSpec(
+        flattened_df.add_spec(
+            spec=PredictorSpec(
                 values_df=event_times_df,
                 interval_days=2,
                 resolve_multiple_fn="max",
@@ -66,4 +68,5 @@ def test_duplicate_prediction_times():
 
         TimeseriesFlattener(
             prediction_times_df=str_to_df(prediction_times_df_str),
+            drop_pred_times_with_insufficient_look_distance=False,
         )
diff --git a/tests/test_timeseriesflattener/test_flattened_dataset.py b/tests/test_timeseriesflattener/test_flattened_dataset.py
new file mode 100644
index 0000000..3c29c1f
--- /dev/null
+++ b/tests/test_timeseriesflattener/test_flattened_dataset.py
@@ -0,0 +1,163 @@
+"""Larger tests for the `flattened_dataset.py` module."""
+import numpy as np
+import pandas as pd
+import pytest
+
+from timeseriesflattener.feature_spec_objects import (
+    OutcomeSpec,
+    PredictorSpec,
+    StaticSpec,
+)
+from timeseriesflattener.flattened_dataset import TimeseriesFlattener
+from timeseriesflattener.resolve_multiple_functions import latest, mean
+from timeseriesflattener.testing.utils_for_testing import (
+    synth_outcome,
+    synth_prediction_times,
+)
+
+# To avoid ruff auto-removing unused imports
+used_funcs = [synth_prediction_times, synth_outcome]
+
+# pylint: disable=missing-function-docstring
+
+
+def test_add_spec(synth_prediction_times: pd.DataFrame, synth_outcome: pd.DataFrame):
+    # Create an instance of the class that contains the `add_spec` method
+    dataset = TimeseriesFlattener(
+        prediction_times_df=synth_prediction_times,
+        drop_pred_times_with_insufficient_look_distance=False,
+    )
+
+    # Create sample specs
+    outcome_spec = OutcomeSpec(
+        values_df=synth_outcome,
+        feature_name="outcome",
+        lookahead_days=1,
+        resolve_multiple_fn=mean,
+        fallback=0,
+        incident=False,
+    )
+    predictor_spec = PredictorSpec(
+        values_df=synth_outcome,
+        feature_name="predictor",
+        lookbehind_days=1,
+        resolve_multiple_fn=mean,
+        fallback=np.nan,
+    )
+    static_spec = StaticSpec(
+        values_df=synth_outcome,
+        feature_name="static",
+        prefix="pred",
+    )
+
+    # Test adding a single spec
+    dataset.add_spec(outcome_spec)
+    assert dataset.unprocessed_specs.outcome_specs == [outcome_spec]
+
+    # Test adding multiple specs
+    dataset.add_spec([predictor_spec, static_spec])
+    assert dataset.unprocessed_specs.predictor_specs == [predictor_spec]
+    assert dataset.unprocessed_specs.static_specs == [static_spec]
+
+    # Test adding an invalid spec type
+    with pytest.raises(ValueError):
+        dataset.add_spec("invalid spec")
+
+
+def test_compute_specs(
+    synth_prediction_times: pd.DataFrame,
+    synth_outcome: pd.DataFrame,
+):
+    # Create an instance of the class that contains the `add_spec` method
+    dataset = TimeseriesFlattener(
+        prediction_times_df=synth_prediction_times,
+        drop_pred_times_with_insufficient_look_distance=False,
+    )
+
+    # Create sample specs
+    outcome_spec = OutcomeSpec(
+        values_df=synth_outcome,
+        feature_name="outcome",
+        lookahead_days=1,
+        resolve_multiple_fn=mean,
+        fallback=0,
+        incident=False,
+    )
+    predictor_spec = PredictorSpec(
+        values_df=synth_outcome,
+        feature_name="predictor",
+        lookbehind_days=1,
+        resolve_multiple_fn=mean,
+        fallback=np.nan,
+    )
+    static_spec = StaticSpec(
+        values_df=synth_outcome[["value", "dw_ek_borger"]],
+        feature_name="static",
+        prefix="pred",
+    )
+
+    # Test adding a single spec
+    dataset.add_spec([outcome_spec, predictor_spec, static_spec])
+
+    df = dataset.get_df()
+
+    assert isinstance(df, pd.DataFrame)
+
+
+def test_drop_pred_time_if_insufficient_look_distance():
+    # Create a sample DataFrame with some test data
+    pred_time_df = pd.DataFrame(
+        {
+            "dw_ek_borger": [1, 1, 1, 1],
+            "timestamp": ["2022-01-01", "2022-01-02", "2022-01-03", "2022-01-04"],
+        },
+    )
+
+    ts_flattener = TimeseriesFlattener(
+        prediction_times_df=pred_time_df,
+        drop_pred_times_with_insufficient_look_distance=True,
+    )
+
+    pred_val_df = pd.DataFrame(
+        {
+            "dw_ek_borger": [1],
+            "timestamp": ["2022-01-01"],
+            "value": [1],
+        },
+    )
+
+    # Create a sample set of specs
+    predictor_spec = PredictorSpec(
+        values_df=pred_val_df,
+        lookbehind_days=1,
+        resolve_multiple_fn=latest,
+        fallback=np.nan,
+        feature_name="test_feature",
+    )
+
+    out_val_df = pd.DataFrame(
+        {
+            "dw_ek_borger": [1],
+            "timestamp": ["2022-01-05"],
+            "value": [4],
+        },
+    )
+
+    outcome_spec = OutcomeSpec(
+        values_df=out_val_df,
+        lookahead_days=2,
+        resolve_multiple_fn=latest,
+        fallback=np.nan,
+        feature_name="test_feature",
+        incident=False,
+    )
+
+    ts_flattener.add_spec(spec=[predictor_spec, outcome_spec])
+
+    out_df = ts_flattener.get_df()
+
+    # Assert that the correct rows were dropped from the DataFrame
+    expected_df = pd.DataFrame({"timestamp": ["2022-01-02", "2022-01-03"]})
+    # Convert to datetime to avoid a warning
+    expected_df = expected_df.astype({"timestamp": "datetime64[ns]"})
+    pd.testing.assert_series_equal(out_df["timestamp"], expected_df["timestamp"])
diff --git a/tests/test_timeseriesflattener/test_flattened_dataset_generation.py b/tests/test_timeseriesflattener/test_flattened_dataset_generation.py
index bcf3ea3..cf04ec7 100644
--- a/tests/test_timeseriesflattener/test_flattened_dataset_generation.py
+++ b/tests/test_timeseriesflattener/test_flattened_dataset_generation.py
@@ -105,10 +105,11 @@ def create_flattened_df(
         prediction_times_df=prediction_times_df,
         n_workers=1,
         cache=cache,
+        drop_pred_times_with_insufficient_look_distance=False,
     )
 
-    flat_ds.add_temporal_predictor_batch(
-        predictor_batch=predictor_specs,
+    flat_ds.add_spec(
+        spec=predictor_specs,
     )
 
     return flat_ds.get_df()
@@ -119,9 +120,9 @@ def create_flattened_df(
     [base_float_predictor_combinations, base_binary_predictor_combinations],
 )
 def test_cache_hitting(
-    tmp_path,
-    predictor_specs,
-    synth_prediction_times,
+    tmp_path: Path,
+    predictor_specs: list[PredictorSpec],
+    synth_prediction_times: pd.DataFrame,
 ):
     """Test that cache hits."""
 
diff --git a/tests/test_timeseriesflattener/test_resolve_multiple.py b/tests/test_timeseriesflattener/test_resolve_multiple.py
index 80e568a..409bf50 100644
--- a/tests/test_timeseriesflattener/test_resolve_multiple.py
+++ b/tests/test_timeseriesflattener/test_resolve_multiple.py
@@ -4,13 +4,6 @@
 import numpy as np
 
 from timeseriesflattener.feature_spec_objects import OutcomeSpec, PredictorSpec
-from timeseriesflattener.resolve_multiple_functions import (  # noqa pylint: disable=unused-import
-    earliest,
-    latest,
-    maximum,
-    mean,
-    minimum,
-)
 from timeseriesflattener.testing.utils_for_testing import (
     assert_flattened_data_as_expected,
     str_to_df,
@@ -35,7 +28,7 @@ def test_resolve_multiple_catalogue():
             resolve_multiple_fn="min",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1],
     )
@@ -58,7 +51,7 @@ def test_resolve_multiple_max():
             resolve_multiple_fn="max",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[2],
     )
@@ -81,7 +74,7 @@ def test_resolve_multiple_min():
             resolve_multiple_fn="min",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1],
     )
@@ -131,7 +124,7 @@ def test_resolve_multiple_latest():
             resolve_multiple_fn="latest",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[3, 9],
     )
@@ -155,7 +148,7 @@ def test_resolve_multiple_latest_no_values():
             resolve_multiple_fn="latest",
             interval_days=2,
             fallback=np.nan,
-            incident=True,
+            incident=False,
         ),
         expected_values=[2, np.nan],
     )
@@ -177,7 +170,7 @@ def test_resolve_multiple_latest_one_vlaue():
             resolve_multiple_fn="latest",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1],
     )
@@ -205,7 +198,7 @@ def test_resolve_multiple_earliest():
             resolve_multiple_fn="earliest",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, 1],
     )
@@ -250,7 +243,7 @@ def test_resolve_multiple_count():
             resolve_multiple_fn="count",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[2],
     )
@@ -274,7 +267,7 @@ def test_resolve_multiple_bool():
             resolve_multiple_fn="bool",
             interval_days=2,
             fallback=0,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, 0],
     )
@@ -300,7 +293,7 @@ def test_resolve_multiple_change_per_day():
             resolve_multiple_fn="change_per_day",
             interval_days=4,
             fallback=np.NaN,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, np.NaN],
     )
@@ -326,7 +319,7 @@ def test_resolve_multiple_change_per_day_unordered():
             resolve_multiple_fn="change_per_day",
             interval_days=4,
             fallback=np.NaN,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, 1],
     )
@@ -352,7 +345,7 @@ def test_resolve_multiple_change_per_day_negative():
             resolve_multiple_fn="change_per_day",
             interval_days=4,
             fallback=np.NaN,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, -1],
     )
@@ -378,7 +371,7 @@ def test_resolve_multiple_change_per_day_too_few_datapoints():
             resolve_multiple_fn="change_per_day",
             interval_days=4,
             fallback=99999,
-            incident=True,
+            incident=False,
         ),
         expected_values=[1, 99999],
     )
@@ -404,7 +397,7 @@ def test_resolve_multiple_variance():
             resolve_multiple_fn="variance",
             interval_days=4,
             fallback=np.NaN,
-            incident=True,
+            incident=False,
         ),
         expected_values=[0.5, np.NaN],
     )