Source code for phenotypic.tune._engine

"""The orchestrator — the ask-and-tell loop over a strategy + evaluator.

Drives ``suggest → evaluate → register_result`` until the strategy exhausts or
the budget caps, journaling every trial. Resumes by fast-forwarding a
deterministic strategy past the trials already in the store.
"""
from __future__ import annotations

from typing import Optional

from phenotypic import ImagePipeline

from ._evaluation import build_pipeline
from ._multi_objective import objective_directions
from ._scoring import CompositeScorer
from ._spec import TuningSpec
from ._study._protocol import StudyStore
from ._study_store import JournalStudyStore, Trial


[docs] class TuningEngine: """Runs a ``TuningSpec`` over a calibration image set, journaling to a store."""
[docs] def __init__(self, spec: TuningSpec, store: Optional[StudyStore] = None) -> None: """Initialize the engine. Args: spec: The tuning recipe (base pipeline + space + scorer + strategy + budget). store: An optional pre-populated store (resume); any backend satisfying the :class:`StudyStore` Protocol. A fresh :class:`JournalStudyStore` is created when omitted. """ self._spec = spec self._store: StudyStore = store if store is not None else JournalStudyStore()
@property def store(self) -> StudyStore: """The trial store this engine appends to.""" return self._store
[docs] def best_pipeline(self) -> Optional[ImagePipeline]: """Build the winning ``ImagePipeline`` from the best trial (or ``None``).""" best = self._store.best() if best is None: return None return build_pipeline(self._spec.pipeline, best.params)
[docs] def optimize(self, images: list) -> Optional[Trial]: """Run the loop; return the best trial. Args: images: The calibration images (non-empty). Returns: The best :class:`Trial`, or ``None`` if none succeeded. """ spec = self._spec # Multi-objective is inferred from the scorer (plan §0b): a dict-returning # scorer yields per-objective ``directions`` the Optuna backend turns into # an NSGA-II Pareto study; ``None`` keeps the scalar single-objective path. directions = objective_directions(spec.scorer) # Pin the study-global active set for the augmented Tchebycheff composite # (§6.3): the children available study-wide form the fixed roster for both # the Tchebycheff max numerator and the normalizer, so the normalizer is a # study-global constant and per-image abstention stays a robust-aggregate # matter (not a max-composition one). Non-composite / non-Tchebycheff # scorers ignore this. # # Meta-validation ordering caveat (SF3): ReferenceFreeScorer.availability() # is False until meta_validate() runs; the engine does not invoke # meta_validate today (it is referenced only as a guard message in the CLI # path), so a ReferenceFreeScorer child is correctly dropped from the # roster here and the engine degrades to its fallback — matching today's # behavior. If a later phase wires meta_validate into the engine, move this # pin to immediately after it. scorer = spec.scorer if isinstance(scorer, CompositeScorer): active = tuple( handle for handle, child in zip(scorer.objective_names(), scorer.scorers) if child.availability() ) scorer.set_active_set(active) strategy = spec.strategy.build( spec.search_space, self._store, directions=directions ) # Resume: a deterministic journal is fast-forwarded by replaying the # strategy past the recorded trials; an in-place-resumable backend (e.g. # an Optuna RDB) already restores the sampler state, so it skips replay. # The same flag decides who writes each trial below (see the loop). completed = len(self._store) resumable = self._store.is_resumable_in_place() if not resumable: for _ in range(completed): if strategy.is_exhausted(): break strategy.suggest() # Seed both budget counters from the store so resume is symmetric: # n_trials counts all recorded trials, max_failures counts recorded # failures (else the failure safety-valve resets to 0 on every resume). failures = sum(1 for t in self._store.trials if t.failed) number = completed budget = spec.budget while not strategy.is_exhausted(): if budget.n_trials is not None and number >= budget.n_trials: break # Checked at the top (not only after a failing trial) so a run # resumed at/over the failure cap stops immediately. if budget.max_failures is not None and failures >= budget.max_failures: break params, channel = strategy.suggest() params = dict(params) result = spec.evaluator.evaluate( spec.pipeline, spec.scorer, params, images, channel=channel ) # Who writes the trial depends on the backend. On an in-place-resumable # store (an Optuna RDB) the strategy's register_result IS the writer: # its native ask/tell trial carries the sampler distributions AND our # off-model fields (via set_trial_user_attrs) into the ONE shared # study, so appending here too would double-record it. Only the # deterministic journal needs the engine to append a Trial record. if not resumable: self._store.append( Trial( number=number, params=params, score=result.score, terms=result.terms, # The multi-objective sidecar (plan §0a): carried verbatim # from the Evaluator so the Pareto front / knee can read it. n_images=result.n_images, objectives=result.objectives, # The robust-eval per-trial signals (plan 4.5p1): the # calibration-dispersion gap + the under-detection # suspicious flag, carried from the Evaluator so the # journal and the data-poor generalization fallback (which # reads the winner's gap) see real values, not nulls. gap=result.gap, suspicious=result.suspicious, failed=result.failed, # explicit flag from the Evaluator pruned=result.pruned, # early-stopped via the channel ) ) # Tell the strategy how the trial ended; a pruned trial flows back as # pruned=True (Optuna marks it TrialState.PRUNED). On the Optuna path # this is the sole write of the shared study (params + user_attrs). strategy.register_result(params, result, pruned=result.pruned) # Budget counts completed + pruned (a pruned trial is a real, if # partial, evaluation). Only true failures feed the failure cap. number += 1 if result.failed: failures += 1 return self._store.best()