Standardization and CV leakage

sparho’s sklearn-compatible wrappers (LassoHO, ElasticNetHO, LogisticRegressionHO) deliberately ship without a standardize= / normalize= parameter. Feature scaling is composed externally via sklearn.pipeline.Pipeline and sklearn.preprocessing.StandardScaler. This page covers the recommended recipe and one subtle leakage trap that follows from putting cross-validation inside the wrapper while feature scaling sits outside.

Why no standardize= parameter

This decision (made 2026-05-20) follows sklearn’s post-1.0 stance after the normalize= deprecation (sklearn#21238, sklearn#26359). Carrying a built-in standardize=True would:

  • Replicate the historical normalize= ambiguity (centered vs scaled? per fold or per fit? before or after train/val split?).

  • Make α* not directly comparable to sklearn Lasso’s α*.

  • Encourage users to skip the explicit feature-engineering step that the sklearn ecosystem now expects as Pipeline composition.

The audience is sklearn-refugees, not glmnet-refugees. Users who want glmnet-style on-by-default standardization should compose a Pipeline.

The leakage trap: nested CV inside an outer Pipeline

LassoHO’s default criterion is a 5-fold CrossVal(HeldOutMSE) over the training data. When the wrapper sits inside a Pipeline that has a StandardScaler upstream of it, every CV fold sees data that was scaled using the full training set’s statistics — not the fold-train statistics. This is exactly the leakage sklearn#26359 describes for LassoCV inside a Pipeline.

For most use cases the leakage is small (StandardScaler is robust under moderate fold-to-fold variation). When it matters — small n, heavy tails, leakage-sensitive downstream evaluation — there are two safe patterns:

1. Move scaling inside each fold via outer CV

If the goal is honest CV-based generalization estimation, wrap the whole pipeline in sklearn.model_selection.cross_validate and let LassoHO use a single held-out criterion for its α search:

from sklearn.model_selection import cross_validate, KFold
from sparho import HeldOutMSE, LassoHO

# Use a fixed held-out split inside LassoHO; the outer cross_validate
# rotates the Pipeline (including the scaler) across folds.
rng = np.random.default_rng(0)
perm = rng.permutation(len(y))
n_inner = int(0.8 * len(y))
idx_train_inner, idx_val_inner = perm[:n_inner].astype(np.int32), perm[n_inner:].astype(np.int32)

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LassoHO(
        criterion=HeldOutMSE(idx_train_inner, idx_val_inner),
        alpha_init=0.1,
        n_iter=30,
    )),
])
cv_scores = cross_validate(pipe, X, y, cv=KFold(5), scoring="r2")

Recap

Setup

α* comparable to sklearn Lasso?

Leakage-safe?

LassoHO(fit_intercept=True) on raw X

✅ (no scaler)

Pipeline([StandardScaler, LassoHO])

⚠️ (α* now scaled-space)

⚠️ (internal CV sees pre-scaled X)

Outer cross_validate(Pipeline) + internal HeldOutMSE

⚠️

Manual one-time StandardScaler.fit_transform + LassoHO

⚠️

The wrapper’s job is to make the bilevel search work; the Pipeline boundary and the choice of criterion are the user’s controls for honesty.