| 
 | 1 | +import numpy as np  | 
 | 2 | +from joblib import Parallel, delayed  | 
 | 3 | +from skglm.datafits import Logistic, QuadraticSVC  | 
 | 4 | +from skglm.estimators import GeneralizedLinearEstimator  | 
 | 5 | +from sklearn.model_selection import KFold, StratifiedKFold  | 
 | 6 | +from sklearn.metrics import accuracy_score, mean_squared_error  | 
 | 7 | + | 
 | 8 | + | 
 | 9 | +class GeneralizedLinearEstimatorCV(GeneralizedLinearEstimator):  | 
 | 10 | +    """Cross-validated wrapper for GeneralizedLinearEstimator.  | 
 | 11 | +
  | 
 | 12 | +    This class performs cross-validated selection of the regularization parameter(s)  | 
 | 13 | +    for a generalized linear estimator, supporting both L1 and elastic-net penalties.  | 
 | 14 | +
  | 
 | 15 | +    Parameters  | 
 | 16 | +    ----------  | 
 | 17 | +    datafit : object  | 
 | 18 | +        Datafit (loss) function instance (e.g., Logistic, Quadratic).  | 
 | 19 | +    penalty : object  | 
 | 20 | +        Penalty instance with an 'alpha' parameter (and optionally 'l1_ratio').  | 
 | 21 | +    solver : object  | 
 | 22 | +        Solver instance to use for optimization.  | 
 | 23 | +    alphas : array-like of shape (n_alphas,), optional  | 
 | 24 | +        List of alpha values to try. If None, they are set automatically.  | 
 | 25 | +    l1_ratio : float or array-like, optional  | 
 | 26 | +        The ElasticNet mixing parameter(s), with 0 <= l1_ratio <= 1.  | 
 | 27 | +        Only used if the penalty supports 'l1_ratio'. If None, defaults to 1.0 (Lasso).  | 
 | 28 | +    cv : int, default=4  | 
 | 29 | +        Number of cross-validation folds.  | 
 | 30 | +    n_jobs : int, default=1  | 
 | 31 | +        Number of jobs to run in parallel for cross-validation.  | 
 | 32 | +    random_state : int or None, default=None  | 
 | 33 | +        Random seed for cross-validation splitting.  | 
 | 34 | +    eps : float, default=1e-3  | 
 | 35 | +        Ratio of minimum to maximum alpha if alphas are set automatically.  | 
 | 36 | +    n_alphas : int, default=100  | 
 | 37 | +        Number of alphas along the regularization path if alphas are set automatically.  | 
 | 38 | +
  | 
 | 39 | +    Attributes  | 
 | 40 | +    ----------  | 
 | 41 | +    alpha_ : float  | 
 | 42 | +        Best alpha found by cross-validation.  | 
 | 43 | +    l1_ratio_ : float or None  | 
 | 44 | +        Best l1_ratio found by cross-validation (if applicable).  | 
 | 45 | +    best_estimator_ : GeneralizedLinearEstimator  | 
 | 46 | +        Estimator fitted on the full data with the best parameters.  | 
 | 47 | +    coef_ : ndarray  | 
 | 48 | +        Coefficients of the fitted model.  | 
 | 49 | +    intercept_ : float or ndarray  | 
 | 50 | +        Intercept of the fitted model.  | 
 | 51 | +    alphas_ : ndarray  | 
 | 52 | +        Array of alphas used in the search.  | 
 | 53 | +    scores_path_ : ndarray  | 
 | 54 | +        Cross-validation scores for each parameter combination.  | 
 | 55 | +    n_iter_ : int or None  | 
 | 56 | +        Number of iterations run by the solver (if available).  | 
 | 57 | +    n_features_in_ : int or None  | 
 | 58 | +        Number of features seen during fit.  | 
 | 59 | +    feature_names_in_ : ndarray or None  | 
 | 60 | +        Names of features seen during fit.  | 
 | 61 | +    """  | 
 | 62 | + | 
 | 63 | +    def __init__(self, datafit, penalty, solver, alphas=None, l1_ratio=None,  | 
 | 64 | +                 cv=4, n_jobs=1, random_state=None,  | 
 | 65 | +                 eps=1e-3, n_alphas=100):  | 
 | 66 | +        super().__init__(datafit=datafit, penalty=penalty, solver=solver)  | 
 | 67 | +        self.alphas = alphas  | 
 | 68 | +        self.l1_ratio = l1_ratio  | 
 | 69 | +        self.cv = cv  | 
 | 70 | +        self.n_jobs = n_jobs  | 
 | 71 | +        self.random_state = random_state  | 
 | 72 | +        self.eps = eps  | 
 | 73 | +        self.n_alphas = n_alphas  | 
 | 74 | + | 
 | 75 | +    def _score(self, y_true, y_pred):  | 
 | 76 | +        """Compute the performance score (higher is better)."""  | 
 | 77 | +        if isinstance(self.datafit, (Logistic, QuadraticSVC)):  | 
 | 78 | +            return accuracy_score(y_true, y_pred)  | 
 | 79 | +        return -mean_squared_error(y_true, y_pred)  | 
 | 80 | + | 
 | 81 | +    def fit(self, X, y):  | 
 | 82 | +        """Fit the model using cross-validation."""  | 
 | 83 | +        if not hasattr(self.penalty, "alpha"):  | 
 | 84 | +            raise ValueError(  | 
 | 85 | +                "GeneralizedLinearEstimatorCV only supports penalties which "  | 
 | 86 | +                "expose an 'alpha' parameter."  | 
 | 87 | +            )  | 
 | 88 | +        n_samples, n_features = X.shape  | 
 | 89 | + | 
 | 90 | +        if self.alphas is not None:  | 
 | 91 | +            alphas = np.sort(self.alphas)[::-1]  | 
 | 92 | +        else:  | 
 | 93 | +            alpha_max = np.max(np.abs(X.T @ y)) / n_samples  | 
 | 94 | +            alphas = np.geomspace(  | 
 | 95 | +                alpha_max,  | 
 | 96 | +                alpha_max * self.eps,  | 
 | 97 | +                self.n_alphas  | 
 | 98 | +            )  | 
 | 99 | +        has_l1_ratio = hasattr(self.penalty, "l1_ratio")  | 
 | 100 | +        l1_ratios = [1.] if not has_l1_ratio else np.atleast_1d(  | 
 | 101 | +            self.l1_ratio if self.l1_ratio is not None else [1.])  | 
 | 102 | + | 
 | 103 | +        scores_path = np.empty((len(l1_ratios), len(alphas), self.cv))  | 
 | 104 | +        best_loss = -np.inf  | 
 | 105 | + | 
 | 106 | +        def _solve_fold(k, train, test, alpha, l1, w_init):  | 
 | 107 | +            pen_kwargs = {k: v for k, v in self.penalty.__dict__.items()  | 
 | 108 | +                          if k not in ("alpha", "l1_ratio")}  | 
 | 109 | +            if has_l1_ratio:  | 
 | 110 | +                pen_kwargs['l1_ratio'] = l1  | 
 | 111 | +            pen = type(self.penalty)(alpha=alpha, **pen_kwargs)  | 
 | 112 | + | 
 | 113 | +            est = GeneralizedLinearEstimator(  | 
 | 114 | +                datafit=self.datafit, penalty=pen, solver=self.solver  | 
 | 115 | +            )  | 
 | 116 | +            if w_init is not None:  | 
 | 117 | +                est.coef_ = w_init[0]  | 
 | 118 | +                est.intercept_ = w_init[1]  | 
 | 119 | +            est.fit(X[train], y[train])  | 
 | 120 | +            y_pred = est.predict(X[test])  | 
 | 121 | +            return est.coef_, est.intercept_, self._score(y[test], y_pred)  | 
 | 122 | + | 
 | 123 | +        for idx_ratio, l1_ratio in enumerate(l1_ratios):  | 
 | 124 | +            warm_start = [None] * self.cv  | 
 | 125 | + | 
 | 126 | +            for idx_alpha, alpha in enumerate(alphas):  | 
 | 127 | +                if isinstance(self.datafit, (Logistic, QuadraticSVC)):  | 
 | 128 | +                    kf = StratifiedKFold(n_splits=self.cv, shuffle=True,  | 
 | 129 | +                                         random_state=self.random_state)  | 
 | 130 | +                    split_iter = kf.split(np.arange(n_samples), y)  | 
 | 131 | +                else:  | 
 | 132 | +                    kf = KFold(n_splits=self.cv, shuffle=True,  | 
 | 133 | +                               random_state=self.random_state)  | 
 | 134 | +                    split_iter = kf.split(np.arange(n_samples))  | 
 | 135 | +                fold_result = Parallel(self.n_jobs)(  | 
 | 136 | +                    delayed(_solve_fold)(k, tr, te, alpha, l1_ratio, warm_start[k])  | 
 | 137 | +                    for k, (tr, te) in enumerate(split_iter)  | 
 | 138 | +                )  | 
 | 139 | + | 
 | 140 | +                for k, (coef_fold, intercept_fold, loss_fold) in enumerate(fold_result):  | 
 | 141 | +                    warm_start[k] = (coef_fold, intercept_fold)  | 
 | 142 | +                    scores_path[idx_ratio, idx_alpha, k] = loss_fold  | 
 | 143 | + | 
 | 144 | +                mean_loss = np.mean(scores_path[idx_ratio, idx_alpha])  | 
 | 145 | +                if mean_loss > best_loss:  | 
 | 146 | +                    best_loss = mean_loss  | 
 | 147 | +                    self.alpha_ = float(alpha)  | 
 | 148 | +                    self.l1_ratio_ = float(l1_ratio) if has_l1_ratio else None  | 
 | 149 | + | 
 | 150 | +        # Refit on full dataset  | 
 | 151 | +        pen_kwargs = {k: v for k, v in self.penalty.__dict__.items()  | 
 | 152 | +                      if k not in ("alpha", "l1_ratio")}  | 
 | 153 | +        if has_l1_ratio:  | 
 | 154 | +            pen_kwargs["l1_ratio"] = self.l1_ratio_  | 
 | 155 | +        best_penalty = type(self.penalty)(  | 
 | 156 | +            alpha=self.alpha_, **pen_kwargs  | 
 | 157 | +        )  | 
 | 158 | +        best_estimator = GeneralizedLinearEstimator(  | 
 | 159 | +            datafit=self.datafit,  | 
 | 160 | +            penalty=best_penalty,  | 
 | 161 | +            solver=self.solver  | 
 | 162 | +        )  | 
 | 163 | +        best_estimator.fit(X, y)  | 
 | 164 | +        self.best_estimator_ = best_estimator  | 
 | 165 | +        self.coef_ = best_estimator.coef_  | 
 | 166 | +        self.intercept_ = best_estimator.intercept_  | 
 | 167 | +        self.n_iter_ = getattr(best_estimator, "n_iter_", None)  | 
 | 168 | +        self.n_features_in_ = getattr(best_estimator, "n_features_in_", None)  | 
 | 169 | +        self.feature_names_in_ = getattr(best_estimator, "feature_names_in_", None)  | 
 | 170 | +        self.alphas_ = alphas  | 
 | 171 | +        self.scores_path_ = np.squeeze(scores_path)  | 
 | 172 | +        return self  | 
 | 173 | + | 
 | 174 | +    def predict(self, X):  | 
 | 175 | +        return self.best_estimator_.predict(X)  | 
 | 176 | + | 
 | 177 | +    def predict_proba(self, X):  | 
 | 178 | +        return self.best_estimator_.predict_proba(X)  | 
 | 179 | + | 
 | 180 | +    def score(self, X, y):  | 
 | 181 | +        return self.best_estimator_.score(X, y)  | 
0 commit comments