File size: 17,050 Bytes
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
"""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")