Source code for emflow.envs.forecast

"""ForecastEnv: rolling-origin forecasting as a real gymnasium environment.

The action *is* the forecast: a DataFrame indexed by the current origin's
target times, with a ``"point"`` column or quantile-level float columns. The
observation is a :class:`Observation` โ€” a :class:`~emflow.data.feed.TimeView`
frozen at the origin's ``asof`` plus the target index to forecast. The model
never touches the dataset; the observation is its whole world.

Rewards are **delayed to settlement**, as in reality: submitting a day-ahead
forecast tells you nothing โ€” the score arrives once the actuals do. At each
step the env settles every previously submitted origin whose targets have
become knowable (per the target field's availability rule) and pays their
scores as the reward (negated when the objective is lower-is-better, so more
reward is always better). ``info["settled"]`` carries the full
:class:`SettlementRecord` s for analyzers.
"""

from __future__ import annotations

from dataclasses import dataclass
import typing as t

import gymnasium as gym
import pandas as pd

from ..data import DataFeed, TimeView
from ..data.field import Field
from ..problems.metrics import POINT_COL, quantile_columns
from ..problems.objective import Objective
from ..problems.schedule import Origin


[docs] class Observation(TimeView): """What a model sees at one forecast origin: point-in-time data access (via :class:`TimeView`) plus the target index it must forecast. ``column`` names the target-field column this origin is scoped to (multi-zone problems); None means the env's default.""" def __init__(self, feed: DataFeed, origin: Origin): super().__init__(feed, origin.asof) self.target_index = origin.target_index self.column = origin.column def __repr__(self): return (f"Observation(asof={self.asof}, targets={self.target_index[0]} โ†’ " f"{self.target_index[-1]}, n={len(self.target_index)})")
[docs] @dataclass class SettlementRecord: """One origin scored against its (now available) actuals.""" origin: Origin prediction: pd.DataFrame actuals: pd.Series score: float
[docs] class ForecastEnv(gym.Env): """Rolling-origin forecast evaluation over a fixed list of origins. Parameters ---------- feed: The point-in-time data gate. origins: The origins to step through (from ``problem.origins(split)``). target_field: Name of the actual field being forecast. objective: Scores each settled origin (prediction vs actuals). target_column: Column of the target field to score (default: its only column). train_end: Clock time of the training view served in ``reset()``'s ``info["train"]``. quantiles: If set, actions must provide exactly these quantile columns; if None, actions must provide a ``"point"`` column. """ metadata = {"render_modes": []} def __init__(self, feed: DataFeed, origins: t.Sequence[Origin], target_field: str, objective: Objective, *, target_column=None, train_end=None, quantiles: t.Optional[t.Sequence[float]] = None): if not origins: raise ValueError("ForecastEnv needs at least one origin") self.feed = feed self.origins = list(origins) self.target_field = target_field self.objective = objective self.quantiles = [float(q) for q in quantiles] if quantiles is not None else None self.train_end = pd.Timestamp(train_end) if train_end is not None else None field = feed.dataset.field(target_field) if target_column is None and not all(o.column for o in origins): value_cols = [c for c in field.frame.columns if c != field.knowledge_col] if len(value_cols) != 1: raise ValueError( f"target field {target_field!r} has columns {value_cols}; " f"pass target_column or give every origin a column" ) target_column = value_cols[0] self.target_column = target_column self._target_lag = field.settlement_lag # Everything a settlement needs must be knowable: subclasses that pull # extra fields (e.g. market prices) raise this to the max of the lags. self._settle_lag = field.settlement_lag self._i = 0 self._pending: t.List[t.Tuple[Origin, pd.DataFrame]] = [] # -- gym API ------------------------------------------------------------------
[docs] def reset(self, *, seed=None, options=None): super().reset(seed=seed) self._i = 0 self._pending = [] info = {} if self.train_end is not None: # Strict boundary: a value stamped exactly train_end is a scored # target of the first evaluation origin, never training data. info["train"] = self.feed.view(self.train_end, strict=True) return Observation(self.feed, self.origins[0]), info
[docs] def step(self, action): origin = self.origins[self._i] self._pending.append((origin, self._validate(action, origin))) self._i += 1 terminated = self._i >= len(self.origins) if terminated: # Final flush: the run is over, settle everything that will ever # be knowable (clock = last target's availability time). last_end = max(o.target_end for o, _ in self._pending) settle_asof = last_end + self._settle_lag obs = None else: settle_asof = self.origins[self._i].asof obs = Observation(self.feed, self.origins[self._i]) settled = self._settle(settle_asof) reward = sum(-r.score if self.objective.lower_is_better else r.score for r in settled if pd.notna(r.score)) info = {"settled": settled} return obs, float(reward), terminated, False, info
# -- internals ----------------------------------------------------------------- def _validate(self, action, origin: Origin) -> pd.DataFrame: if not isinstance(action, pd.DataFrame): raise TypeError( f"action must be a DataFrame indexed by target time, got " f"{type(action).__name__}" ) missing = origin.target_index.difference(action.index) if len(missing): raise ValueError( f"action is missing forecasts for {len(missing)} target times " f"(first: {missing[0]})" ) action = action.reindex(origin.target_index) if self.quantiles is not None: have = set(quantile_columns(action)) need = set(self.quantiles) if not need.issubset(have): raise ValueError( f"action must provide quantile columns {sorted(need)}, " f"got {sorted(have) or list(action.columns)}" ) elif POINT_COL not in action.columns: if action.shape[1] == 1: action = action.set_axis([POINT_COL], axis=1) else: raise ValueError( f"action must have a {POINT_COL!r} column, got {list(action.columns)}" ) return action def _settle(self, asof) -> t.List[SettlementRecord]: """Score every pending origin whose full target window is knowable at ``asof``; keep the rest pending.""" settled, still_pending = [], [] for origin, prediction in self._pending: if origin.target_end + self._settle_lag <= asof: actuals = self.feed.actuals_between( self.target_field, origin.target_start, origin.target_end, asof=asof, )[origin.column or self.target_column].reindex(origin.target_index) score = self._score(origin, prediction, actuals, asof) settled.append(SettlementRecord(origin, prediction, actuals, score)) else: still_pending.append((origin, prediction)) self._pending = still_pending return settled def _score(self, origin: Origin, prediction: pd.DataFrame, actuals: pd.Series, asof) -> float: """Score one settled origin. Subclasses may use market state at ``asof`` (always availability-checked through the feed).""" return self.objective.calculate(actuals, prediction)