Source code for phenotypic.tune._screening

"""Parameter importance — fANOVA-vs-RF dispatch on store capability (Phase 2).

The screening layer ranks tuned parameters by their contribution to the
objective. It dispatches **on store capability**, never on type: a backend that
exposes ``param_importances()`` (an Optuna study → fANOVA, whose variance
decomposition attributes interaction effects to each parameter) drives the
``"fanova"`` path; any backend returning ``None`` (the journal, or an Optuna
study with no native sampler dimensions) falls back to the homegrown
``RandomForest`` + permutation estimate (``"rf-permutation"``). The
``interactions_estimated`` honesty flag records whether the chosen method
accounts for interactions (fANOVA does; RF-permutation is main-effect only) so a
downstream freeze decision can be conservative when interactions are unverified
(screening-importance.md §1, §7).
"""
from __future__ import annotations

from typing import Literal

import numpy as np
import pandas as pd
from pydantic import BaseModel, ConfigDict

from ._study._protocol import StudyStore

#: The importance estimators, a closed set (never a bare ``str``). ``"fanova"``
#: is the native Optuna variance decomposition (main + interaction); the
#: ``"rf-permutation"`` fallback is the homegrown RandomForest + permutation
#: estimate (main effects only).
ImportanceMethod = Literal["fanova", "rf-permutation"]


[docs] class ImportanceReport(BaseModel): """A ranked importance estimate plus its method + interaction honesty flag. Args: importances: ``{param_key: importance}`` sorted descending (empty when there is nothing to fit). method: Which estimator produced ``importances`` (a closed :data:`ImportanceMethod` set). interactions_estimated: Whether ``method`` accounts for interaction effects. ``True`` for ``"fanova"`` (variance decomposition); ``False`` for ``"rf-permutation"`` (main-effect permutation only). A freeze decision should stay conservative when this is ``False`` — a low-main/high-interaction parameter may look unimportant (screening-importance.md §7). """ model_config = ConfigDict(frozen=True) importances: dict[str, float] method: ImportanceMethod interactions_estimated: bool
[docs] def compute_param_importance( store: StudyStore, *, random_state: int = 0, objective: str | None = None ) -> dict[str, float]: """Rank tuned parameters by importance against the objective (the dict view). The back-compat, dict-returning façade over :func:`compute_param_importance_report`: it dispatches the same way (native fANOVA when the store offers it, RandomForest + permutation otherwise) but discards the method / honesty metadata. Args: store: The study store of completed trials. random_state: Seed for the forest + permutation (reproducibility); only used on the RandomForest fallback path. objective: The named multi-objective to rank against (plan §0a sidecar). ``None`` (default) ranks against ``Trial.score`` — the unchanged single-objective path. A name ranks against ``Trial.objectives[name]``, skipping trials that lack the objective. Returns: ``{param_key: importance}`` sorted descending. Empty when fewer than two usable trials (nothing to fit). """ return compute_param_importance_report( store, random_state=random_state, objective=objective ).importances
[docs] def compute_param_importance_report( store: StudyStore, *, random_state: int = 0, objective: str | None = None ) -> ImportanceReport: """Rank parameters and record the method + interaction honesty flag. First asks the store for a native importance model via ``store.param_importances()`` (capability dispatch — never an ``isinstance`` check). A non-empty result is the fANOVA path (``interactions_estimated=True``); ``None`` falls back to the homegrown RandomForest + permutation estimate (``interactions_estimated=False``). A **per-objective** request (``objective`` given) always takes the RF path: the native importance models rank against the optimizer's scalar, not a named objective from the multi-objective sidecar. Args: store: The study store of completed trials. Any object exposing ``param_importances()`` and ``trials`` satisfies the contract. random_state: Seed for the forest + permutation (RandomForest path). objective: The named multi-objective to rank against (plan §0a sidecar). ``None`` ranks against ``Trial.score`` and may use the native fANOVA model; a name forces the RF path against ``Trial.objectives[name]``. Returns: An :class:`ImportanceReport` carrying the ranked importances, the ``method``, and the ``interactions_estimated`` flag. """ # A per-objective request cannot use the native (scalar-targeted) model. if objective is None: native = store.param_importances() if native: ranked = dict( sorted(native.items(), key=lambda kv: kv[1], reverse=True) ) return ImportanceReport( importances=ranked, method="fanova", interactions_estimated=True, ) return ImportanceReport( importances=_rf_permutation_importance( store, random_state=random_state, objective=objective ), method="rf-permutation", interactions_estimated=False, )
def _rf_permutation_importance( store: StudyStore, *, random_state: int = 0, objective: str | None = None ) -> dict[str, float]: """The homegrown fallback: RandomForest + permutation importance. Fits a ``RandomForestRegressor`` on the trials' (encoded) params → target and runs ``permutation_importance``. The target is ``Trial.score`` when ``objective`` is ``None`` (single-objective), else ``Trial.objectives[name]`` over the trials that carry it (others are dropped). Non-numeric params are one-hot encoded (per-key prefix) and the encoded importances summed back to the original key; absent conditional params fill to ``0``. Imports no optuna — the lazy-import boundary holds on this path (screening-importance.md §1). Args: store: The journal of completed trials. random_state: Seed for the forest + permutation (reproducibility). objective: The named multi-objective to target, or ``None`` for the scalar ``Trial.score`` (plan §0a sidecar). Returns: ``{param_key: importance}`` sorted descending. Empty when fewer than two usable trials (nothing to fit). """ trials = [t for t in store.trials if not t.failed] if objective is not None: # Per-objective: keep only trials that carry the named objective. trials = [ t for t in trials if t.objectives is not None and objective in t.objectives ] if len(trials) < 2: return {} raw = pd.DataFrame([t.params for t in trials]) if objective is None: y = np.asarray([t.score for t in trials], dtype=float) else: y = np.asarray( [t.objectives[objective] for t in trials], # type: ignore[index] dtype=float, ) original_keys = list(raw.columns) numeric = raw.select_dtypes(include="number") non_numeric = raw.drop(columns=list(numeric.columns)) parts: list[pd.DataFrame] = [] col_to_key: dict[str, str] = {} for col in numeric.columns: series = numeric[col].astype(float) fill = float(series.median()) if series.notna().any() else 0.0 parts.append(series.fillna(fill).to_frame(name=col)) col_to_key[col] = col if not non_numeric.empty: dummies = pd.get_dummies( non_numeric.astype("object"), prefix_sep="=", dummy_na=False ) for col in dummies.columns: col_to_key[col] = col.split("=", 1)[0] parts.append(dummies) features = pd.concat(parts, axis=1).fillna(0.0) if features.shape[1] == 0: return {} # Deferred: scikit-learn is heavy and only needed for this fallback path, # not at module import (mirrors the lazy-optuna boundary convention). from sklearn.ensemble import RandomForestRegressor from sklearn.inspection import permutation_importance forest = RandomForestRegressor(n_estimators=200, random_state=random_state) forest.fit(features.to_numpy(), y) perm = permutation_importance( forest, features.to_numpy(), y, n_repeats=10, random_state=random_state ) importances: dict[str, float] = {key: 0.0 for key in original_keys} for col, value in zip(features.columns, perm.importances_mean): importances[col_to_key[col]] += float(value) return dict( sorted(importances.items(), key=lambda kv: kv[1], reverse=True) )