Source code for emflow.features.spec

"""Declarative feature specs, materialized point-in-time-correctly.

The most common *accidental* leak is feature engineering done on the full
frame โ€” lags and rolling means computed once over train+test. These specs are
instead materialized through the feed's availability rules (see
:mod:`emflow.features.materialize`): a lag that would reach past the origin's
information set comes out NaN, never as a peeked value.

Four spec types (deliberately minimal):

* :class:`Lag` โ€” value of an actual field at ``target_time - lag``
* :class:`Rolling` โ€” trailing-window aggregate of an actual field's *available*
  history at the origin
* :class:`ForecastField` โ€” columns of the latest forecast run knowable at the
  origin, aligned to target times
* :class:`Calendar` โ€” deterministic calendar encodings of the target time
"""

from __future__ import annotations

from dataclasses import dataclass, field as _dc_field
import typing as t

import pandas as pd

CALENDAR_PARTS = ("hour", "dayofweek", "month", "dayofyear")


[docs] @dataclass(frozen=True) class Lag: """``field``'s value at ``target_time - lag``, if knowable at the origin.""" field: str lags: t.Tuple[str, ...] column: t.Optional[str] = None def __post_init__(self): lags = (self.lags,) if isinstance(self.lags, str) else tuple(self.lags) object.__setattr__(self, "lags", lags)
[docs] def names(self): return [f"{self.field}_lag_{lag}" for lag in self.lags]
[docs] @dataclass(frozen=True) class Rolling: """Trailing-window ``agg`` of ``field`` over the history available at the origin (same value for every target time of one origin).""" field: str window: str agg: str = "mean" column: t.Optional[str] = None
[docs] def names(self): return [f"{self.field}_roll_{self.window}_{self.agg}"]
[docs] @dataclass(frozen=True) class ForecastField: """Columns of the latest run of forecast field ``field`` knowable at the origin, looked up at each target time. ``interp="time"`` time-interpolates the run onto target times that fall between its native steps (e.g. hourly NWP onto half-hourly settlement periods). Interpolation only ever uses the already-knowable run โ€” it cannot leak.""" field: str columns: t.Tuple[str, ...] = () interp: t.Optional[str] = None def __post_init__(self): cols = (self.columns,) if isinstance(self.columns, str) else tuple(self.columns) object.__setattr__(self, "columns", cols)
[docs] def names(self): return [f"{self.field}_{c}" for c in self.columns]
[docs] @dataclass(frozen=True) class Calendar: """Calendar encodings of the target time (leak-free by construction).""" parts: t.Tuple[str, ...] = ("hour", "dayofweek") def __post_init__(self): parts = (self.parts,) if isinstance(self.parts, str) else tuple(self.parts) unknown = set(parts) - set(CALENDAR_PARTS) if unknown: raise ValueError(f"unknown calendar parts {sorted(unknown)}; " f"available: {CALENDAR_PARTS}") object.__setattr__(self, "parts", parts)
[docs] def names(self): return [f"cal_{p}" for p in self.parts]
FeatureSpec = t.Union[Lag, Rolling, ForecastField, Calendar]