본문으로 건너뛰기
라이브러리 문서 전체

해시 기반 실행 재사용 및 예측 범위 점검

코드 Machine Learning for Trading

요약

이 문서는 모델 학습과 백테스트를 다시 실행할지, 기존 결과를 재사용할지 결정하기 위한 레지스트리 점검을 설명합니다. 실행은 명세에서 도출한 해시로 식별하며, 예상 산출물이 모두 있을 때만 재사용할 수 있습니다. 빠진 항목이 있으면 실행을 부분 완료 상태로 표시해 재학습 또는 재실행을 시작하고, 명시적 강제 플래그는 디버깅을 지원합니다. 또한 예측 키를 표준화하고 요약하는 방법을 설명합니다. 버전이 관리되는 시간값 출력 규칙을 적용해 서로 다른 유형으로 불러온 동등한 날짜를 일관되게 비교할 수 있습니다.

핵심 주의사항은 ‘완전한’ 예측 범위가 선언된 전체 대상 집합이 아니라 모델 어댑터 자체의 예상 키를 기준으로 측정된다는 점입니다. 따라서 모델이 선택한 목표를 충족하면서도 대상 집합에 포함된 적격 구성원을 누락할 수 있습니다. 문서는 옵션 분석 사례의 예로 이 한계를 보여주며, 개수를 확인하고 더 넓은 대상 집합과 범위를 비교하라고 권합니다. 이러한 점검은 재현성을 높이고 불완전한 산출물의 무심코 한 재사용을 막지만, 판정 결과는 예상 키를 어떻게 정의하느냐에 달려 있습니다.

핵심 아이디어

  • 실행 해시를 사용하면 학습 또는 백테스트 명세가 바뀌지 않았을 때 워크플로가 산출물을 재사용할 수 있습니다.
  • 일부 산출물만 있는 실행은 조용히 재사용하지 말고 불완전한 상태로 보고 다시 생성해야 합니다.
  • 예측 범위 요약을 비교하려면 시간 키를 일관된 형식으로 출력해야 합니다.
  • 모델 자체의 예상 키를 기준으로 한 범위 점검만으로 선언된 전체 대상 집합이 포함됐다고 볼 수 없습니다.

태그

전문
# completeness.py


```py
"""Registry completeness checks and skip-if-exists logic.

Provides a single entry point for "should I train this config?" decisions
across all model families (linear, gbm, tabular_dl, deep_learning,
latent_factors, causal_dml) and for backtests.

Contract
--------
Every training notebook should guard each config:

    spec = build_training_spec(...)
    status = training_run_status(CASE_STUDY_ID, spec)
    if status.complete and not FORCE_RETRAIN:
        print(f"  {cfg['config_name']}: SKIP — {status.summary()}")
        continue
    if status.partial:
        print(f"  {cfg['config_name']}: RETRAIN — {status.summary()}")
    # ... train and register

Every backtest sweep should guard each variant:

    strategy_spec = build_backtest_spec(...)
    status = backtest_run_status(CASE_STUDY_ID, pred_hash, strategy_spec)
    if status.complete and not FORCE_REBACKTEST:
        print(f"  {variant_name}: SKIP — backtest already complete")
        continue
    # ... run backtest

Rationale
---------
Large sweeps (GBM on 9.2M-row us_equities_panel, nasdaq100 microstructure,
DL families) can take hours. Re-running from scratch after a correctness
fix, partial interruption, or added configs wastes compute. Re-running
training where the training_hash already has complete artifacts is pure
waste — the hash IS the identity. If the hash exists and has all expected
artifacts, the result is reproducible and can be reused.

The only legitimate reasons to retrain:
1. The fix or config change produces a NEW hash (handled automatically).
2. The existing artifacts are corrupt or partially written.
3. FORCE_RETRAIN=True (explicit opt-in for debugging).

Partial state handling
----------------------
If some artifacts exist but not all (e.g., training_runs row but no
predictions.parquet), report the partial state and retrain. We NEVER
silently reuse a partial state because the result would be misleading
(the ic_mean might exist while the predictions are gone).
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

from .specs import backtest_hash_from_parts, training_hash_from_spec
from .store import (
    _backtest_dir,
    _case_dir,
    _open_registry,
    _prediction_dir,
)


@dataclass(frozen=True)
class PredictionCoverage:
    """Exact expected-versus-actual prediction coverage evidence.

    ``expected`` is the family's own target. ``_prepare_expected_keys`` builds it from the
    fold inputs the adapter prepared, after the model's own eligibility filtering, so
    ``complete`` here means "this run produced what it set out to produce". It does not and
    cannot mean "this run covered the declared universe": the declared universe never enters
    the comparison. A sequence model that narrows its own target scores ``complete`` here and
    50.9% on :func:`notebook_contracts.measure_prediction_cross_sections`, from the same
    predictions - measured on ``sp500_equity_option_analytics``, where all 140 members ruled
    inadmissible carry a row here reading ``complete`` with ``n_missing = 0``.

    ``status`` is stored, but a stored row can only ever read ``complete``: registration
    raises on a partial evaluation and ``allow_partial`` defaults to False everywhere in
    production, so the non-complete evaluations are refused before the row is written.
    Readers should test the counts rather than the verdict, which is what
    :func:`notebook_contracts.incompletely_registered_predictions` does - though on the
    corpus as it stands that is a change of what is read and not of what can be seen. Every
    count and digest here is computed against the same ``expected_keys``, so they agree with
    ``status`` on all 6,480 rows today; they can disagree with it only on a row written by
    something that stores a shortfall instead of refusing it.
    """

    expected_key_digest: str
    actual_key_digest: str
    n_expected: int
    n_actual: int
    n_duplicates: int
    n_missing: int
    n_extra: int
    n_null: int
    n_non_finite: int
    n_folds_expected: int
    n_folds_actual: int
    schema_json: str
    status: str

    @property
    def complete(self) -> bool:
        return self.status == "complete"

    def as_dict(self) -> dict[str, str | int]:
        return {
            "expected_key_digest": self.expected_key_digest,
            "actual_key_digest": self.actual_key_digest,
            "n_expected": self.n_expected,
            "n_actual": self.n_actual,
            "n_duplicates": self.n_duplicates,
            "n_missing": self.n_missing,
            "n_extra": self.n_extra,
            "n_null": self.n_null,
            "n_non_finite": self.n_non_finite,
            "n_folds_expected": self.n_folds_expected,
            "n_folds_actual": self.n_folds_actual,
            "schema_json": self.schema_json,
            "status": self.status,
        }


# The rendering `_canonical_key_column` implements, stamped onto every digest it produces.
# `k1` is the rendering it implemented before it normalized temporal columns, under which a
# `Date` rendered `2016-01-29` and a `Datetime("ms")` of the same instant rendered
# `2016-01-29 00:00:00.000`, so the same key set digested two ways. Bump `KEY_DIGEST_RENDERING`
# whenever `_canonical_key_column` changes what it emits, and keep the older rendering
# reachable from `_canonical_key_column` so a digest that predates the stamp stays identifiable.
KEY_DIGEST_RENDERING = "k2"
LEGACY_KEY_DIGEST_RENDERING = "k1"


def _prediction_key_columns(frame) -> tuple[str, ...]:
    columns = set(frame.columns)
    entities = [name for name in ("symbol", "product") if name in columns]
    if len(entities) != 1:
        raise ValueError("prediction coverage requires exactly one of symbol or product")
    return (
        entities[0],
        *(("position",) if "position" in columns else ()),
        "timestamp",
        "fold_id",
    )


def _canonical_key_frame(
    frame,
    key_columns: tuple[str, ...] | None = None,
    *,
    rendering: str = KEY_DIGEST_RENDERING,
):
    import polars as pl

    if not isinstance(frame, pl.DataFrame):
        frame = pl.from_pandas(frame)
    if "fold" in frame.columns and "fold_id" not in frame.columns:
        frame = frame.rename({"fold": "fold_id"})
    if key_columns is None:
        key_columns = _prediction_key_columns(frame)
    required = set(key_columns)
    missing = required - set(frame.columns)
    if missing:
        raise ValueError(
            f"prediction coverage requires columns {sorted(required)}; missing {missing}"
        )
    return frame.select(
        *(_canonical_key_column(frame, name, rendering=rendering) for name in key_columns)
    )


def _canonical_key_column(frame, name: str, *, rendering: str = KEY_DIGEST_RENDERING):
    """One key column rendered so two frames from different paths can be joined on it.

    Casting a temporal column straight to String renders whatever dtype it happens to carry:
    a `Date` becomes `2016-01-29` and a `Datetime("ms")` of the same instant becomes
    `2016-01-29 00:00:00.000`. The two never join, so a registry frame meeting a dataset
    frame reports every expected row missing and every actual row extra - a 100% mismatch
    that reads as a data problem and is a dtype problem. Family runners never see it because
    both of their frames come from one source.

    Every temporal dtype is therefore normalized to microsecond UTC first, so the rendering
    is decided by this function rather than by which loader produced the frame.

    *rendering* selects which version of that decision to apply. `k1` is what this function
    did before it normalized, kept only so a stored digest that names no rendering can be
    identified by recomputing it both ways rather than assumed to be one of them.
    """
    import polars as pl

    if name == "fold_id":
        return pl.col(name).cast(pl.Int64)
    if rendering == LEGACY_KEY_DIGEST_RENDERING:
        return pl.col(name).cast(pl.String)
    dtype = frame.schema[name]
    if dtype == pl.Date or isinstance(dtype, pl.Datetime):
        return pl.col(name).cast(pl.Datetime("us")).cast(pl.String).alias(name)
    return pl.col(name).cast(pl.String)


def key_digest_value(digest: str) -> str:
    """*digest* without its rendering prefix: the part two digests are equal on."""
    _, sep, value = str(digest).partition(":")
    return value if sep else str(digest)


def key_digest_rendering(digest: str, *, expected_keys=None) -> str | None:
    """Which rendering produced *digest*, or ``None`` where that cannot be established.

    A digest carrying no prefix was written before the prefix existed, and that is all it
    says: measured across the fleet 2026-09-07, 4,303 of the 4,469 stored digests reproduce
    under the current rendering and 166 - all `us_equities_panel`'s - under `k1`, and every
    one of them is stored bare. Reading a bare digest as `k1` would therefore have declared
    a rendering change on 4,303 rows where there was none, and refuse the next checkpoint
    registered against any of them.

    Pass *expected_keys* to settle it by measurement instead: the key frame is digested
    under each known rendering and the one that reproduces *digest* is the one that produced
    it. Where neither does, the frames hold different keys, which is a different question,
    and this returns ``None`` rather than guessing.
    """
    prefix, sep, _ = str(digest).partition(":")
    if sep:
        return prefix
    if expected_keys is None:
        return None
    for rendering in (KEY_DIGEST_RENDERING, LEGACY_KEY_DIGEST_RENDERING):
        if key_digest_value(coverage_key_digest(expected_keys, rendering=rendering)) == str(digest):
            return rendering
    return None


def coverage_key_digest(expected_keys, *, rendering: str = KEY_DIGEST_RENDERING) -> str:
    """The coverage key digest *expected_keys* takes under *rendering*."""
    frame = _canonical_key_frame(expected_keys, rendering=rendering)
    return _key_digest(frame, tuple(frame.columns), rendering=rendering)


def require_comparable_key_digests(digests, *, what: str, expected_keys=None) -> None:
    """Refuse to treat digests from different renderings as comparable.

    Two digests taken under different renderings are unequal whatever their key sets, and
    nothing about the inequality says so - which is how a rendering change turns into a
    silent mis-grouping rather than an error. A consumer that
    groups predictions by their eligibility digest reports more distinct contracts than
    exist and tells a reader that two checkpoints scored on identical rows are not
    comparable.

    Measured when #1065 was filed: grouping each registry by the stored digest against one
    uniform rendering split one set in sp500_options, one in us_firm_characteristics and two
    in cme_futures, and nothing raised - the guard at the consumer only rejects a group whose
    members disagree on `n_expected`, `n_actual` or `n_folds`, and the split halves agree on
    all three.

    Only renderings that can be established are compared. A digest that names none and that
    *expected_keys* cannot identify makes no claim, and refusing on it would refuse the
    whole fleet as written today.
    """
    renderings = sorted(
        {
            rendering
            for digest in digests
            if digest
            for rendering in (key_digest_rendering(str(digest), expected_keys=expected_keys),)
            if rendering is not None
        }
    )
    if len(renderings) > 1:
        raise ValueError(
            f"{what} spans coverage-key renderings {renderings}, so its digests cannot be "
            "compared: two digests taken under different renderings are unequal whatever "
            "their key sets. Re-register the rows written under the older rendering, or "
            "compare within one rendering."
        )


def _key_digest(frame, key_columns: tuple[str, ...], rendering: str = KEY_DIGEST_RENDERING) -> str:
    from case_studies.utils.artifact_digest import value_digest

    return f"{rendering}:{value_digest(frame, key_columns)}"


def evaluate_prediction_coverage(expected_keys, predictions) -> PredictionCoverage:
    """Compare exact prediction keys and finite scores without mutating storage."""
    import polars as pl

    expected = _canonical_key_frame(expected_keys)
    key_columns = tuple(expected.columns)
    actual = _canonical_key_frame(predictions, key_columns)
    if expected.n_unique(key_columns) != expected.height:
        raise ValueError("expected prediction coverage keys must be unique")

    unique_actual = actual.unique(key_columns)
    n_duplicates = actual.height - unique_actual.height
    n_missing = expected.join(unique_actual, on=key_columns, how="anti").height
    n_extra = unique_actual.join(expected, on=key_columns, how="anti").height

    if not isinstance(predictions, pl.DataFrame):
        predictions = pl.from_pandas(predictions)
    score_col = "y_score" if "y_score" in predictions.columns else "prediction"
    if score_col not in predictions.columns:
        raise ValueError("prediction coverage requires y_score or prediction")
    score = predictions.get_column(score_col).cast(pl.Float64, strict=False)
    n_null = score.null_count()
    n_non_finite = (score.is_not_null() & ~score.is_finite()).sum()
    expected_digest = _key_digest(expected, key_columns)
    actual_digest = _key_digest(unique_actual, key_columns)
    complete = not any((n_duplicates, n_missing, n_extra, n_null, n_non_finite)) and (
        expected_digest == actual_digest
    )
    return PredictionCoverage(
        expected_key_digest=expected_digest,
        actual_key_digest=actual_digest,
        n_expected=expected.height,
        n_actual=actual.height,
        n_duplicates=n_duplicates,
        n_missing=n_missing,
        n_extra=n_extra,
        n_null=n_null,
        n_non_finite=int(n_non_finite),
        n_folds_expected=expected.get_column("fold_id").n_unique(),
        n_folds_actual=actual.get_column("fold_id").n_unique(),
        schema_json=json.dumps(
            {name: str(dtype) for name, dtype in predictions.schema.items()},
            sort_keys=True,
            separators=(",", ":"),
        ),
        status="complete" if complete else "partial",
    )


# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TrainingRunStatus:
    """Completeness status of a training run in the registry.

    Fields
    ------
    training_hash : str
        Canonical identity hash from the spec.
    exists : bool
        True if the training_runs row exists.
    has_predictions : bool
        True if at least one prediction_sets row exists.
    has_predictions_file : bool
        True if at least one predictions.parquet file exists on disk.
    has_metrics : bool
        True if the prediction has an ic_mean value.
    complete : bool
        True if all required artifacts are present.
    partial : bool
        True if the run exists but some artifacts are missing.
    missing : tuple[str, ...]
        Names of missing artifacts.
    """

    training_hash: str
    exists: bool
    has_predictions: bool
    has_predictions_file: bool
    has_metrics: bool
    missing: tuple[str, ...] = ()

    @property
    def complete(self) -> bool:
        return not self.missing and self.exists

    @property
    def partial(self) -> bool:
        return self.exists and bool(self.missing)

    def summary(self) -> str:
        if not self.exists:
            return f"no training_run for hash {self.training_hash[:12]}"
        if self.complete:
            return f"complete (hash={self.training_hash[:12]})"
        return f"partial (hash={self.training_hash[:12]}, missing: {', '.join(self.missing)})"


@dataclass(frozen=True)
class BacktestRunStatus:
    """Completeness status of a backtest run in the registry."""

    backtest_hash: str
    exists: bool
    has_returns: bool
    has_metrics: bool
    missing: tuple[str, ...] = ()

    @property
    def complete(self) -> bool:
        return not self.missing and self.exists

    @property
    def partial(self) -> bool:
        return self.exists and bool(self.missing)

    def summary(self) -> str:
        if not self.exists:
            return f"no backtest_run for hash {self.backtest_hash[:12]}"
        if self.complete:
            return f"complete (hash={self.backtest_hash[:12]})"
        return f"partial (hash={self.backtest_hash[:12]}, missing: {', '.join(self.missing)})"


# ---------------------------------------------------------------------------
# Training run completeness
# ---------------------------------------------------------------------------


def training_run_status(
    case_study: str,
    spec: dict,
    *,
    require_metrics: bool = True,
    require_predictions_file: bool = True,
    case_dir: Path | None = None,
) -> TrainingRunStatus:
    """Inspect the registry for a training run matching the given spec.

    Parameters
    ----------
    case_study : str
        Case study id.
    spec : dict
        Complete training spec (same structure build_training_spec produces).
    require_metrics : bool
        Whether ic_mean must be non-NULL for the run to count as complete.
        Default True. Causal DML runs are tracked in `causal_runs`, not
        through this path.
    require_predictions_file : bool
        Whether predictions.parquet must exist on disk. Default True.
    case_dir : Path, optional
        Override case study directory.

    Returns
    -------
    TrainingRunStatus
    """
    if case_dir is None:
        case_dir = _case_dir(case_study)

    t_hash = training_hash_from_spec(spec)

    db = _open_registry(case_dir)
    try:
        row = db.execute(
            "SELECT training_hash FROM training_runs WHERE training_hash = ?",
            (t_hash,),
        ).fetchone()
        exists = row is not None
        if not exists:
            return TrainingRunStatus(
                training_hash=t_hash,
                exists=False,
                has_predictions=False,
                has_predictions_file=False,
                has_metrics=False,
                missing=("training_run",),
            )

        # Prediction sets
        pred_hashes = [
            r[0]
            for r in db.execute(
                "SELECT prediction_hash FROM prediction_sets WHERE training_hash = ?",
                (t_hash,),
            ).fetchall()
        ]
        has_predictions = len(pred_hashes) > 0

        # Metrics on the prediction(s)
        has_metrics = False
        if has_predictions:
            # Get any prediction with non-null ic_mean
            q = (
                f"SELECT prediction_hash FROM prediction_metrics "
                f"WHERE prediction_hash IN ({','.join('?' * len(pred_hashes))}) "
                f"AND ic_mean IS NOT NULL"
            )
            m_rows = db.execute(q, tuple(pred_hashes)).fetchall()
            has_metrics = len(m_rows) > 0
    finally:
        db.close()

    # Check predictions.parquet files on disk
    has_predictions_file = False
    if has_predictions:
        for ph in pred_hashes:
            f = _prediction_dir(case_dir, ph) / "predictions.parquet"
            if f.exists():
                has_predictions_file = True
                break

    missing = []
    if not has_predictions:
        missing.append("prediction_sets")
    if require_predictions_file and not has_predictions_file:
        missing.append("predictions.parquet")
    if require_metrics and not has_metrics:
        missing.append("ic_mean")

    return TrainingRunStatus(
        training_hash=t_hash,
        exists=exists,
        has_predictions=has_predictions,
        has_predictions_file=has_predictions_file,
        has_metrics=has_metrics,
        missing=tuple(missing),
    )


def skip_training_if_complete(
    case_study: str,
    spec: dict,
    *,
    force_retrain: bool = False,
    verbose: bool = True,
    **kwargs,
) -> TrainingRunStatus:
    """Convenience wrapper for the "should I train?" decision.

    Returns the status. Caller should check ``status.complete`` and
    ``force_retrain`` to decide whether to skip.

    When ``verbose=True``, prints a one-line status for partial/complete runs
    so interactive runs get visible feedback.

    Example
    -------
        status = skip_training_if_complete(CASE_STUDY_ID, spec,
                                          force_retrain=FORCE_RETRAIN)
        if status.complete and not FORCE_RETRAIN:
            print(f"  {cfg_name}: SKIP ({status.summary()})")
            continue
    """
    status = training_run_status(case_study, spec, **kwargs)
    if verbose:
        if status.complete and not force_retrain:
            return status  # caller prints
        if status.partial:
            print(f"  WARNING: partial run detected, will retrain: {status.summary()}")
    return status


# ---------------------------------------------------------------------------
# Backtest run completeness
# ---------------------------------------------------------------------------


def backtest_run_status(
    case_study: str,
    prediction_hash: str,
    strategy_spec: dict,
    *,
    require_metrics: bool = True,
    require_returns_file: bool = True,
    case_dir: Path | None = None,
) -> BacktestRunStatus:
    """Inspect the registry for a backtest run matching prediction_hash + strategy_spec."""
    if case_dir is None:
        case_dir = _case_dir(case_study)

    b_hash = backtest_hash_from_parts(prediction_hash, strategy_spec)

    db = _open_registry(case_dir)
    try:
        row = db.execute(
            "SELECT backtest_hash FROM backtest_runs WHERE backtest_hash = ?",
            (b_hash,),
        ).fetchone()
        exists = row is not None
        if not exists:
            return BacktestRunStatus(
                backtest_hash=b_hash,
                exists=False,
                has_returns=False,
                has_metrics=False,
                missing=("backtest_run",),
            )

        has_metrics = False
        if require_metrics:
            # A NULL sharpe means "no metric pass has run here" everywhere except one
            # case: a path the engine stopped at ruin registers every ranking metric as
            # NULL on purpose, so that nothing sorts a bankrupt account against a solvent
            # one. Reading that as incomplete would re-run the
            # backtest on every pass and never converge, so `ruin` is what separates
            # them. `ruin` is absent from a registry written before that column existed,
            # in which case a NULL sharpe still means unmeasured.
            has_ruin = "ruin" in {
                row[1] for row in db.execute("PRAGMA table_info(backtest_metrics)").fetchall()
            }
            measured = "sharpe IS NOT NULL OR ruin = 1.0" if has_ruin else "sharpe IS NOT NULL"
            m_row = db.execute(
                f"SELECT sharpe FROM backtest_metrics WHERE backtest_hash = ? AND ({measured})",
                (b_hash,),
            ).fetchone()
            has_metrics = m_row is not None
    finally:
        db.close()

    # Check returns.parquet on disk
    has_returns = (_backtest_dir(case_dir, b_hash) / "daily_returns.parquet").exists()

    missing = []
    if require_returns_file and not has_returns:
        missing.append("daily_returns.parquet")
    if require_metrics and not has_metrics:
        missing.append("sharpe")

    return BacktestRunStatus(
        backtest_hash=b_hash,
        exists=exists,
        has_returns=has_returns,
        has_metrics=has_metrics,
        missing=tuple(missing),
    )


def skip_backtest_if_complete(
    case_study: str,
    prediction_hash: str,
    strategy_spec: dict,
    *,
    force_rebacktest: bool = False,
    verbose: bool = True,
    **kwargs,
) -> BacktestRunStatus:
    """Convenience wrapper for the "should I backtest?" decision.

    Example
    -------
        status = skip_backtest_if_complete(CASE_STUDY_ID, pred_hash, spec,
                                          force_rebacktest=FORCE_REBACKTEST)
        if status.complete and not FORCE_REBACKTEST:
            print(f"  {variant_name}: SKIP ({status.summary()})")
            continue
    """
    status = backtest_run_status(case_study, prediction_hash, strategy_spec, **kwargs)
    if verbose:
        if status.partial:
            print(f"  WARNING: partial backtest detected, will re-run: {status.summary()}")
    return status


__all__ = [
    "TrainingRunStatus",
    "BacktestRunStatus",
    "training_run_status",
    "skip_training_if_complete",
    "backtest_run_status",
    "skip_backtest_if_complete",
]

```

출처의 라이선스에 따라 출처를 표시하고 전문을 공개합니다. 라이선스: MIT

이 요약은 원문을 바탕으로 Stratmill의 리서치 에이전트가 작성했으며, 원문을 복사한 것이 아닙니다.