Source code for emflow.features.materialize

"""Point-in-time-correct feature materialization.

Two paths that must (and are tested to) agree:

* :func:`materialize` โ€” batch: one matrix for many origins at once, indexed by
  ``(asof, target_time)``. This is what makes the vectorized execution mode
  fast: all origins in one shot, masked by availability.
* :func:`materialize_observation` โ€” one origin, built *purely from the
  observation's view API* (which already enforces availability). Used by
  :class:`~emflow.models.predictor.FeaturePredictor` in event mode.

Both produce NaN wherever a feature would need data outside the origin's
information set โ€” never a peeked value.
"""

from __future__ import annotations

import typing as t

import numpy as np
import pandas as pd

from ..data import DataFeed, TimeView
from .spec import Calendar, FeatureSpec, ForecastField, Lag, Rolling

INDEX_NAMES = ("asof", "target_time")


def _column(frame: pd.DataFrame, column: t.Optional[str], what: str) -> pd.Series:
    if column is not None:
        return frame[column]
    if frame.shape[1] != 1:
        raise ValueError(f"{what}: field has columns {list(frame.columns)}; specify column=")
    return frame.iloc[:, 0]


def _calendar_values(part: str, times: pd.DatetimeIndex) -> np.ndarray:
    return getattr(times, part).to_numpy(dtype=float)


def _align_forecast(fc: pd.DataFrame, col: str, targets: pd.DatetimeIndex,
                    interp: t.Optional[str]) -> np.ndarray:
    """Align one forecast column onto target times, optionally interpolating
    between the run's native steps (uses only the knowable run โ€” no leak)."""
    series = fc[col]
    if interp is None:
        return series.reindex(targets).to_numpy(dtype=float)
    union = series.index.union(targets)
    return (series.reindex(union).interpolate(method=interp, limit_area="inside")
            .reindex(targets).to_numpy(dtype=float))


# -- batch path -------------------------------------------------------------------


[docs] def materialize(feed: DataFeed, features: t.Sequence[FeatureSpec], origins) -> pd.DataFrame: """Feature matrix for all ``origins``, indexed by ``(asof, target_time)``.""" asofs, targets, origin_slices = [], [], [] pos = 0 for o in origins: n = len(o.target_index) asofs.append(pd.DatetimeIndex([o.asof] * n)) targets.append(o.target_index) origin_slices.append((o, slice(pos, pos + n))) pos += n asof_arr = asofs[0].append(asofs[1:]) if len(asofs) > 1 else asofs[0] target_arr = targets[0].append(targets[1:]) if len(targets) > 1 else targets[0] index = pd.MultiIndex.from_arrays([asof_arr, target_arr], names=INDEX_NAMES) cols: t.Dict[str, np.ndarray] = {} for spec in features: if isinstance(spec, Lag): field = feed.dataset.field(spec.field) series = _column(field.frame, spec.column, f"Lag({spec.field!r})") for name, lag in zip(spec.names(), spec.lags): lag_td = pd.Timedelta(lag) lookup = target_arr - lag_td vals = series.reindex(lookup).to_numpy(dtype=float, copy=True) knowable = (lookup + field.availability_lag) <= asof_arr vals[~np.asarray(knowable)] = np.nan cols[name] = vals elif isinstance(spec, Rolling): field = feed.dataset.field(spec.field) series = _column(field.frame, spec.column, f"Rolling({spec.field!r})") rolled = series.rolling(spec.window).agg(spec.agg).dropna() cutoffs = asof_arr - field.availability_lag if rolled.empty: vals = np.full(len(index), np.nan) else: vals = rolled.reindex(cutoffs, method="ffill").to_numpy(dtype=float) cols[spec.names()[0]] = vals elif isinstance(spec, ForecastField): blocks = {name: np.full(len(index), np.nan) for name in spec.names()} for origin, sl in origin_slices: fc = feed.forecasts(origin.asof, spec.field, columns=list(spec.columns) or None) if fc.empty: continue for name, col in zip(spec.names(), spec.columns): blocks[name][sl] = _align_forecast(fc, col, origin.target_index, spec.interp) cols.update(blocks) elif isinstance(spec, Calendar): for name, part in zip(spec.names(), spec.parts): cols[name] = _calendar_values(part, target_arr) else: raise TypeError(f"unknown feature spec {type(spec).__name__}") return pd.DataFrame(cols, index=index)
# -- single-origin path (built on the view API only) ---------------------------------
[docs] def materialize_observation(view: TimeView, features: t.Sequence[FeatureSpec], target_index: pd.DatetimeIndex) -> pd.DataFrame: """Feature matrix for one origin, using only what the view serves. The view's availability enforcement *is* the masking: a lag reaching past the information set simply isn't in ``view.history()`` and reindexes to NaN. Index matches :func:`materialize`'s rows for the same origin. """ index = pd.MultiIndex.from_arrays( [pd.DatetimeIndex([view.asof] * len(target_index)), target_index], names=INDEX_NAMES, ) cols: t.Dict[str, np.ndarray] = {} for spec in features: if isinstance(spec, Lag): hist = view.history(spec.field) series = _column(hist, spec.column, f"Lag({spec.field!r})") for name, lag in zip(spec.names(), spec.lags): lookup = target_index - pd.Timedelta(lag) cols[name] = series.reindex(lookup).to_numpy(dtype=float) elif isinstance(spec, Rolling): hist = view.history(spec.field, window=None) series = _column(hist, spec.column, f"Rolling({spec.field!r})") rolled = series.rolling(spec.window).agg(spec.agg).dropna() val = rolled.iloc[-1] if len(rolled) else np.nan cols[spec.names()[0]] = np.full(len(index), float(val)) elif isinstance(spec, ForecastField): fc = view.forecasts(spec.field, columns=list(spec.columns) or None) for name, col in zip(spec.names(), spec.columns): cols[name] = (np.full(len(index), np.nan) if fc.empty else _align_forecast(fc, col, target_index, spec.interp)) elif isinstance(spec, Calendar): for name, part in zip(spec.names(), spec.parts): cols[name] = _calendar_values(part, target_index) else: raise TypeError(f"unknown feature spec {type(spec).__name__}") return pd.DataFrame(cols, index=index)
[docs] def supervised_frame(feed: DataFeed, features: t.Sequence[FeatureSpec], origins, target_field: str, target_column=None): """``(X, y)`` for supervised training over *training-period* origins. ``y`` reads actuals at target times directly โ€” only ever call this on origins whose targets lie in the training split. Evaluation goes through the environment, which cannot leak. """ X = materialize(feed, features, origins) field = feed.dataset.field(target_field) series = _column(field.frame, target_column, f"target {target_field!r}") y = pd.Series(series.reindex(X.index.get_level_values("target_time")).to_numpy(), index=X.index, name=series.name) return X, y