Hyperparameter Tuning#
The tune engine searches over an ImagePipeline’s parameters and returns the
combination that scores best on your plates. It is sample-efficient — grid and
seeded-random for small spaces, Optuna samplers (TPE, CMA-ES, GP, NSGA-II) for
larger ones — and pairs the search with pluggable scoring objectives and a
robust held-out generalization check, so a winning pipeline has to prove it
holds up on plates the search never optimized against.
The engine replaces the old sweep module: instead of enumerating a fixed
grid of pipeline variants, you author (or auto-infer) a search space, pick a
scoring objective, and let the optimizer propose candidates.
```{admonition} Migrating from a legacy sweep manifest
:class: tip
If you have an existing sweep manifest, convert it to a tuning_spec.json —
a Categorical grid over the manifest’s varying parameters (plus
__enabled__ presence knobs for optional ops) — with the migration script:
python scripts/migrate_sweep_manifest.py old_manifest.json \
-o tuning_spec.json --metadata layout.csv
--metadata is the per-image layout CSV/Parquet that feeds the count scorer
(grouped by --groupby, default Metadata_ImageName). Manifests that swept
nested operations raise NotImplementedError — author those spaces by
hand against the new SearchSpace API.
## Python interface
### Running a study from the CLI
The basic run takes a `tuning_spec.json`, an image directory, and an output
directory:
```bash
python -m phenotypic.tune run spec.json -i ./plates -o ./out
Override the spec’s strategy and trial budget, and turn on the two-round screening freeze, from the command line:
python -m phenotypic.tune run spec.json -i ./plates -o ./out \
--strategy tpe \
--n-trials 200 \
--screen
--strategy grid and --strategy random use the built-in configs;
tpe, cmaes, gp, and nsga2 build an OptunaConfig and require the
tune extra (uv sync --extras tune). --screen enables the two-round
screening freeze (it is off by default; --no-screen is the explicit
default). --n-trials overrides the spec’s Budget.n_trials.
A run writes its deliverables under <output>/deliverables/:
best_pipeline.json— the winning pipeline, ready to run withpython -m phenotypic.tuning_spec.json— the fully resolved spec (pipeline + space + scorer + strategy + budget), so the run round-trips.param_importance.json— per-parameter importance (random-forest / fANOVA) showing which knobs moved the objective.pareto/— the non-dominated front, for a multi-objective (CompositeScorer(multi_objective=True)) study.generalization.json— the held-out verdict: how the winner scored on the reserved plates versus the search set.
Alongside the deliverables, trials.parquet is written at the output
root. It is dual-purpose: the Optuna resume store and the trial journal.
Resume is automatic. Re-running run with -o pointed at a prior run
directory resumes the study rather than restarting it — grid/random pick up
from trials.parquet, while the Optuna samplers resume from the study store
(the local .pht-tune-cache/study.db or the --storage-url study). A killed or
extended run picks up its prior trials.
Inferring a search space with auto-space#
When you don’t want to hand-author every knob, auto-space mines a configured
pipeline’s pydantic fields into a reviewable candidate space — no engine runs:
python -m phenotypic.tune auto-space pipeline.json -o ./out
The same inference is available in Python as infer_search_space, which
returns an InferredSearchSpace carrying:
.knobs— the inferredKnobs (each with asourceprovenance tag and aneeds_reviewflag)..excluded—Excludedrecords for fields inference declined to tune, each with a reason..needs_review—Truewhen any knob is flagged for review (or a field was excluded blindly).
Flagged knobs (needs_review=True) keep the autonomy gate conservative: an
unattended run stays cautious until a human confirms the shaky guesses, so a
generously-inferred bound never silently drives a fully-automatic study.
The four scoring objectives#
Every scorer is a Scorer subclass with a higher-is-better objective in
[0, 1]. Pick the one that matches the ground truth you have.
QCScorer#
Use it when you have no ground-truth masks but you know the expected colony
count per plate (e.g. a 96-well layout). It is a purely statistical check:
it wraps phenotypic.analysis.ExpectedVsDetectedCount and scores each
groupby unit on |detected − expected| / expected, folded to a
higher-is-better term.
It requires a configured count check on its check field. Configure that check
from a metadata path so the scorer round-trips through tuning_spec.json:
from phenotypic.analysis import ExpectedVsDetectedCount
from phenotypic.tune.score import QCScorer
scorer = QCScorer(
check=ExpectedVsDetectedCount(
metadata="layout.csv", groupby=["Metadata_ImageName"]
)
)
ReferenceFreeScorer#
Use it when you have no ground truth at all — not even expected counts — and
want to score segmentation quality from the result itself. It computes
fixed-normalized proxy terms: ShapeRegularity (mean shape-prior plausibility
from the Shape_* columns), Contrast (Otsu between-class separation of
foreground vs. background), and SizeCV (within-replicate size uniformity).
Supply an optional count_check to add the Count term.
It requires nothing to construct, but it is gated behind meta-validation:
availability() returns False (so the engine fails safe and degrades to
QCScorer) until the proxy is correlated against ground truth and clears the
enable threshold (Spearman ρ ≥ 0.7; ρ ≥ 0.8 for fully unattended tuning). Point
gt_masks_source at a ground-truth source to let the gate validate; without it
the gate abstains and the scorer stays unavailable.
from phenotypic.tune.score import ReferenceFreeScorer
scorer = ReferenceFreeScorer(replicate_groupby=["Metadata_ImageName"])
SupervisedScorer#
Use it when you have ground-truth annotations. It is modality-tiered: a
directory of per-image masks runs the mask tier (per-object Dice or IoU,
macro-averaged into the Region term); a .csv/.parquet count table runs the
count tier (count divergence into the CountMAE term); nothing resolvable
makes it abstain.
It requires a GroundTruthMasks loader on its gt field, whose
gt_masks_source path selects the modality. The mask tier carries exactly one
region metric (region_metric="dice" or "iou" — they rank identically); the
count tier additionally needs a configured count_check:
from phenotypic.analysis import ExpectedVsDetectedCount
from phenotypic.tune.score import GroundTruthMasks, SupervisedScorer
# Mask tier: a directory of per-image masks.
masks = SupervisedScorer(
gt=GroundTruthMasks(gt_masks_source="./gt_masks/"),
region_metric="dice",
)
# Count tier: a count table plus the reused count check.
counts = SupervisedScorer(
gt=GroundTruthMasks(gt_masks_source="./counts.csv"),
count_check=ExpectedVsDetectedCount(
metadata="counts.csv", groupby=["Metadata_ImageName"]
),
)
CompositeScorer#
Use it to combine the above into one objective — the small complementary
panel no single metric covers. It nests a list[Scorer] on its scorers field
and composes them two ways:
single-objective (default): a
weights-weighted arithmetic blend, or (without weights) the geometric mean — so one weak axis cannot be masked by a strong one.multi-objective (
multi_objective=True): per-child objectives kept separate, driving a true Pareto study (requires an Optunansga2strategy; pairing a multi-objective scorer with grid/random is rejected at spec construction). The non-dominated front lands indeliverables/pareto/.
from phenotypic.tune.score import CompositeScorer
scorer = CompositeScorer(
scorers=[qc_scorer, reference_free_scorer],
weights={"s0": 2.0, "s1": 1.0},
)
GUI interface#
phenotypic-gui mounts a /tune/ Dash co-pilot as a tab alongside
Home · Builder · Tune · Run · Viewer · Analysis. The co-pilot is a full
author → run → monitor workflow organised as three top-level destinations,
selected from the destination row at the top of the page:
Run — configure and launch#
The Run destination (unlocked once a pipeline is chosen) is a launch form whose
live command card mirrors your choices into the real
python -m phenotypic.tune run invocation — the same subcommand and flag names
you would type by hand. It exposes:
Inputs — an in-sandbox image-source override (blank falls back to the shared source root) and the output directory.
Strategy — strategy dropdown (
grid/random/tpe/cmaes/gp/nsga2), trial budget, and storage URL.Compute — Local vs. SLURM, the two-round screening toggle, and the SLURM fleet sizing fields (workers, partition, memory, time).
Evaluation — held-out fraction and CV-group overrides.
A pre-flight gate blocks Deploy when the configuration cannot run (for
example a grid search over a continuous FloatRange). Unlike the earlier
read-only co-pilot, Deploy actually launches the run: it registers the run
in the shared run registry and starts it through the local runner, or submits
the SLURM fleet. You no longer have to copy the command out and run it in a
terminal (though the live command card still lets you, if you prefer).
Monitor — watch, curate, and export#
Once a run is deployed (or you Bind run to an existing
python -m phenotypic.tune output directory via the sandbox-bounded run
picker), the Monitor destination opens the live read over the study. It carries
a run switcher across registered Local/SLURM runs, a Local-only Cancel
(SLURM runs are not killed from the GUI in v1), and an Export best pipeline
button — plus the four classic sub-tabs:
Monitor — the live study read: objective curve (raw scores + running best), a parameter-importance bar chart, a winner-stability (generalization-gap) badge, and a trials table, polled every 3 s while a run is in flight. If the live study store is unreachable it degrades to the finished
trials.parquetjournal. A Pareto-front card appears for multi-objective runs only.Curate — a shortlist of the best (and gap-flagged) trials; pin two into A / B slots and compare their colony overlays side-by-side (or as a single difference image) on any plate, with linked pan/zoom. “Set as winner” writes
best_pipeline.json.Space — the inferred search space as editable knob-rows; export the edited space back to
tuning_spec.json.Launch — a strategy / budget / storage-URL form whose live command card renders a copy-paste
python -m phenotypic.tune runinvocation.
The co-pilot keeps its import surface optuna-free — the live study is opened lazily inside the Monitor poll only — and binding an existing run never writes to its directory.
For the full walkthrough, see the Tune co-pilot tutorial.
Distributed runs#
For running a single tune study across many SLURM compute nodes that share one
Optuna study (via --slurm and a Postgres --storage-url), see
Distributed Tuning on HPCC Clusters.