Source code for phenotypic.tune._study_store

"""The trial journal — Phase-1 homegrown persistence (Optuna SQLite is Phase 2).

A ``JournalStudyStore`` accumulates ``Trial`` records, reports the ``best``
(**min** cost among non-failed trials), and round-trips through ``trials.parquet``
(params and terms persisted as JSON columns — lossless across heterogeneous/
conditional param sets). Reloading a store powers CLI resume (``_engine``
fast-forwards a deterministic strategy past the recorded trials). It is the
concrete Phase-1 implementation of the ``StudyStore`` Protocol
(``_study/_protocol.py``); ``StudyStore`` remains an exported back-compat alias
for the concrete journal.
"""
from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any, Optional

import pandas as pd
from pydantic import BaseModel, ConfigDict


[docs] class Trial(BaseModel): """One evaluated candidate: its params, score, per-term scores, and status. Args: number: The zero-based trial index in journaling order. params: The sampled combo (``{root-relative-key: value}``). score: The finalized scalar objective **cost** the optimizer minimizes (lower = better). For a multi-objective trial this is the scalar projection of ``objectives`` (``mean(objectives.values())``). terms: The robust-aggregated per-term costs backing ``score``. n_images: Number of calibration images evaluated. objectives: The named multi-objective values (plan §0a sidecar), or ``None`` for a single-objective trial. Carried from ``EvaluationResult.objectives``; persisted as the ``objectives_json`` journal column. ``None`` for every legacy (pre-sidecar) trial. failed: ``True`` when the candidate raised and scored the failure floor. pruned: ``True`` when the rung ladder early-stopped this candidate. Distinct from ``failed``: pruned trials ran cleanly on a partial set and still count against the budget (failed trials do not). gap: The trial's relative across-plate dispersion of the primary term — a cheap instability / overfit-risk flag carried from ``EvaluationResult.gap``, persisted as the nullable-float ``gap`` journal column. **Not** a held-out generalization gap. ``None`` when the signal was unavailable (and for every legacy pre-4.5p1 trial). suspicious: ``True`` when the trial matched the qc §5 under-detection gaming signature; carried from ``EvaluationResult.suspicious`` and persisted as the ``suspicious`` bool journal column. ``False`` for every legacy (pre-4.5p1) trial. """ model_config = ConfigDict(frozen=True) number: int params: dict[str, Any] score: float terms: dict[str, float] n_images: int objectives: Optional[dict[str, float]] = None failed: bool = False pruned: bool = False gap: Optional[float] = None suspicious: bool = False
[docs] class JournalStudyStore: """An append-only journal of trials with best-tracking + parquet I/O. The Phase-1 concrete :class:`~phenotypic.tune._study._protocol.StudyStore` backend. It resumes by **replay** (the engine fast-forwards the deterministic strategy past the recorded trials), so :meth:`is_resumable_in_place` is ``False`` — distinguishing it from a future Optuna ``RDBStorage`` backend whose own storage reconstructs the sampler state. """
[docs] def __init__(self, trials: Optional[list[Trial]] = None) -> None: """Initialize the journal. Args: trials: Optional seed trials (e.g. a resumed run's prior journal). """ self._trials: list[Trial] = list(trials or [])
[docs] def append(self, trial: Trial) -> None: """Record one completed ``trial``.""" self._trials.append(trial)
@property def trials(self) -> list[Trial]: """A copy of the journaled trials in order.""" return list(self._trials) def __len__(self) -> int: return len(self._trials)
[docs] def best(self) -> Optional[Trial]: """The non-failed trial with the lowest cost score, or ``None``.""" valid = [t for t in self._trials if not t.failed] if not valid: return None return min(valid, key=lambda t: t.score)
[docs] def is_resumable_in_place(self) -> bool: """Always ``False``: the journal resumes by deterministic replay.""" return False
[docs] def completed_count(self) -> int: """The number of completed (non-failed) trials; pruned counts as done.""" return sum(1 for t in self._trials if not t.failed)
[docs] def param_importances(self) -> Optional[dict[str, float]]: """Always ``None``: the journal owns no native importance model. The screening layer falls back to its RandomForest + permutation estimate over the journaled trials (screening-importance.md §1). """ return None
[docs] def pareto_front(self) -> list[Trial]: """The non-dominated trials by their ``objectives`` sidecar (plan §0a). Delegates to the store-agnostic :func:`pareto_front_of` over the journaled trials. A single-objective journal (no trial carries ``objectives``) returns ``[]`` while scalar :meth:`best` still works. """ from ._study._pareto import pareto_front_of return pareto_front_of(self._trials)
[docs] def knee_point(self, front: list[Trial]) -> Optional[Trial]: """The ``front`` trial at max perpendicular distance to the chord. Delegates to the store-agnostic :func:`knee_point_of`; ``None`` for an empty front. """ from ._study._pareto import knee_point_of return knee_point_of(front)
#: Stable column order for the trials frame (explicit so an empty store #: still writes a valid parquet schema rather than a zero-column frame). #: ``objectives_json`` is the multi-objective sidecar column (plan §0a): #: ``null`` for single-objective trials, a JSON dict for multi-objective ones. #: ``gap`` (nullable float) + ``suspicious`` (bool) are the 4.5p1 robust-eval #: signals, appended **last** so a legacy parquet without them still loads. _COLUMNS = [ "number", "score", "n_images", "failed", "pruned", "params_json", "terms_json", "objectives_json", "gap", "suspicious", ]
[docs] def to_dataframe(self) -> pd.DataFrame: """One row per trial; ``params``/``terms``/``objectives`` as JSON strings. ``objectives_json`` is ``None`` for single-objective trials (the column holds ``null``) and ``json.dumps(t.objectives)`` for multi-objective ones. """ rows = [ { "number": t.number, "score": t.score, "n_images": t.n_images, "failed": t.failed, "pruned": t.pruned, "params_json": json.dumps(t.params, sort_keys=True), "terms_json": json.dumps(t.terms, sort_keys=True), "objectives_json": ( json.dumps(t.objectives, sort_keys=True) if t.objectives else None ), "gap": None if t.gap is None else float(t.gap), "suspicious": bool(t.suspicious), } for t in self._trials ] return pd.DataFrame(rows, columns=self._COLUMNS)
[docs] def to_parquet(self, path: Path) -> None: """Write the journal to ``path`` atomically (creating parent dirs). Writes to a sibling ``<path>.tmp`` first, then :func:`os.replace`s it over ``path`` (atomic on POSIX). A killed worker / full disk mid-serialize therefore leaves any pre-existing ``trials.parquet`` intact rather than a truncated file, and the temp is removed on failure so no ``.tmp`` debris lingers. The temp sibling shares ``path``'s directory so the rename stays on one filesystem (the atomicity precondition). """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(path.suffix + ".tmp") try: self.to_dataframe().to_parquet(tmp_path, index=False) os.replace(tmp_path, path) except BaseException: try: os.unlink(tmp_path) except OSError: pass raise
[docs] @classmethod def from_parquet(cls, path: Path) -> "JournalStudyStore": """Reload a journal previously written by :meth:`to_parquet`. Reads ``objectives_json`` defensively: a legacy Phase-1/2 parquet predating the multi-objective sidecar (plan §0a) has no such column, and a single-objective trial stores ``null`` — both resolve to ``objectives=None`` so older journals still load. """ df = pd.read_parquet(path) trials = [ Trial( number=int(row["number"]), params=json.loads(str(row["params_json"])), score=float(row["score"]), terms=json.loads(str(row["terms_json"])), n_images=int(row["n_images"]), objectives=cls._parse_objectives(row.get("objectives_json")), failed=bool(row["failed"]), # Tolerate pre-pruned-column journals (default to not-pruned). pruned=bool(row.get("pruned", False)), # Tolerate pre-4.5p1 journals (no gap/suspicious → neutral). gap=cls._parse_optional_float(row.get("gap")), suspicious=bool(row.get("suspicious", False)), ) for row in df.to_dict(orient="records") ] return cls(trials)
@staticmethod def _is_null_cell(raw: Any) -> bool: """Whether a journal cell is null — ``None`` or a pandas ``NaN`` float. The shared null/NaN preamble of :meth:`_parse_objectives` and :meth:`_parse_optional_float`: a missing column reads as ``None`` (via ``row.get``) and an empty cell reads as a ``float`` ``NaN``. Catching the ``NaN`` here, before any ``float``/``json`` coercion, keeps both parsers' null handling identical. Args: raw: The raw cell value. Returns: ``True`` when ``raw`` is ``None`` or a ``NaN`` float. """ return raw is None or (isinstance(raw, float) and pd.isna(raw)) @classmethod def _parse_objectives(cls, raw: Any) -> Optional[dict[str, float]]: """Decode an ``objectives_json`` cell into a dict, or ``None``. Tolerates the three back-compat shapes: a missing column (``raw`` is ``None`` via ``row.get``), a ``null``/``NaN`` cell (single-objective trial), and a JSON dict string (multi-objective trial). Args: raw: The raw ``objectives_json`` cell — ``None``, a pandas ``NaN``, or a JSON dict string. Returns: The decoded ``{objective: value}`` dict, or ``None`` when there is no multi-objective payload. """ return None if cls._is_null_cell(raw) else json.loads(str(raw)) @classmethod def _parse_optional_float(cls, raw: Any) -> Optional[float]: """Decode a nullable-float journal cell (e.g. ``gap``) into ``float``. Mirrors :meth:`_parse_objectives` for the 4.5p1 ``gap`` column: tolerates the three back-compat shapes — a missing column (``raw`` is ``None`` via ``row.get``), a ``null``/``NaN`` cell (the signal was unavailable), and a real numeric value. The shared :meth:`_is_null_cell` guard catches the ``NaN`` before the ``float`` coercion (strictly safe for the float/null ``gap`` column). Args: raw: The raw cell — ``None``, a pandas ``NaN``, or a number. Returns: The decoded ``float``, or ``None`` when there is no value. """ return None if cls._is_null_cell(raw) else float(raw)
#: Back-compat alias: ``StudyStore`` historically named the concrete journal. #: The name now also denotes the Protocol in ``_study/_protocol.py``; the public #: ``phenotypic.tune.StudyStore`` export resolves to this concrete journal so all #: Phase-1 imports/constructions (``StudyStore()``) keep working. StudyStore = JournalStudyStore