"""The composite scoring objective — Phase 4 chunk B (4.5).
``CompositeScorer`` nests a ``list[Scorer]`` (via the polymorphic
``ScorerField``) into one objective, the small *complementary panel* the
supervised-scorers catalogue calls for (§1: "no single metric covers split,
merge, boundary, count, and small-colony errors at once — the scorer must be a
small complementary panel"). It composes children two ways:
* **single-objective** (default): :meth:`finalize` returns one ``float`` — the
conjunctive **augmented Tchebycheff** cost over the per-child cost scalars
(``blend="tchebycheff"``), so a single weak (high-cost) axis dominates the
``max`` and cannot be masked by a strong one. ``blend="weighted_mean"`` is the
compensatory arithmetic-mean opt-out (``weights`` then weight that mean). The
geometric-mean blend is no longer offered (it inverts the conjunctive property
under cost — a single perfect axis would annihilate the product).
* **multi-objective** (``multi_objective=True``): :meth:`finalize` returns a
``dict[str, float]`` of per-child objectives — the plan §0a *sidecar* path.
The ``Evaluator`` stashes that dict on ``EvaluationResult.objectives`` and
projects it to the scalar ``score`` as ``mean(objectives.values())``, so a
composite can drive a true multi-objective (Pareto) study.
**Collision-free merging.** :meth:`score_image` prefixes every child term with
the child's positional handle ``s{i}`` (``"s0.Region"``, ``"s1.Count"``), so two
children emitting the same term name (e.g. both a ``"Count"``) never clash and
:meth:`finalize` can re-group the merged terms back to their originating child.
**Cycle / self-nesting rejection.** A ``model_validator`` walks the nested
scorer graph by object identity and rejects any composite reachable from itself
(direct self-nesting or a deeper back-edge), so a malformed recipe fails at
construction rather than recursing forever at score time.
"""
from __future__ import annotations
from typing import Any, Final, Mapping, Optional
import pandas as pd
from pydantic import ConfigDict, PrivateAttr, model_validator
from phenotypic.sdk_.typing_ import CompositeBlend
from ._orient import clamp01
from ._scorer import Scorer, ScorerField, project_objectives_to_scalar
#: The per-child handle prefix: child ``i`` owns the ``"s{i}."`` term namespace,
#: so collisions across children are impossible and ``finalize`` can re-group
#: merged terms by their leading handle.
_CHILD_HANDLE: Final[str] = "s"
#: The separator between a child handle and the child's own term name.
_SEP: Final[str] = "."
#: The utopia-point shift ``z*ᵢ = −ε`` for the augmented Tchebycheff combiner.
#: A small, fixed numerical safety margin (~0.1% of the [0,1] cost scale): it
#: pushes the reference strictly below the achievable front so every
#: ``bᵢ − z*ᵢ = bᵢ + ε > 0`` (the unsigned ``max`` is valid) and caps the
#: weight realizer ``1/(bᵢ + ε)`` at ``≤ 1000×``. Internal — never a field
#: (spec §6.4/§6.6).
_UTOPIA_EPS: Final[float] = 1e-3
[docs]
class CompositeScorer(Scorer):
"""Blend several ``Scorer`` children into one objective (scalar or Pareto).
Args:
scorers: The child objectives to compose. Each is a ``ScorerField`` so
any ``Scorer`` subclass — including a nested ``CompositeScorer`` —
round-trips through the polymorphic registry. Child ``i`` owns the
``"s{i}."`` term namespace.
weights: Optional per-child weights for the **single-objective** scalar
blend, keyed by child handle (``"s0"``, ``"s1"``, …). The meaning is
**blend-dependent**: under ``blend="tchebycheff"`` they are per-axis
Tchebycheff weights (they steer the ``max`` term, uniform ``1.0``
when ``None``); under ``blend="weighted_mean"`` they are arithmetic
weights for the compensatory mean. Ignored when ``multi_objective``
is ``True``.
multi_objective: When ``True``, :meth:`finalize` returns a
``dict[str, float]`` of per-child objectives (the plan §0a sidecar)
instead of a scalar, so the composite can drive a Pareto study.
blend: The single-objective combiner. ``"tchebycheff"`` (default) is the
conjunctive augmented-Tchebycheff cost over the pinned study-global
active set — worst-axis-dominant, so a single weak axis cannot be
masked by a strong one. ``"weighted_mean"`` is the compensatory
arithmetic-mean opt-out. The geometric-mean-of-cost blend is no
longer offered (it inverts the conjunctive property — a single
perfect axis would annihilate the product).
rho: The augmented-Tchebycheff augmentation coefficient (advanced-only;
default ``0.05``). It scales the weighted-L1 term that breaks ties
between weakly-dominated points; ``→0`` recovers the plain
Tchebycheff (admits weakly-dominated winners), large values drift
toward the weighted sum (spec §6.4). Ignored under
``blend="weighted_mean"`` / ``multi_objective``.
Raises:
pydantic.ValidationError: If the nested scorer graph contains a cycle —
a composite reachable from itself (direct self-nesting or a deeper
back-edge).
Examples:
Compose two children and read the prefixed, merged per-image terms (the
``QCScorer`` here scores a perfect 96-well count match):
>>> import pandas as pd
>>> from phenotypic.analysis import ExpectedVsDetectedCount
>>> from phenotypic.tune.score import CompositeScorer, QCScorer
>>> layout = pd.DataFrame(
... {"MetadataImage_ImageName": ["p"] * 96, "Object_Label": list(range(96))}
... )
>>> qc = QCScorer(
... check=ExpectedVsDetectedCount(
... metadata=layout, groupby=["MetadataImage_ImageName"]
... )
... )
>>> comp = CompositeScorer(scorers=[qc, qc])
>>> terms = comp.score_image(None, layout)
>>> sorted(terms)
['s0.Count', 's1.Count']
>>> round(comp.finalize(terms), 3) # augmented Tchebycheff of two perfect (cost-0) children
0.001
Flip to multi-objective and ``finalize`` returns the per-child sidecar:
>>> comp_mo = CompositeScorer(scorers=[qc, qc], multi_objective=True)
>>> {k: round(v, 3) for k, v in comp_mo.finalize(terms).items()}
{'s0': 0.0, 's1': 0.0}
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
scorers: list[ScorerField] = []
weights: Optional[dict[str, float]] = None
multi_objective: bool = False
blend: CompositeBlend = "tchebycheff"
rho: float = 0.05
#: The pinned study-global active set — child handles available study-wide,
#: fixed once at study start by :meth:`set_active_set`. Used as the roster for
#: BOTH the Tchebycheff ``max`` numerator and the normalizer so the
#: normalizer is a study-global constant (§6.2/§6.3). ``None`` (never pinned —
#: e.g. a direct ``finalize`` unit call) falls back to the in-call roster.
#: A ``PrivateAttr`` so it never serializes (it is run/study state, not recipe).
_active_handles: Optional[tuple[str, ...]] = PrivateAttr(default=None)
[docs]
def set_active_set(self, handles: tuple[str, ...]) -> None:
"""Pin the study-global active set (child handles available study-wide).
Called once by the engine after meta-validation, before the trial loop,
so every trial's Tchebycheff ``max`` and normalizer use the same fixed
roster (§6.3 plumbing SF3). Idempotent.
Args:
handles: The available child handles (a subset of
:meth:`objective_names`), in objective order.
"""
self._active_handles = tuple(handles)
@model_validator(mode="after")
def _reject_cycles(self) -> "CompositeScorer":
"""Reject a nested scorer graph in which a composite reaches itself.
Walks the composite tree by object identity from each composite node and
rejects the recipe if any ``CompositeScorer`` is reachable from itself
(direct self-nesting ``scorers=[self]`` or a deeper back-edge). This runs
at construction / validation so a malformed recipe fails fast instead of
recursing forever in :meth:`score_image`.
Returns:
``self`` (unchanged) when the graph is acyclic.
Raises:
ValueError: If a cycle (a composite reachable from itself) exists.
"""
def _visit(node: CompositeScorer, ancestors: frozenset[int]) -> None:
if id(node) in ancestors:
raise ValueError(
"CompositeScorer nesting contains a cycle: a composite is "
"reachable from itself (self-nesting or a back-edge). The "
"nested scorer graph must be acyclic."
)
deeper = ancestors | {id(node)}
for child in node.scorers:
if isinstance(child, CompositeScorer):
_visit(child, deeper)
_visit(self, frozenset())
return self
def _handle(self, index: int) -> str:
"""The term-namespace handle for child ``index`` (``"s0"``, ``"s1"``…).
Args:
index: The child's position in :attr:`scorers`.
Returns:
The child handle string.
"""
return f"{_CHILD_HANDLE}{index}"
[docs]
def objective_names(self) -> list[str]:
"""The ordered objective-axis names of a multi-objective composite.
Returns the per-child handles (``["s0", "s1", …]``) in :attr:`scorers`
order — exactly the keys :meth:`finalize` emits when
:attr:`multi_objective` is ``True``, and therefore the
``Trial.objectives`` / ``objectives_json`` keys and the ``pareto/`` axis
labels. Stable across a study so the per-objective directions, the
per-objective best pipelines, and the front parquet all align.
Returns:
The child handles in order; ``[]`` for an empty composite.
"""
return [self._handle(index) for index in range(len(self.scorers))]
[docs]
def availability(self) -> bool:
"""Whether the composite can contribute any signal.
**Pinned rule:** a composite is available iff **at least one** child is
available. A child that abstains contributes no terms, but as long as one
child can score, the composite produces a usable (partial) objective; an
empty composite, or one all of whose children abstain, is unavailable and
the engine degrades to its fallback.
Returns:
``True`` if any child reports :meth:`Scorer.availability`; ``False``
for an empty composite or one whose children all abstain.
"""
return any(child.availability() for child in self.scorers)
def _score_terms(
self, image: Any, measurements: pd.DataFrame
) -> dict[str, float]:
"""Not used — the composite overrides :meth:`score_image` instead.
``_score_terms`` is abstract on the base, but a composite's children
already returned **cost** (each child's own ``score_image`` oriented its
terms), so the composite must **not** re-orient: it overrides the merge
in :meth:`score_image` directly. This stub satisfies the abstract base.
Raises:
NotImplementedError: Always — call :meth:`score_image`.
"""
raise NotImplementedError(
"CompositeScorer overrides score_image (it merges already-cost "
"children); _score_terms is not used."
)
[docs]
def score_image(
self, image: Any, measurements: pd.DataFrame
) -> dict[str, float]:
"""Merge every child's per-image terms under a per-child prefix.
Each child ``i`` is scored on ``(image, measurements)`` and its terms are
re-keyed ``"s{i}.<term>"`` so two children emitting the same term name
never collide and :meth:`finalize` can re-group them.
Args:
image: The processed image, passed to every child unchanged.
measurements: The candidate pipeline's measurement frame, passed to
every child unchanged.
Returns:
The union of all children's terms, each prefixed with its child
handle.
"""
merged: dict[str, float] = {}
for index, child in enumerate(self.scorers):
handle = self._handle(index)
for term, value in child.score_image(image, measurements).items():
merged[f"{handle}{_SEP}{term}"] = float(value)
return merged
[docs]
def finalize(
self, terms: Mapping[str, float]
) -> float | dict[str, float]:
"""Blend the per-child scalars — scalar (default) or dict (Pareto).
Re-groups the prefixed ``terms`` back to their originating child, calls
each child's own :meth:`Scorer.finalize` over its un-prefixed sub-terms
(projecting a child's own multi-objective dict to its mean), then:
* ``multi_objective=True`` → returns ``{handle: child_scalar}`` over
**every** axis in :meth:`objective_names` order — the plan §0a sidecar
the ``Evaluator`` stashes on ``EvaluationResult.objectives``. A child
that abstains (no terms this run) is floored to ``1.0`` (the worst
cost; the study minimizes) rather than dropped, so the dict keys +
order stay invariant and exactly match the multi-objective study's
fixed ``directions`` (now ``minimize``). A dropped axis would otherwise
make the NSGA-II value vector the wrong length and crash Optuna's
``tell`` mid-run; the ``1.0`` floor also mirrors the journal Pareto
``_vector`` fill so the journal and Optuna backends agree on an
abstaining axis.
* ``blend="weighted_mean"`` → the compensatory weighted arithmetic mean
of the per-child **cost** scalars (missing weights default to ``1.0``).
* ``blend="tchebycheff"`` (default) → the conjunctive augmented
Tchebycheff cost (:meth:`_tchebycheff`) over the pinned study-global
active set, so a single weak (high-cost) axis dominates the ``max`` and
cannot be masked by a strong one. The roster is restricted to the
pinned active set (the children available study-wide, §6.3): a
study-wide abstainer is simply not an objective (dropped from both the
``max`` and the normalizer), while per-image abstention is already a
fewer-samples matter handled upstream by the robust aggregate — so a
present-but-absent-this-call child is NOT flooded into the ``max``.
Args:
terms: The robust-aggregated, child-prefixed terms (the output of
:meth:`score_image` after the ``Evaluator``'s per-term
aggregation).
Returns:
The scalar objective for the single-objective path (worst cost
``1.0`` for no scored children/terms), or the per-child ``dict`` (one
entry per axis, abstainers floored) for the multi-objective path.
"""
child_scalars = self._per_child_scalars(terms)
if self.multi_objective:
return {
handle: child_scalars.get(handle, 1.0) # was 0.0 (goodness worst)
for handle in self.objective_names()
}
if self.blend == "weighted_mean":
if not child_scalars:
return 1.0 # worst cost (cost-convention floor; was 0.0 goodness)
return self._weighted_mean(child_scalars)
# Default conjunctive blend: augmented Tchebycheff over the pinned
# study-global active set (§6.3). Restrict to the active roster so a
# study-wide abstainer is simply not an objective (dropped from both the
# max and the normalizer); per-image abstention is already a fewer-samples
# matter handled by the robust aggregate, so a present-but-absent-this-call
# child is NOT flooded into the max.
if self._active_handles is None:
roster = dict(child_scalars)
else:
roster = {
handle: child_scalars[handle]
for handle in self._active_handles
if handle in child_scalars
}
return self._tchebycheff(roster)
def _per_child_scalars(
self, terms: Mapping[str, float]
) -> dict[str, float]:
"""Project the merged terms to one finalized scalar per child.
Args:
terms: The child-prefixed terms from :meth:`score_image`.
Returns:
``{handle: scalar}`` — each child's own :meth:`Scorer.finalize` over
its un-prefixed sub-terms, with a child's own multi-objective dict
projected to its mean. Children with no terms in ``terms`` are
omitted.
"""
scalars: dict[str, float] = {}
for index, child in enumerate(self.scorers):
handle = self._handle(index)
prefix = f"{handle}{_SEP}"
sub = {
key[len(prefix):]: value
for key, value in terms.items()
if key.startswith(prefix)
}
if not sub:
continue
scalars[handle] = self._as_scalar(child.finalize(sub))
return scalars
@staticmethod
def _as_scalar(finalized: float | Mapping[str, float]) -> float:
"""Reduce a child's ``finalize`` result to a scalar.
Mirrors the ``Evaluator``'s sidecar projection: a child that is itself a
multi-objective composite returns a ``dict``, projected here to the mean
of its values so the parent can still blend a single number per child.
Args:
finalized: The child's :meth:`Scorer.finalize` return — a ``float``
or a ``dict`` of named objectives.
Returns:
The scalar form (``mean`` of a dict's values; ``0.0`` for an empty
dict).
"""
if isinstance(finalized, Mapping):
return project_objectives_to_scalar(finalized)
return float(finalized)
def _weighted_mean(self, child_scalars: dict[str, float]) -> float:
"""The weight-weighted arithmetic mean of the per-child scalars.
Args:
child_scalars: ``{handle: scalar}`` per scored child.
Returns:
``Σ wᵢ·sᵢ / Σ wᵢ`` over scored children (missing weights default to
``1.0``); ``0.0`` if the total weight is zero.
"""
weights = self.weights or {}
total_weight = 0.0
weighted_sum = 0.0
for handle, scalar in child_scalars.items():
weight = float(weights.get(handle, 1.0))
weighted_sum += weight * scalar
total_weight += weight
if total_weight == 0.0:
return 0.0
return weighted_sum / total_weight
def _tchebycheff(self, child_costs: dict[str, float]) -> float:
"""Augmented weighted Tchebycheff of per-child **cost** scalars (§6.1/§6.2).
``Tᵨ(b) = maxᵢ wᵢ(bᵢ + ε) + ρ·Σᵢ wᵢ·bᵢ``, minimized, with utopia point
``z*ᵢ = −ε`` (``_UTOPIA_EPS``). The ``max`` drops the absolute value
because ``z*ᵢ = −ε < 0 ≤ bᵢ`` makes every ``bᵢ − z*ᵢ = bᵢ + ε > 0`` — an
invariant asserted here (the Phase 1 ``[0,1]`` clamp guarantees the upper
bound; the assert fires loudly if that clamp ever regresses).
**Normalization is a study-global constant (§6.2).** The raw ``Tᵨ``
ranges over ``[ε·(…), (1+ε)·(…)]``, so it is normalized by the theoretical
worst ``Tᵨ(1…1)`` to give a normalized cost in ``(0, 1]``. That worst-case
denominator is computed over the **full pinned active set**
(:attr:`_active_handles`) — every child available study-wide — **not**
the in-call roster. Otherwise a pinned child that produces zero terms
across a whole trial's calibration set would drop the trial's denominator
from ``(1+ε)+ρ·n`` to ``(1+ε)+ρ·(n−1)`` while other trials keep ``n``,
normalizing different trials by different constants and perturbing the
cross-trial argmin (the winner-equivalence this migration must preserve).
The **numerator** (``max_term`` / ``l1``) stays over the in-call
``child_costs``: a child that genuinely abstained this trial contributes
no cost to the numerator, which is correct. When :attr:`_active_handles`
is ``None`` (direct-``finalize`` unit calls with no engine pin), the
denominator falls back to the in-call roster.
Args:
child_costs: ``{handle: cost}`` over the children that scored this
call (each ``bᵢ ∈ [0,1]``).
Returns:
The normalized composite cost in ``(0, 1]``. Empty roster → ``1.0``
(the worst floor; the engine degrades — guards the ``max([])``).
"""
if not child_costs:
return 1.0
eps = _UTOPIA_EPS
weights = self.weights or {}
max_term = 0.0
l1 = 0.0
for handle, cost in child_costs.items():
assert 0.0 <= cost <= 1.0, ( # noqa: S101 — B1 invariant guard
f"per-child cost {handle}={cost!r} escaped [0,1]; the Phase 1 "
"robust-aggregate clamp regressed"
)
weight = float(weights.get(handle, 1.0))
max_term = max(max_term, weight * (cost + eps))
l1 += weight * cost
# Denominator roster = the full pinned active set (study-global constant),
# falling back to the in-call roster when unpinned (direct-finalize unit
# calls). Each pinned handle contributes its weight at the worst cost 1.0,
# independent of whether it scored this call.
denom_handles = (
self._active_handles if self._active_handles is not None
else tuple(child_costs)
)
denom_max = 0.0
denom_l1 = 0.0
for handle in denom_handles:
weight = float(weights.get(handle, 1.0))
denom_max = max(denom_max, weight * (1.0 + eps))
denom_l1 += weight
numerator = max_term + self.rho * l1
denominator = denom_max + self.rho * denom_l1
if denominator <= 0.0:
return 1.0
return clamp01(numerator / denominator)