Skip to content

How to Use Time Weighting

This guide shows you how to weight observations by recency or seasonal relevance so that forecasters and scorers focus on the most informative parts of your history.

Prerequisites

Try it interactively

  • How to Apply Time-Weighted Training


    Use time_weight and sample_weight_alignment to emphasise recent or seasonal training samples in PointReductionForecaster, with visualisation of weight curves and alignment strategy comparison.

    View · Open in marimo

  • How to Score with Time-Weighted Metrics


    Apply exponential decay, linear decay, and seasonal emphasis weighting to forecast evaluation, prioritising recent or periodic time steps.

    View · Open in marimo

1. Choose a Weighting Strategy

Time weighting is expressed with weighter estimators: small scikit-learn estimators that map a key series (observation times, vintage times, or integer forecasting steps) to a series of weights. Because they are estimators, their parameters are introspectable, clonable, and tunable by search (covered in §6).

If you want to down-weight old observations smoothly, use ExponentialDecayWeighter. It halves the weight every half_life, keeping the most recent observation at 1.0:

from yohou.weighting import ExponentialDecayWeighter

weighter = ExponentialDecayWeighter(half_life=365)

ExponentialDecayWeighter has a scale parameter that sets the decay basis (real elapsed time versus rank position), which matters when sampling is irregular. When scale=None (the default) it is inferred from the key dtype:

Key dtype Inferred scale half_life units Decay basis
datetime "elapsed" days (int/float) or timedelta real elapsed time to the latest key
numeric / integer step "position" steps (int/float) rank-index distance to the latest key

Set scale explicitly to override the inference, e.g. scale="position" to decay regularly-spaced datetimes by row position, or to weight integer forecasting steps. A timedelta half_life with scale="position" raises ValueError.

If you prefer a simple ramp from 0 (oldest) to 1 (newest), use LinearDecayWeighter:

from yohou.weighting import LinearDecayWeighter

weighter = LinearDecayWeighter()

To zero out observations older than a fixed window, pass max_steps:

weighter = LinearDecayWeighter(max_steps=100)

If seasonality matters more than recency, use SeasonalEmphasisWeighter to boost observations at the same seasonal position as the most recent one:

from yohou.weighting import SeasonalEmphasisWeighter

# Emphasize same-month observations (monthly data with yearly cycle)
weighter = SeasonalEmphasisWeighter(seasonality=12, emphasis=2.0)

In-phase observations get the emphasis weight (default 2.0), all others get 1.0. For multiple seasonalities, pass a list:

weighter = SeasonalEmphasisWeighter(seasonality=[7, 365], emphasis=1.5)

2. Use Explicit or Table-Driven Weights

When you want weights assigned by key rather than by a decay rule, use the lookup and table weighters.

LookupWeighter maps keys to weights via a dict. Keys absent from the mapping receive the tunable default weight (this replaces the old "*" wildcard):

from datetime import datetime
from yohou.weighting import LookupWeighter

weighter = LookupWeighter(
    mapping={datetime(2024, 6, 1): 2.0, datetime(2024, 7, 1): 2.0},
    default=0.5,  # weight for all other keys
)

TableWeighter resolves weights by joining the key series to a pl.DataFrame on a key column:

import polars as pl
from yohou.weighting import TableWeighter

frame = pl.DataFrame({
    "time": y_train["time"],
    "weight": [1.0, 1.0, 0.5, 0.5, 0.0],
})
weighter = TableWeighter(frame=frame, on="time")

For panel data, give the frame group-specific columns (e.g. "store_a_weight", "store_b_weight") or a single "weight" column applied to all groups. A key with no matching row raises ValueError.

3. Compose Multiple Weights

To combine recency and seasonal effects, use CompositeWeighter. Its components are named (name, weighter) tuples (the same convention as FeaturePipeline and the voting ensembles), which keeps every sub-weighter's parameters addressable for tuning. By default it multiplies the component weights element-wise:

from yohou.weighting import CompositeWeighter

weighter = CompositeWeighter([
    ("decay", ExponentialDecayWeighter(half_life=365)),
    ("seasonal", SeasonalEmphasisWeighter(seasonality=12, emphasis=2.0)),
])

Pass combination="mean" to average the components instead of multiplying, and weights=[...] to give per-component exponents (under "multiply") or mixing coefficients (under "mean"):

weighter = CompositeWeighter(
    [("decay", ExponentialDecayWeighter(half_life=365)),
     ("seasonal", SeasonalEmphasisWeighter(seasonality=12))],
    combination="mean",
    weights=[2.0, 1.0],
)

4. Apply Weights During Training

Weighting is a constructor parameter of the forecaster, not an argument to fit. Pass a weighter to the time_weighter slot of a reduction forecaster:

from sklearn.linear_model import Ridge
from yohou.point import PointReductionForecaster

forecaster = PointReductionForecaster(
    estimator=Ridge(),
    time_weighter=ExponentialDecayWeighter(half_life=365),
)
forecaster.fit(y_train, forecasting_horizon=12)

The forecaster converts time weights to sklearn sample_weight internally. Because each training sample spans multiple forecast steps, the per-timestamp weights must be collapsed into one weight per sample. The sample_weight_alignment constructor parameter controls how:

forecaster = PointReductionForecaster(
    estimator=Ridge(),
    time_weighter=ExponentialDecayWeighter(half_life=365),
    sample_weight_alignment="mean_step",
)
forecaster.fit(y_train, forecasting_horizon=12)

The default is "first_step". A vintage_weighter slot is available for per-vintage weighting. See Weighting for a full comparison of alignment strategies.

5. Apply Weights During Scoring

Scorers carry their weighting on __init__ too. Pass a weighter to time_weighter to weight per-timestep errors:

from yohou.metrics import MeanAbsoluteError

scorer = MeanAbsoluteError(time_weighter=ExponentialDecayWeighter(half_life=365))
scorer.fit(y_train)
weighted_score = scorer.score(y_test, y_pred)

Scorers also expose step_weighter and vintage_weighter for multi-vintage predictions. A scorer only exposes the weighter slots it supports. For example, MedianAbsoluteError has no time_weighter parameter, so an unsupported weighter is rejected at construction rather than at score time. See Multi-vintage Scoring for details.

Because the weighting lives on the scorer instance, a weighted scorer is a valid cross_validate objective with no per-call weight argument:

from yohou.model_selection import cross_validate

cross_validate(forecaster, y_train, forecasting_horizon=12, scoring=scorer)

6. Tune Your Weighting

Because weighters are constructor parameters, their settings are searchable hyperparameters addressed with the __ syntax. Tune the decay half-life, and even the decay basis, directly:

from yohou.metrics import MeanAbsoluteError
from yohou.model_selection import GridSearchCV, ExpandingWindowSplitter

forecaster = PointReductionForecaster(
    estimator=Ridge(),
    time_weighter=ExponentialDecayWeighter(half_life=365),
)

search = GridSearchCV(
    forecaster,
    param_grid={
        "time_weighter__half_life": [90, 180, 365, 730],
        "time_weighter__scale": ["elapsed", "position"],
    },
    scoring=MeanAbsoluteError(),
    cv=ExpandingWindowSplitter(n_splits=5, test_size=12),
)
search.fit(y_train, forecasting_horizon=12)

Components of a CompositeWeighter are reachable through their names (time_weighter__decay__half_life), and you can search over whole weighter instances by listing them as grid values:

search = GridSearchCV(
    forecaster,
    param_grid={"time_weighter": [
        ExponentialDecayWeighter(half_life=180),
        LinearDecayWeighter(max_steps=100),
    ]},
    scoring=MeanAbsoluteError(),
    cv=ExpandingWindowSplitter(n_splits=5, test_size=12),
)
search.fit(y_train, forecasting_horizon=12)

Weighters recompute on each fold's own key series, so recency is always relative to that fold's most-recent key.

7. Customize Weights for Panel Data

Built-in weighters are panel-aware automatically: for panel data the forecaster calls compute_weights(key, group_name) once per group, on that group's own key series, so each group is weighted relative to its own most-recent key. The built-ins ignore group_name (every group gets the same profile shape).

To give different groups different parameters, write a small BaseWeighter subclass that dispatches on group_name:

import polars as pl
from yohou.weighting import BaseWeighter, ExponentialDecayWeighter

class PerStoreDecay(BaseWeighter):
    def compute_weights(self, key: pl.Series, group_name: str | None = None) -> pl.Series:
        half_life = 180 if group_name == "store_a" else 365
        return ExponentialDecayWeighter(half_life=half_life).compute_weights(key, group_name)

forecaster = PointReductionForecaster(estimator=Ridge(), time_weighter=PerStoreDecay())
forecaster.fit(y_train, forecasting_horizon=12)

For global (non-panel) data, group_name is None.

8. Visualize the Weight Profile

plot_time_weight shows weights over time. Call the weighter's compute_weights on the key series to build the expected DataFrame:

from yohou.plotting import plot_time_weight

weighter = ExponentialDecayWeighter(half_life=365)
weights = weighter.compute_weights(y_train["time"])
weights_df = y_train.select("time").with_columns(time_weight=weights)

plot_time_weight(weights_df)

If your weight column has a different name, pass weight_column="my_col". To disable the filled area under the curve, pass fill=False.

See Also