Source code for emflow.run.analyzers

"""Analyzers: observers of a run, fed each settlement as it lands.

The backtrader idea โ€” one evaluation loop, many pluggable observers. Analyzers
never influence the run; they accumulate diagnostics and report in
``Result.analysis``.
"""

from __future__ import annotations

from abc import ABC
import typing as t

import numpy as np
import pandas as pd

from ..problems.metrics import quantile_columns


[docs] class Analyzer(ABC): """Base analyzer. ``setup`` receives the run context; ``on_settlement`` each scored origin; ``finalize`` returns the diagnostics dict.""" @property def name(self) -> str: return type(self).__name__
[docs] def setup(self, feed, target_field, target_column, objective) -> None: self.feed = feed self.target_field = target_field self.target_column = target_column self.objective = objective
[docs] def on_settlement(self, record) -> None: # pragma: no cover - interface pass
[docs] def finalize(self) -> dict: # pragma: no cover - interface return {}
[docs] class PersistenceSkill(Analyzer): """Scores a persistence baseline (last knowable value at each origin, broadcast over its targets) and reports the model's skill against it. A model that can't beat this hasn't learned anything.""" def __init__(self): self._baseline_scores: t.List[float] = [] self._model_scores: t.List[float] = [] self._weights: t.List[int] = [] self._disabled = False
[docs] def setup(self, feed, target_field, target_column, objective) -> None: super().setup(feed, target_field, target_column, objective) # Env-settled objectives (trading revenue) can't score a synthetic # baseline from (actuals, prediction) alone โ€” opt out cleanly. self._disabled = getattr(objective.metric, "settled_by_env", False)
[docs] def on_settlement(self, record) -> None: if self._disabled: return hist = self.feed.history(record.origin.asof, self.target_field) col = hist[record.origin.column or self.target_column].dropna() if col.empty: return # Match the submission's output shape: for quantile predictions the # baseline is the point-mass persistence (last value at every level). levels = quantile_columns(record.prediction) or ["point"] baseline = pd.DataFrame({lvl: col.iloc[-1] for lvl in levels}, index=record.origin.target_index) n = int((record.actuals.notna()).sum()) if n == 0: return self._baseline_scores.append(self.objective.calculate(record.actuals, baseline)) self._model_scores.append(record.score) self._weights.append(n)
[docs] def finalize(self) -> dict: if not self._weights: return {} w = np.asarray(self._weights, dtype=float) base = float(np.average(self._baseline_scores, weights=w)) model = float(np.average(self._model_scores, weights=w)) beats = self.objective.is_better(model, base) out = {"persistence_score": base, "model_score": model, "beats_persistence": bool(beats)} if base != 0: out["skill"] = float(1.0 - model / base) if self.objective.lower_is_better \ else float(model / base - 1.0) return out
[docs] class QuantileCalibration(Analyzer): """Empirical coverage per predicted quantile. A calibrated q10 should have ~10% of actuals below it; large gaps mean the intervals are lying.""" def __init__(self): self._below: t.Dict[float, int] = {} self._total: t.Dict[float, int] = {}
[docs] def on_settlement(self, record) -> None: levels = quantile_columns(record.prediction) if not levels: return y = record.actuals for q in levels: pred = record.prediction[q].reindex(y.index) ok = y.notna() & pred.notna() self._below[q] = self._below.get(q, 0) + int((y[ok] <= pred[ok]).sum()) self._total[q] = self._total.get(q, 0) + int(ok.sum())
[docs] def finalize(self) -> dict: out = {} for q in sorted(self._total): if self._total[q]: out[f"coverage_q{int(round(q * 100)):02d}"] = self._below[q] / self._total[q] return out
[docs] def default_analyzers(model=None) -> t.List[Analyzer]: analyzers: t.List[Analyzer] = [PersistenceSkill()] if model is None or getattr(model, "output_kind", "point") == "quantiles": analyzers.append(QuantileCalibration()) return analyzers