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 COMPLETE 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 math
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Optional

import pandas as pd
from pydantic import BaseModel, ConfigDict

from phenotypic.sdk_ import atomic_write_parquet


[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, remain exported terminal history, and still count against the budget (failed trials do not), but cannot be published as winners. 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 terminal_trials(self) -> list[Trial]: """Return every journal entry; this backend has no in-flight records.""" return list(self._trials)
[docs] def best(self) -> Optional[Trial]: """The finite COMPLETE trial with the lowest cost, or ``None``.""" valid = [ trial for trial in self._trials if not trial.failed and not trial.pruned and math.isfinite(trial.score) ] if not valid: return None return min(valid, key=lambda trial: trial.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, objective_axes: Sequence[str] | None = None ) -> list[Trial]: """Return finite non-dominated COMPLETE trials on optional fixed axes.""" from ._study._pareto import pareto_front_of return pareto_front_of( self._trials, objective_axes=objective_axes )
[docs] def knee_point( self, front: list[Trial], objective_axes: Sequence[str] | None = None, ) -> Optional[Trial]: """Return the ``front`` knee using optional fixed scorer axes.""" from ._study._pareto import knee_point_of return knee_point_of(front, objective_axes=objective_axes)
#: 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). """ atomic_write_parquet(path, self.to_dataframe())
[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