Skip to content

Downsampler

yohou.preprocessing.Downsampler

Bases: BaseActualTransformer

Downsample time series to a lower frequency using aggregation.

Reduces the frequency of time series data by grouping consecutive time points into bins and applying an aggregation function. Uses polars' group_by_dynamic for efficient windowed aggregation.

Because group_by_dynamic bins by wall-clock windows, the input does not need a uniform grid: Downsampler declares accepts_irregular_grid=True, so a jittered or gapped sub-hourly feed is accepted at fit and transform (the strict interval-consistency check is skipped and a representative median interval is recorded for the target >= input guard). Behavior on a uniform grid is unchanged.

Accepting a gapped input axis means the output can carry gaps too: a window with no rows produces no bin, so a gap in the input becomes a gap in the output. A downstream transformer that requires a uniform grid may not notice, because the strict interval check tolerates a sub-day delta spread and will infer an interval from a gapped axis rather than reject it. A lag or rolling transformer placed after a Downsampler on gapped input therefore computes over rows that are not the real-time distance apart that its parameters imply. Fill or validate the gaps (see SimpleTimeImputer, Upsampler) before an order-dependent step.

Parameters

Name Type Description Default
interval str

Target time interval (e.g., "1h", "1d", "5m", "30s"). Uses polars duration string syntax. Must be greater than or equal to the input data's interval.

'1h'
aggregation (mean, sum, min, max, first, last, median)

Aggregation function to apply within each time bin: - "mean": Average values in each bin - "sum": Sum values in each bin - "min": Minimum value in each bin - "max": Maximum value in each bin - "first": First value in each bin - "last": Last value in each bin - "median": Median value in each bin

"mean"
closed (left, right)

Which side of the interval is closed.

"left"
label (left, right)

Which side of the interval to use as the label for each bin.

"left"
include_boundaries bool

Whether to include the interval boundaries in output.

False

Attributes

Name Type Description
n_features_in_ int

Number of features seen during fit.

feature_names_in_ list of str

Names of features seen during fit.

input_interval_ timedelta or None

Detected time interval of input data.

target_interval_ timedelta or None

Target time interval.

Examples

>>> import polars as pl
>>> from datetime import datetime, timedelta
>>> from yohou.preprocessing import Downsampler
>>> # Create hourly data
>>> times = [datetime(2020, 1, 1) + timedelta(hours=i) for i in range(24)]
>>> X = pl.DataFrame({"time": times, "value": list(range(24))})
>>> # Downsample to daily (24h) using mean aggregation
>>> downsampler = Downsampler(interval="1d", aggregation="mean")
>>> downsampler.fit(X)
Downsampler(interval='1d')
>>> X_daily = downsampler.transform(X)
>>> len(X_daily) == 1  # Single day
True

See Also

Upsampler : Upsample time series to higher frequency.

Source Code

Source code in src/yohou/preprocessing/resampling.py
class Downsampler(BaseActualTransformer):
    """Downsample time series to a lower frequency using aggregation.

    Reduces the frequency of time series data by grouping consecutive time
    points into bins and applying an aggregation function. Uses polars'
    `group_by_dynamic` for efficient windowed aggregation.

    Because `group_by_dynamic` bins by wall-clock windows, the input does not need a
    uniform grid: `Downsampler` declares `accepts_irregular_grid=True`, so a jittered
    or gapped sub-hourly feed is accepted at fit and transform (the strict
    interval-consistency check is skipped and a representative median interval is
    recorded for the `target >= input` guard). Behavior on a uniform grid is unchanged.

    Accepting a gapped input axis means the output can carry gaps too: a window with
    no rows produces no bin, so a gap in the input becomes a gap in the output. A
    downstream transformer that requires a uniform grid may not notice, because the
    strict interval check tolerates a sub-day delta spread and will infer an interval
    from a gapped axis rather than reject it. A lag or rolling transformer placed after
    a `Downsampler` on gapped input therefore computes over rows that are not the
    real-time distance apart that its parameters imply. Fill or validate the gaps
    (see `SimpleTimeImputer`, `Upsampler`) before an order-dependent step.

    Parameters
    ----------
    interval : str, default='1h'
        Target time interval (e.g., "1h", "1d", "5m", "30s").
        Uses polars duration string syntax. Must be greater than or equal to
        the input data's interval.
    aggregation : {"mean", "sum", "min", "max", "first", "last", "median"}, default="mean"
        Aggregation function to apply within each time bin:
        - "mean": Average values in each bin
        - "sum": Sum values in each bin
        - "min": Minimum value in each bin
        - "max": Maximum value in each bin
        - "first": First value in each bin
        - "last": Last value in each bin
        - "median": Median value in each bin
    closed : {"left", "right"}, default="left"
        Which side of the interval is closed.
    label : {"left", "right"}, default="left"
        Which side of the interval to use as the label for each bin.
    include_boundaries : bool, default=False
        Whether to include the interval boundaries in output.

    Attributes
    ----------
    n_features_in_ : int
        Number of features seen during fit.
    feature_names_in_ : list of str
        Names of features seen during fit.
    input_interval_ : timedelta or None
        Detected time interval of input data.
    target_interval_ : timedelta or None
        Target time interval.

    Examples
    --------
    >>> import polars as pl
    >>> from datetime import datetime, timedelta
    >>> from yohou.preprocessing import Downsampler

    >>> # Create hourly data
    >>> times = [datetime(2020, 1, 1) + timedelta(hours=i) for i in range(24)]
    >>> X = pl.DataFrame({"time": times, "value": list(range(24))})

    >>> # Downsample to daily (24h) using mean aggregation
    >>> downsampler = Downsampler(interval="1d", aggregation="mean")
    >>> downsampler.fit(X)
    Downsampler(interval='1d')
    >>> X_daily = downsampler.transform(X)
    >>> len(X_daily) == 1  # Single day
    True

    See Also
    --------
    - [`Upsampler`][yohou.preprocessing.resampling.Upsampler] : Upsample time series to higher frequency.

    """

    _valid_aggregations = {"mean", "sum", "min", "max", "first", "last", "median"}

    _parameter_constraints: dict = {
        "interval": [str],
        "aggregation": [StrOptions(_valid_aggregations)],
        "closed": [StrOptions({"left", "right"})],
        "label": [StrOptions({"left", "right"})],
        "include_boundaries": ["boolean"],
    }

    # Bins via group_by_dynamic, which is correct on a non-uniform grid, so it opts
    # into the irregular-grid contract: a jittered or gapped sub-hourly feed can be
    # downsampled without first being placed on a strict uniform grid.
    _tags = {"stateful": False, "accepts_irregular_grid": True}

    def __init__(
        self,
        interval: str = "1h",
        aggregation: Literal["mean", "sum", "min", "max", "first", "last", "median"] = "mean",
        closed: Literal["left", "right"] = "left",
        label: Literal["left", "right"] = "left",
        include_boundaries: bool = False,
    ):
        self.interval = interval
        self.aggregation = aggregation
        self.closed = closed
        self.label = label
        self.include_boundaries = include_boundaries

    def _fit(self, X: pl.DataFrame, y: pl.DataFrame | None = None) -> None:
        """Fit the internal model."""
        # Detect the input interval with the representative (frequency-weighted median)
        # measure, which tolerates a jittered or gapped grid and agrees with the strict
        # check on a uniform one. The strict check is not tried first: on a sub-day axis
        # it medians the *unique* deltas, so a few gaps skew it above the true cadence
        # (a 5m feed with two gaps reads as 10m), and it succeeds rather than raising,
        # which would wrongly reject a target at the feed's real cadence in the guard
        # below. group_by_dynamic bins either way, so the transform is unaffected.
        self.input_interval_str_ = representative_interval(X)
        self.input_interval_ = interval_to_timedelta(self.input_interval_str_)
        self.target_interval_ = interval_to_timedelta(self.interval)

        # Normalize interval to polars-native format (e.g. "30min" → "30m")
        _mult, _unit = parse_interval(self.interval)
        self.polars_interval_ = f"{_mult}{_unit}"

        # Validate: target must be >= input for downsampling
        if (
            self.input_interval_ is not None
            and self.target_interval_ is not None
            and self.target_interval_ < self.input_interval_
        ):
            msg = (
                f"Target interval ({self.interval}) is smaller than input interval "
                f"({self.input_interval_str_}). Use Upsampler for increasing frequency."
            )
            raise ValueError(msg)

    def _transform(self, X: pl.DataFrame) -> pl.DataFrame:
        """Downsample time series to target frequency.

        Parameters
        ----------
        X : pl.DataFrame
            Validated input time series.

        Returns
        -------
        pl.DataFrame
            Downsampled time series.

        """
        # Get data columns
        data_cols = [c for c in X.columns if c != "time"]

        # Build aggregation expressions
        agg_exprs = []
        for col in data_cols:
            if self.aggregation == "mean":
                agg_exprs.append(pl.col(col).mean())
            elif self.aggregation == "sum":
                agg_exprs.append(pl.col(col).sum())
            elif self.aggregation == "min":
                agg_exprs.append(pl.col(col).min())
            elif self.aggregation == "max":
                agg_exprs.append(pl.col(col).max())
            elif self.aggregation == "first":
                agg_exprs.append(pl.col(col).first())
            elif self.aggregation == "last":
                agg_exprs.append(pl.col(col).last())
            elif self.aggregation == "median":
                agg_exprs.append(pl.col(col).median())

        result = (
            X
            .sort("time")
            .group_by_dynamic(
                "time",
                every=self.polars_interval_,
                closed=self.closed,
                label=self.label,
                include_boundaries=self.include_boundaries,
            )
            .agg(agg_exprs)
        )

        # group_by_dynamic prepends _lower_boundary/_upper_boundary columns when
        # include_boundaries=True; drop them so the output schema is just "time"
        # plus the original feature columns (they are not reported by
        # get_feature_names_out and duplicate the "time" anchor).
        if self.include_boundaries:
            result = result.drop(["_lower_boundary", "_upper_boundary"], strict=False)

        return result

    def get_feature_names_out(self, input_features: list[str] | None = None) -> list[str]:
        """Get output feature names for transformation.

        Parameters
        ----------
        input_features : list of str or None, default=None
            Column names of the input features.  If ``None``, uses the
            feature names seen during ``fit``.

        Returns
        -------
        list of str
            Output feature names after transformation.

        """
        check_is_fitted(self, ["feature_names_in_"])
        input_features = _check_feature_names_in(self, input_features)
        return list(input_features)

Methods

get_feature_names_out(input_features=None)

Get output feature names for transformation.

Parameters
Name Type Description Default
input_features list of str or None

Column names of the input features. If None, uses the feature names seen during fit.

None
Returns
Type Description
list of str

Output feature names after transformation.

Source Code
Source code in src/yohou/preprocessing/resampling.py
def get_feature_names_out(self, input_features: list[str] | None = None) -> list[str]:
    """Get output feature names for transformation.

    Parameters
    ----------
    input_features : list of str or None, default=None
        Column names of the input features.  If ``None``, uses the
        feature names seen during ``fit``.

    Returns
    -------
    list of str
        Output feature names after transformation.

    """
    check_is_fitted(self, ["feature_names_in_"])
    input_features = _check_feature_names_in(self, input_features)
    return list(input_features)

Tutorials

The following example notebooks use this component:

  • How to Handle Long Series


    Limit history with observation_horizon, weight recent errors with exponential decay, and downsample high-frequency data.

    View · Open in marimo

  • How to Resample Time Series


    Demonstrate Downsampler and Upsampler for changing time series frequency, including multivariate support, boundary settings, and round-trip information loss.

    View · Open in marimo