CellTriage / src /features /selector.py
Sarvarbek13's picture
CellTriage QC operator console - inference only, CPU-bound classical ML
749bffa verified
Raw
History Blame Contribute Delete
17.1 kB
"""Feature selection, implemented so that fitting outside a CV fold is impossible.
THE CONSTRAINT. n is 124 cells. Selecting features on the full dataset is the
fastest way to manufacture a result that looks strong and does not replicate:
the selector sees the labels of the cells it will later be evaluated on, and
"which features correlate with the target" is exactly the information a test
fold is supposed to withhold.
HOW IT IS ENFORCED HERE. Every selector below is an sklearn transformer with a
``fit``/``transform`` split. Composed into a ``Pipeline`` and passed to
``cross_val_score`` or ``GridSearchCV``, ``fit`` is called on the training fold
ONLY -- scikit-learn guarantees that, so the guarantee does not depend on this
project remembering to do it. There is deliberately no module-level
"select_features(X, y)" convenience function, because that is the shape of API
that invites full-data use.
``configs/features.yaml`` sets ``selection.fit_inside_cv_only: true`` and the
schema in ``src/utils/config.py`` REJECTS the config if it is ever false.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.feature_selection import mutual_info_classif, mutual_info_regression
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.utils.validation import check_is_fitted
from src.utils.config import ConfigBundle
def _abs_spearman(x: np.ndarray, y: np.ndarray) -> float:
"""|Spearman rho| between two aligned 1-D arrays, 0.0 if undefined.
Takes arrays rather than Series on purpose: pandas correlates on the shared
INDEX, which silently mis-pairs a bootstrap subset (original row labels)
against a freshly built target (RangeIndex).
"""
if x.size != y.size or x.size < 3:
return 0.0
finite = np.isfinite(x) & np.isfinite(y)
if finite.sum() < 3:
return 0.0
x, y = x[finite], y[finite]
if np.ptp(x) == 0 or np.ptp(y) == 0:
return 0.0
rho = stats.spearmanr(x, y).statistic
return float(abs(rho)) if np.isfinite(rho) else 0.0
def _encode_target(y) -> tuple[np.ndarray, bool]:
"""Return a numeric target and whether it is categorical.
The selectors serve both routes of the project: regression on log10 cycle
life, and the secondary direct classification on grade labels. Grades arrive
as strings, which neither mutual information nor a rank correlation can
consume.
Sorted order is used for the encoding, which for the grade labels A/B/C
yields 0/1/2 -- their true ordinal order, most demanding first. That is
meaningful rather than arbitrary here, so a rank statistic computed on the
codes measures something real.
"""
array = np.asarray(y).ravel()
if array.dtype.kind in "biufc":
return array.astype("float64"), False
categories = np.unique(array)
lookup = {value: index for index, value in enumerate(categories)}
return np.array([lookup[v] for v in array], dtype="float64"), True
def _abs_spearman_matrix(X: np.ndarray, y: np.ndarray) -> np.ndarray:
"""|Spearman rho| between every column of ``X`` and ``y``, vectorised.
Spearman is Pearson on ranks, so ranking each column and then taking a
single centred, normalised matrix product is EXACTLY equivalent to calling
a Spearman routine per column -- it is a speed change, not an approximation.
Ties are handled by ``rankdata``'s average method, matching
``scipy.stats.spearmanr``.
Columns that are constant, or all-NaN, score 0.0 rather than NaN: they carry
no information, and a NaN would propagate into the ranking.
"""
n_rows, n_cols = X.shape
if n_rows < 3:
return np.zeros(n_cols)
# NaNs cannot be ranked. Column medians keep the column usable without
# inventing signal, and a fully-NaN column is zeroed out below anyway.
if np.isnan(X).any():
medians = np.nanmedian(np.where(np.isfinite(X), X, np.nan), axis=0)
medians = np.where(np.isfinite(medians), medians, 0.0)
X = np.where(np.isfinite(X), X, medians)
ranks_x = stats.rankdata(X, axis=0)
ranks_y = stats.rankdata(y)
xc = ranks_x - ranks_x.mean(axis=0)
yc = ranks_y - ranks_y.mean()
x_norm = np.sqrt((xc**2).sum(axis=0))
y_norm = np.sqrt((yc**2).sum())
with np.errstate(invalid="ignore", divide="ignore"):
rho = (xc * yc[:, None]).sum(axis=0) / (x_norm * y_norm)
return np.nan_to_num(np.abs(rho), nan=0.0, posinf=0.0, neginf=0.0)
class _FrameAwareSelector(BaseEstimator, TransformerMixin):
"""Base class that preserves feature names across transforms."""
def _record_names(self, X) -> np.ndarray:
if isinstance(X, pd.DataFrame):
self.feature_names_in_ = np.asarray(X.columns)
else:
self.feature_names_in_ = np.asarray([f"x{i}" for i in range(np.shape(X)[1])])
return self.feature_names_in_
def _subset(self, X):
if isinstance(X, pd.DataFrame):
return X.loc[:, self.support_]
return np.asarray(X)[:, self.support_]
def get_feature_names_out(self, input_features=None) -> np.ndarray:
check_is_fitted(self, "support_")
return self.feature_names_in_[self.support_]
class VarianceFilter(_FrameAwareSelector):
"""Drop near-constant and all-missing columns.
A column with no variance in the TRAINING fold cannot inform a model fitted
on that fold. This also removes the degenerate columns the builder
deliberately leaves in place (``policy_c3`` is all-NaN in this corpus and
``policy_n_steps`` is constant), which is where that decision belongs.
"""
def __init__(self, threshold: float = 1e-8):
self.threshold = threshold
def fit(self, X, y=None):
names = self._record_names(X)
frame = pd.DataFrame(X, columns=names)
variances = frame.var(axis=0, skipna=True)
all_missing = frame.isna().all(axis=0)
self.support_ = (variances > self.threshold).to_numpy() & ~all_missing.to_numpy()
if not self.support_.any(): # never hand an empty matrix downstream
self.support_ = np.ones(len(names), dtype=bool)
return self
def transform(self, X):
check_is_fitted(self, "support_")
return self._subset(X)
class CorrelationFilter(_FrameAwareSelector):
"""Drop one of every pair of features correlated above a threshold.
WHY it matters here specifically: the curve features are collinear by
construction -- ``dq_var`` and ``log_abs_dq_var`` are monotone transforms of
one another, and several ΔQ(V) statistics move together. Severe collinearity
destabilises linear-model coefficients, which in turn makes the SHAP
attributions of Phase 9 unstable, and an unstable attribution cannot support
an auditable scrap decision.
Of each correlated pair the LATER column is dropped, so the ordering of the
feature registry determines precedence deterministically rather than by
whichever the correlation matrix happened to list first.
"""
def __init__(self, threshold: float = 0.95):
self.threshold = threshold
def fit(self, X, y=None):
names = self._record_names(X)
frame = pd.DataFrame(X, columns=names)
# .copy() because pandas returns a read-only view here, which
# np.fill_diagonal cannot write into.
corr = frame.corr(method="spearman").abs().to_numpy().copy()
np.fill_diagonal(corr, 0.0)
corr = np.nan_to_num(corr, nan=0.0)
keep = np.ones(len(names), dtype=bool)
for j in range(len(names)):
if not keep[j]:
continue
for k in range(j + 1, len(names)):
if keep[k] and corr[j, k] > self.threshold:
keep[k] = False
self.support_ = keep
return self
def transform(self, X):
check_is_fitted(self, "support_")
return self._subset(X)
class MutualInfoSelector(_FrameAwareSelector):
"""Keep the top-k features by mutual information with the target.
Mutual information rather than a linear correlation because several
relationships here are monotone but not linear -- cycle life against
resistance is a clear case. Requires ``y``, so it can only ever be fitted
where labels are legitimately available, i.e. the training fold.
"""
def __init__(self, k: int | None = None, n_neighbors: int = 3, random_state: int = 42):
self.k = k
self.n_neighbors = n_neighbors
self.random_state = random_state
def fit(self, X, y=None):
names = self._record_names(X)
if y is None:
raise ValueError("MutualInfoSelector requires y; it cannot be fitted unsupervised.")
frame = pd.DataFrame(X, columns=names)
# MI cannot consume NaN. Impute with training-fold medians for the
# ranking only; the returned mask is applied to the untouched data.
filled = frame.fillna(frame.median(numeric_only=True)).fillna(0.0)
# The selectors serve both project routes, so the estimator must match
# the target type: mutual_info_regression cannot consume the string
# grade labels the secondary classification route supplies.
target, is_categorical = _encode_target(y)
estimator = mutual_info_classif if is_categorical else mutual_info_regression
scores = estimator(
filled, target.astype("int64") if is_categorical else target,
n_neighbors=self.n_neighbors, random_state=self.random_state,
)
self.scores_ = scores
k = len(names) if self.k is None else min(self.k, len(names))
order = np.argsort(scores)[::-1][:k]
self.support_ = np.zeros(len(names), dtype=bool)
self.support_[order] = True
return self
def transform(self, X):
check_is_fitted(self, "support_")
return self._subset(X)
class StabilitySelector(_FrameAwareSelector):
"""Keep features selected in a sufficient fraction of bootstrap resamples.
WHY, at n = 124: a single fit will happily rank a noise feature highly by
chance. Requiring a feature to survive repeated resampling of the TRAINING
fold is a far stronger criterion and is the standard remedy for selection
instability in the small-n regime. All resampling happens strictly within
whatever data ``fit`` receives.
"""
def __init__(
self,
n_bootstrap: int = 100,
sample_fraction: float = 0.75,
selection_frequency_threshold: float = 0.6,
k_per_bootstrap: int | None = None,
random_state: int = 42,
):
self.n_bootstrap = n_bootstrap
self.sample_fraction = sample_fraction
self.selection_frequency_threshold = selection_frequency_threshold
self.k_per_bootstrap = k_per_bootstrap
self.random_state = random_state
def fit(self, X, y=None):
names = self._record_names(X)
if y is None:
raise ValueError("StabilitySelector requires y.")
frame = pd.DataFrame(X, columns=names)
filled = frame.fillna(frame.median(numeric_only=True)).fillna(0.0)
# Grade labels are encoded to their ordinal codes so the rank statistic
# below is meaningful; see _encode_target.
target, _ = _encode_target(y)
rng = np.random.default_rng(self.random_state)
n_samples = len(filled)
draw = max(5, int(round(self.sample_fraction * n_samples)))
k = self.k_per_bootstrap or max(1, len(names) // 2)
counts = np.zeros(len(names), dtype=float)
for _ in range(self.n_bootstrap):
idx = rng.choice(n_samples, size=draw, replace=False)
sub, sub_y = filled.iloc[idx], target[idx]
if len(np.unique(sub_y)) < 3:
continue
# Spearman |rho| as the per-bootstrap ranking statistic: robust to
# the monotone-but-nonlinear relationships this corpus contains.
#
# Computed VECTORISED over all features at once, and on NUMPY
# arrays. Both details matter:
#
# * Vectorised because the loop version issued one scipy call per
# feature per bootstrap -- 5,700 calls per fit -- and dominated
# the Phase 6 sweep runtime. Spearman is Pearson on ranks, so
# ranking each column once per bootstrap and taking a single
# matrix product is exactly equivalent, not an approximation.
# * On arrays because pandas correlates on the INDEX: the bootstrap
# subset keeps the original row labels while the target gets a
# fresh RangeIndex, so ``Series.corr`` silently mis-pairs them.
# That bug scored dq_var at |rho| = 0.02 against a true 0.91,
# demoting the strongest predictor in the project to noise.
scores = _abs_spearman_matrix(sub.to_numpy(dtype="float64"), sub_y)
counts[np.argsort(scores)[::-1][:k]] += 1.0
self.selection_frequency_ = counts / max(self.n_bootstrap, 1)
self.support_ = self.selection_frequency_ >= self.selection_frequency_threshold
if not self.support_.any():
# Never return zero features: fall back to the most stable ones.
top = np.argsort(self.selection_frequency_)[::-1][:max(1, len(names) // 4)]
self.support_ = np.zeros(len(names), dtype=bool)
self.support_[top] = True
return self
def transform(self, X):
check_is_fitted(self, "support_")
return self._subset(X)
def build_selection_pipeline(bundle: ConfigBundle, *, include_scaler: bool = True) -> Pipeline:
"""Compose the configured preprocessing and selection steps into a Pipeline.
The returned object is intended to be the front of a model pipeline, so that
imputation, scaling and selection are all fitted on the training fold only.
Ordering rationale: variance and correlation filters are unsupervised and
run first to shrink the problem cheaply; the supervised steps (mutual
information, stability selection) run afterwards on the reduced set.
Imputation precedes them because the supervised selectors cannot consume
NaN, and it uses the median, which is robust to the residual outliers the
plausibility mask does not catch.
"""
selection = bundle.features.selection
steps: list[tuple[str, object]] = [
# keep_empty_features=True is load-bearing, not a default tweak. Without
# it SimpleImputer silently DROPS all-NaN columns, so the matrix leaving
# this step has fewer columns than the feature registry declares and
# every downstream selector's feature names shift out of alignment.
# Keeping the column (filled with zeros) lets the variance filter remove
# it explicitly, per fold, which is where that decision belongs.
("impute", SimpleImputer(strategy=bundle.features.preprocessing.impute_strategy,
keep_empty_features=True)),
("variance", VarianceFilter(threshold=selection.variance_threshold)),
("correlation", CorrelationFilter(threshold=selection.correlation_threshold)),
]
if selection.stability_selection.enabled:
stability = selection.stability_selection
steps.append((
"stability",
StabilitySelector(
n_bootstrap=stability.n_bootstrap,
sample_fraction=stability.sample_fraction,
selection_frequency_threshold=stability.selection_frequency_threshold,
random_state=bundle.models.seeds.global_,
),
))
if selection.mutual_information.enabled:
steps.append((
"mutual_info",
MutualInfoSelector(
n_neighbors=selection.mutual_information.n_neighbors,
random_state=bundle.models.seeds.global_,
),
))
if include_scaler and bundle.features.preprocessing.scaler == "standard":
steps.append(("scale", StandardScaler()))
pipeline = Pipeline(steps)
# Emit DataFrames between steps so feature names survive the whole chain.
# Without this, selectors after the first receive a bare ndarray and fall
# back to positional names, which would make the Phase 9 SHAP attributions
# impossible to map back to a physical quantity.
return pipeline.set_output(transform="pandas")