Source code for numeraire.core.schema
"""The standard tidy, long-format result schema.
Every evaluator emits rows in this schema; downstream plotting (plotnine / R) consumes it,
so the plotting choice stays decoupled. Stability is promised on this schema (semver).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
RESULT_COLUMNS: tuple[str, ...] = (
"run_id",
"method",
"date",
"metric",
"value",
"universe",
"capability",
"protocol",
"config_hash",
"data_vintage",
)
"""Minimum columns every result table must carry (in any order).
``protocol`` labels the evaluation discipline the row was produced under: ``"walk_forward"`` (the
framework's out-of-sample walk-forward path, which every weights/forecast evaluator emits) or
``"in_sample"`` (a single full-sample fit, the paper cross-sectional-pricing tradition). It makes an
explanatory in-sample number unconfusable with an out-of-sample one.
"""
ATTRITION_COLUMNS: tuple[str, ...] = ("n_obs", "n_dropped")
"""Optional, schema-additive attrition columns.
Evaluators that compare a model against a benchmark or a realized target (out-of-sample R^2,
squared-error difference, Clark-West, the cross-sectional pricing metrics) attach ``n_obs`` — the
size of the joint finite sample the metric was computed on — and ``n_dropped`` — the count of
candidate observations excluded by that joint mask. They make selective missingness auditable on the
row itself. They are *optional*: rows from evaluators without a benchmark comparison omit them, and
:func:`validate_result` never requires them (only that every non-null cell is a finite,
non-negative, integer-valued count).
"""
[docs]
def validate_result(df: pd.DataFrame) -> None:
"""Raise ``ValueError`` if ``df`` violates the result schema.
Enforces that every column in :data:`RESULT_COLUMNS` is present; extra columns are allowed. When
the optional :data:`ATTRITION_COLUMNS` are present, every non-null cell must be a finite,
non-negative, integer-valued numeric (a count); non-numeric cells are rejected rather than
coerced away. The columns themselves are never required.
"""
present = {str(c) for c in df.columns}
missing = [c for c in RESULT_COLUMNS if c not in present]
if missing:
raise ValueError(f"result table missing required columns: {missing}")
for col in ATTRITION_COLUMNS:
if col in present:
# NaN is a legitimate "not applicable" for a row from an evaluator that emits no
# attrition (e.g. concatenated with benchmark-comparison rows); every other cell must
# be a genuine count.
raw = df[col]
numeric = pd.to_numeric(raw, errors="coerce")
if bool((numeric.isna() & raw.notna()).any()):
raise ValueError(f"result column {col!r} holds non-numeric values")
counts = numeric.dropna().to_numpy(dtype=np.float64)
if counts.size and (
not bool(np.isfinite(counts).all())
or bool((counts < 0).any())
or bool((counts != np.floor(counts)).any())
):
raise ValueError(
f"result column {col!r} must hold finite non-negative integer counts"
)