From bde6f97cbf11d27a8a904eb1801b81fb2474a95b Mon Sep 17 00:00:00 2001 From: Leona <129308028+Leona-LYT@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:56:44 +0800 Subject: [PATCH 1/3] add classifier and regressor for plq_ElasticNet --- rehline/__init__.py | 4 +- rehline/_sklearn_mixin.py | 545 +++++++++++++++++++++++++++++++++++++- 2 files changed, 547 insertions(+), 2 deletions(-) diff --git a/rehline/__init__.py b/rehline/__init__.py index 270a8a3..c60b729 100644 --- a/rehline/__init__.py +++ b/rehline/__init__.py @@ -13,7 +13,7 @@ from ._loss import ReHLoss from ._mf_class import plqMF_Ridge from ._path_sol import plqERM_Ridge_path_sol -from ._sklearn_mixin import plq_Ridge_Classifier, plq_Ridge_Regressor +from ._sklearn_mixin import plq_Ridge_Classifier, plq_Ridge_Regressor, plq_ElasticNet_Classifier, plq_ElasticNet_Regressor __all__ = ( "_BaseReHLine", @@ -26,6 +26,8 @@ "plqERM_Ridge_path_sol", "plq_Ridge_Classifier", "plq_Ridge_Regressor", + "plq_ElasticNet_Classifier", + "plq_ElasticNet_Regressor", "_make_loss_rehline_param", "_make_constraint_rehline_param", "make_mf_dataset", diff --git a/rehline/_sklearn_mixin.py b/rehline/_sklearn_mixin.py index 0b2fffa..68ba90b 100644 --- a/rehline/_sklearn_mixin.py +++ b/rehline/_sklearn_mixin.py @@ -9,7 +9,7 @@ from sklearn.utils.multiclass import check_classification_targets from sklearn.utils.validation import check_array, check_is_fitted, check_X_y -from ._class import plqERM_Ridge +from ._class import plqERM_Ridge, plqERM_ElasticNet class plq_Ridge_Classifier(plqERM_Ridge, ClassifierMixin): @@ -677,3 +677,546 @@ def __sklearn_tags__(self): tags.input_tags.sparse = False tags.target_tags.required = True return tags + + +class plq_ElasticNet_Classifier(plqERM_ElasticNet, ClassifierMixin): + """ + Empirical Risk Minimization (ERM) Classifier with a Piecewise Linear-Quadratic (PLQ) loss + and elastic net penalty, compatible with the scikit-learn API. + + This wrapper makes ``plqERM_ElasticNet`` behave as a classifier: + - Accepts arbitrary binary labels in the original label space. + - Computes class weights on original labels (if ``class_weight`` is set). + - Encodes labels with ``LabelEncoder`` into {0,1}, then maps to {-1,+1} for training. + - Supports optional intercept fitting (via an augmented constant feature). + - Provides standard methods ``fit``, ``predict``, and ``decision_function``. + - Integrates with scikit-learn ecosystem (e.g., GridSearchCV, Pipeline). + - Supports multiclass classification via OvR or OvO method. + + Parameters + ---------- + loss : dict + Dictionary specifying the loss function parameters. Examples include: + - {'name': 'svm'} + - {'name': 'sSVM'} + - {'name': 'huber'} + and other PLQ losses supported by ``plqERM_ElasticNet``. + + constraint : list of dict, default=[] + Optional constraints. Each dictionary must include a ``'name'`` key. + + C : float, default=1.0 + Inverse regularization strength (scales the loss term). + + l1_ratio : float, default=0.5 + The ElasticNet mixing parameter, 0 <= l1_ratio < 1. + - l1_ratio = 0 → pure Ridge (equivalent to plq_Ridge_Classifier) + - 0 < l1_ratio < 1 → combined L1 + L2 penalty + Must be strictly less than 1.0 to avoid division by zero in rho/C_eff. + + fit_intercept : bool, default=True + Whether to fit an intercept term via an augmented constant feature column. + + intercept_scaling : float, default=1.0 + Value of the constant feature column when ``fit_intercept=True``. + + class_weight : dict, 'balanced', or None, default=None + Class weights applied like in LinearSVC. + + multi_class : str or list, default=[] + Method for multiclass classification: + - 'ovr': One-vs-Rest + - 'ovo': One-vs-One + - [] or ignored when only 2 classes are present. + + n_jobs : int or None, default=None + Number of parallel jobs for multiclass fitting. + + max_iter : int, default=1000 + tol : float, default=1e-4 + shrink : int, default=1 + warm_start : int, default=0 + verbose : int, default=0 + trace_freq : int, default=100 + + Attributes + ---------- + coef_ : ndarray of shape (n_features,) for binary, (n_estimators, n_features) for multiclass + intercept_ : float for binary, ndarray of shape (n_estimators,) for multiclass + classes_ : ndarray of shape (n_classes,) + estimators_ : list, only present for multiclass + _label_encoder : LabelEncoder + """ + + def __init__( + self, + loss, + constraint=[], + C=1.0, + l1_ratio=0.5, + U=np.empty((0, 0)), + V=np.empty((0, 0)), + Tau=np.empty((0, 0)), + S=np.empty((0, 0)), + T=np.empty((0, 0)), + A=np.empty((0, 0)), + b=np.empty((0,)), + max_iter=1000, + tol=1e-4, + shrink=1, + warm_start=0, + verbose=0, + trace_freq=100, + fit_intercept=True, + intercept_scaling=1.0, + class_weight=None, + multi_class=[], + n_jobs=None, + ): + if not (0.0 <= l1_ratio < 1.0): + raise ValueError( + f"l1_ratio must be in [0, 1), got {l1_ratio}. " + f"Use l1_ratio=0 for pure Ridge, or plq_Ridge_Classifier directly." + ) + + super().__init__( + loss=loss, + constraint=constraint, + C=C, + l1_ratio=l1_ratio, + U=U, V=V, Tau=Tau, S=S, T=T, + A=A, b=b, + max_iter=max_iter, + tol=tol, + shrink=shrink, + warm_start=warm_start, + verbose=verbose, + trace_freq=trace_freq, + ) + self.fit_intercept = fit_intercept + self.intercept_scaling = float(intercept_scaling) + self.class_weight = class_weight + self._label_encoder = None + self.classes_ = None + self.multi_class = multi_class + self.n_jobs = n_jobs + + @staticmethod + def _fit_subproblem(estimator, X_aug, y_pm, sample_weight, fit_intercept): + """ + Train a plqERM_ElasticNet instance on a single multiclass subproblem. + + Directly constructs plqERM_ElasticNet from estimator's hyperparameters, + bypassing plq_ElasticNet_Classifier.fit() preprocessing (LabelEncoder, + intercept augmentation) since X_aug and y_pm are already preprocessed. + + Parameters + ---------- + estimator : plq_ElasticNet_Classifier + Source estimator from which hyperparameters are extracted. + + X_aug : ndarray of shape (n_samples, n_features[+1]) + Already preprocessed feature matrix (intercept column included if needed). + + y_pm : ndarray of shape (n_samples,) + Binary labels already in {-1, +1}. + + sample_weight : ndarray or None + + fit_intercept : bool + + Returns + ------- + coef : ndarray of shape (n_features,) + intercept : float + """ + clf = plqERM_ElasticNet( + loss=estimator.loss, + constraint=estimator.constraint, + C=estimator.C, + l1_ratio=estimator.l1_ratio, + max_iter=estimator.max_iter, + tol=estimator.tol, + shrink=estimator.shrink, + warm_start=estimator.warm_start, + verbose=estimator.verbose, + trace_freq=estimator.trace_freq, + ) + clf.fit(X_aug, y_pm, sample_weight=sample_weight) + if fit_intercept: + coef = clf.coef_[:-1].copy() + intercept = float(clf.coef_[-1]) + else: + coef = clf.coef_.copy() + intercept = 0.0 + return coef, intercept + + def fit(self, X, y, sample_weight=None): + """ + Fit the classifier to training data. + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + y : array-like of shape (n_samples,) + sample_weight : array-like of shape (n_samples,), default=None + + Returns + ------- + self + """ + X, y = check_X_y(X, y, accept_sparse=False, dtype=np.float64, order="C") + self.n_features_in_ = X.shape[1] + + check_classification_targets(y) + + self.classes_ = np.unique(y) + if self.classes_.size < 2: + raise ValueError( + f"plq_ElasticNet_Classifier requires at least 2 classes, " + f"but received {self.classes_.size} class(es): {self.classes_}." + ) + + # Compute class weights on original labels + if self.class_weight is not None: + cw_vec = compute_class_weight( + class_weight=self.class_weight, + classes=self.classes_, + y=y, + ) + cw_map = {c: w for c, w in zip(self.classes_, cw_vec)} + sw_cw = np.asarray([cw_map[yi] for yi in y], dtype=np.float64) + sample_weight = ( + sw_cw if sample_weight is None else (np.asarray(sample_weight) * sw_cw) + ) + + le = LabelEncoder().fit(self.classes_) + self._label_encoder = le + + # Intercept augmentation + X_aug = X + if self.fit_intercept: + col = np.full((X.shape[0], 1), self.intercept_scaling, dtype=X.dtype) + X_aug = np.hstack([X, col]) + + if self.classes_.size == 2: + y01 = le.transform(y) + y_pm = 2 * y01 - 1 + + # super() resolves to plqERM_ElasticNet.fit() + super().fit(X_aug, y_pm, sample_weight=sample_weight) + + if self.fit_intercept: + self.intercept_ = float(self.coef_[-1]) + self.coef_ = self.coef_[:-1].copy() + else: + self.intercept_ = 0.0 + + else: + if self.multi_class not in ('ovr', 'ovo'): + raise ValueError( + f"multi_class must be 'ovr' or 'ovo' for multiclass problems, " + f"got '{self.multi_class}'." + ) + self._fit_multiclass(X_aug, y, sample_weight) + + return self + + def _fit_multiclass(self, X_aug, y, sample_weight=None): + """ + Fit multiple binary classifiers for multiclass classification. + Identical logic to plq_Ridge_Classifier._fit_multiclass; dispatches + to self._fit_subproblem which uses plqERM_ElasticNet internally. + """ + if self.multi_class == 'ovr': + tasks = [ + (X_aug, np.where(y == cls, 1, -1).astype(np.float64), sample_weight) + for cls in self.classes_ + ] + class_pairs = None + + elif self.multi_class == 'ovo': + tasks = [] + class_pairs = [] + for cls_i, cls_j in combinations(self.classes_, 2): + mask = np.isin(y, [cls_i, cls_j]) + y_pm = np.where(y[mask] == cls_j, 1, -1).astype(np.float64) + sw_sub = sample_weight[mask] if sample_weight is not None else None + tasks.append((X_aug[mask], y_pm, sw_sub)) + class_pairs.append((cls_i, cls_j)) + + results = Parallel(n_jobs=self.n_jobs, prefer="threads")( + delayed(self._fit_subproblem)(self, X_sub, y_pm, sw, self.fit_intercept) + for X_sub, y_pm, sw in tasks + ) + + if self.multi_class == 'ovr': + self.estimators_ = [(coef, intercept) for coef, intercept in results] + elif self.multi_class == 'ovo': + self.estimators_ = [ + (coef, intercept, cls_i, cls_j) + for (coef, intercept), (cls_i, cls_j) in zip(results, class_pairs) + ] + + self.coef_ = np.array([e[0] for e in self.estimators_]) + self.intercept_ = np.array([e[1] for e in self.estimators_]) + + def decision_function(self, X): + """ + Compute the decision function for samples in X. + + For binary: 1D array of shape (n_samples,). + For OvR/OvO multiclass: 2D array of shape (n_samples, n_estimators). + """ + check_is_fitted( + self, attributes=["coef_", "intercept_", "_label_encoder", "classes_"] + ) + X = check_array(X, accept_sparse=False, dtype=np.float64, order="C") + return X @ self.coef_.T + self.intercept_ + + def predict(self, X): + """ + Predict class labels for samples in X. + + Binary: threshold at 0. + OvR: argmax across K classifiers. + OvO: majority vote + normalized confidence tie-breaking. + """ + scores = self.decision_function(X) + + if self.classes_.size == 2: + pred01 = (scores >= 0).astype(int) + return self._label_encoder.inverse_transform(pred01) + + elif self.multi_class == 'ovr': + idx = np.argmax(scores, axis=1) + return self.classes_[idx] + + elif self.multi_class == 'ovo': + n_samples = X.shape[0] + n_classes = len(self.classes_) + votes = np.zeros((n_samples, n_classes), dtype=np.float64) + sum_of_confidences = np.zeros((n_samples, n_classes), dtype=np.float64) + + for k, (_, _, cls_i, cls_j) in enumerate(self.estimators_): + i = np.where(self.classes_ == cls_i)[0][0] + j = np.where(self.classes_ == cls_j)[0][0] + + pred = (scores[:, k] > 0).astype(int) + votes[:, j] += pred + votes[:, i] += 1 - pred + + sum_of_confidences[:, j] += scores[:, k] + sum_of_confidences[:, i] -= scores[:, k] + + transformed_confidences = sum_of_confidences / ( + 3 * (np.abs(sum_of_confidences) + 1) + ) + return self.classes_[np.argmax(votes + transformed_confidences, axis=1)] + + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.estimator_type = "classifier" + tags.classifier_tags = ClassifierTags() + tags.target_tags.required = True + tags.input_tags.sparse = False + return tags + +class plq_ElasticNet_Regressor(plqERM_ElasticNet, RegressorMixin): + """ + Empirical Risk Minimization (ERM) regressor with a Piecewise Linear-Quadratic (PLQ) loss + and an elastic net penalty, implemented as a scikit-learn compatible estimator. + + This wrapper makes ``plqERM_ElasticNet`` behave as a regressor: + - Supports optional intercept fitting via an augmented constant feature column. + - Provides standard methods ``fit``, ``predict``, and ``decision_function``. + - Integrates with the scikit-learn ecosystem (e.g., GridSearchCV, Pipeline). + + Notes + ----- + - **Intercept handling**: if ``fit_intercept=True``, a constant column + (value = ``intercept_scaling``) is appended to the right of the design + matrix before calling the base solver. The last learned coefficient is + then split out as ``intercept_``. + Original feature indices are therefore unaffected; ``sen_idx`` in a + ``'fair'`` constraint continues to reference the original columns. + - **Sparse input**: not supported. Convert to dense before fitting. + + Parameters + ---------- + loss : dict, default={'name': 'QR', 'qt': 0.5} + PLQ loss configuration. Examples: + ``{'name': 'QR', 'qt': 0.5}``, ``{'name': 'huber', 'tau': 1.0}``, + ``{'name': 'SVR', 'epsilon': 0.1}``. + + constraint : list of dict, default=[] + Constraint specifications: + - ``{'name': 'nonnegative'}`` or ``{'name': '>=0'}`` + - ``{'name': 'fair', 'sen_idx': list[int], 'tol_sen': list[float]}`` + - ``{'name': 'custom', 'A': ndarray[K, d], 'b': ndarray[K]}`` + + C : float, default=1.0 + Regularization parameter (scales the loss term). + + l1_ratio : float, default=0.5 + The ElasticNet mixing parameter, 0 <= l1_ratio < 1. + - l1_ratio = 0 → pure Ridge (equivalent to plq_Ridge_Regressor) + - 0 < l1_ratio < 1 → combined L1 + L2 penalty + Must be strictly less than 1.0 to avoid division by zero in rho/C_eff. + + fit_intercept : bool, default=True + If True, append a constant column (value = ``intercept_scaling``) to + the design matrix before solving. The last learned coefficient is then + extracted as ``intercept_``. + + intercept_scaling : float, default=1.0 + Scaling applied to the appended constant column when + ``fit_intercept=True``. + + max_iter : int, default=1000 + tol : float, default=1e-4 + shrink : int, default=1 + warm_start : int, default=0 + verbose : int, default=0 + trace_freq : int, default=100 + + Attributes + ---------- + coef_ : ndarray of shape (n_features,) + Learned linear coefficients (excluding the intercept term). + intercept_ : float + Intercept term. 0.0 if ``fit_intercept=False``. + n_features_in_ : int + Number of input features seen during :meth:`fit` (before intercept + augmentation). + """ + + def __init__( + self, + loss={"name": "QR", "qt": 0.5}, + constraint=[], + C=1.0, + l1_ratio=0.5, + U=np.empty((0, 0)), + V=np.empty((0, 0)), + Tau=np.empty((0, 0)), + S=np.empty((0, 0)), + T=np.empty((0, 0)), + A=np.empty((0, 0)), + b=np.empty((0,)), + max_iter=1000, + tol=1e-4, + shrink=1, + warm_start=0, + verbose=0, + trace_freq=100, + fit_intercept=True, + intercept_scaling=1.0, + ): + if not (0.0 <= l1_ratio < 1.0): + raise ValueError( + f"l1_ratio must be in [0, 1), got {l1_ratio}. " + f"Use l1_ratio=0 for pure Ridge, or plq_Ridge_Regressor directly." + ) + + super().__init__( + loss=loss, + constraint=constraint, + C=C, + l1_ratio=l1_ratio, + U=U, V=V, Tau=Tau, S=S, T=T, + A=A, b=b, + max_iter=max_iter, + tol=tol, + shrink=shrink, + warm_start=warm_start, + verbose=verbose, + trace_freq=trace_freq, + ) + self.fit_intercept = fit_intercept + self.intercept_scaling = float(intercept_scaling) + + def fit(self, X, y, sample_weight=None): + """ + Fit the regressor to training data. + + If ``fit_intercept=True``, a constant column (value = + ``intercept_scaling``) is appended to the right of ``X`` before + calling the base solver (``plqERM_ElasticNet.fit``). After solving, + the last coefficient is split as ``intercept_`` and removed from + ``coef_``. + + Parameters + ---------- + X : ndarray of shape (n_samples, n_features) + Training design matrix (dense). Sparse inputs are not supported. + y : ndarray of shape (n_samples,) + Target values. + sample_weight : ndarray of shape (n_samples,), default=None + Optional per-sample weights; forwarded to the underlying solver. + + Returns + ------- + self : object + Fitted estimator. + """ + X, y = check_X_y(X, y, accept_sparse=False, dtype=np.float64, order="C") + self.n_features_in_ = X.shape[1] + + X_aug = X + if self.fit_intercept: + col = np.full((X.shape[0], 1), self.intercept_scaling, dtype=X.dtype) + X_aug = np.hstack([X, col]) + + # MRO resolves super() to plqERM_ElasticNet.fit() + super().fit(X_aug, y, sample_weight=sample_weight) + + if self.fit_intercept: + self.intercept_ = float(self.coef_[-1]) + self.coef_ = self.coef_[:-1].copy() + else: + self.intercept_ = 0.0 + + return self + + def decision_function(self, X): + """ + Compute f(X) = X @ coef_ + intercept_. + + Parameters + ---------- + X : ndarray of shape (n_samples, n_features) + Input data (dense). + + Returns + ------- + scores : ndarray of shape (n_samples,) + Predicted real-valued scores. + """ + check_is_fitted(self, attributes=["coef_", "intercept_"]) + X = check_array(X, accept_sparse=False, dtype=np.float64, order="C") + return X @ self.coef_ + self.intercept_ + + def predict(self, X): + """ + Predict target values as the linear decision function. + + Parameters + ---------- + X : ndarray of shape (n_samples, n_features) + Input data (dense). + + Returns + ------- + y_pred : ndarray of shape (n_samples,) + Predicted target values (real-valued). + """ + return self.decision_function(X) + + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.estimator_type = "regressor" + tags.regressor_tags = RegressorTags() + tags.input_tags.sparse = False + tags.target_tags.required = True + return tags \ No newline at end of file From 0f9118f4c7c2f7cbe0a9f9ceff32fa6bd0b33b9f Mon Sep 17 00:00:00 2001 From: Leona <129308028+Leona-LYT@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:05:24 +0800 Subject: [PATCH 2/3] add tutorial for ElasticNet --- doc/source/example.rst | 2 + doc/source/examples/ElasticNet.ipynb | 1466 ++++++++++++++++++++++ doc/source/tutorials/ReHLine_sklearn.rst | 1 + 3 files changed, 1469 insertions(+) create mode 100644 doc/source/examples/ElasticNet.ipynb diff --git a/doc/source/example.rst b/doc/source/example.rst index 33d145f..bba8ebc 100644 --- a/doc/source/example.rst +++ b/doc/source/example.rst @@ -20,6 +20,7 @@ Example Gallery examples/Sklearn_Mixin.ipynb examples/Multiclass_Classification.ipynb examples/NMF.ipynb + examples/ElasticNet.ipynb List of Examples ---------------- @@ -39,3 +40,4 @@ List of Examples examples/Sklearn_Mixin.ipynb examples/Multiclass_Classification.ipynb examples/NMF.ipynb + examples/ElasticNet.ipynb diff --git a/doc/source/examples/ElasticNet.ipynb b/doc/source/examples/ElasticNet.ipynb new file mode 100644 index 0000000..b9f1090 --- /dev/null +++ b/doc/source/examples/ElasticNet.ipynb @@ -0,0 +1,1466 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "xSWYNZ1zvzCA" + }, + "source": [ + "# ElasticNet Compatible Estimators\n", + "\n", + "[![Slides](https://img.shields.io/badge/🦌-ReHLine-blueviolet)](https://rehline-python.readthedocs.io/en/latest/)\n", + "\n", + "The core class `plqERM_ElasticNet` serves as a base implementation for both classification and regression tasks. Its subclasses, `plq_ElasticNet_Classifier` and `plq_ElasticNet_Regressor`, extend the Ridge-based variants by introducing an additional `l1_ratio` parameter that controls the mix between L1 and L2 regularization. These estimators integrate seamlessly with scikit-learn utilities such as `Pipeline`, `cross_val_score`, and `GridSearchCV`." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "HDGBmNUmxZtn" + }, + "source": [ + "ElasticNet regularization solves the following optimization problem:\n", + "\n", + "$$\n", + "\\min_{\\beta \\in \\mathbb{R}^d} \\; C \\sum_{i=1}^{n} \\text{PLQ}(y_i, \\mathbf{x}_i^T \\beta) + \\ell_1\\text{ratio} \\|\\beta\\|_1 + \\frac{1}{2}(1 - \\ell_1\\text{ratio})\\|\\beta\\|_2^2, \\quad \\text{s.t.} \\quad \\mathbf{A}\\beta + \\mathbf{b} \\geq \\mathbf{0},\n", + "$$\n", + "\n", + "where\n", + "\n", + "- $\\text{PLQ}(\\cdot)$ is a piecewise linear-quadratic loss function (e.g., SVM hinge, quantile, Huber),\n", + "- $\\mathbf{x}_i \\in \\mathbb{R}^d$ is a feature vector,\n", + "- $y_i$ is the response variable (class label or continuous value),\n", + "- $C > 0$ is the regularization strength (larger $C$ = less regularization),\n", + "- $\\ell_1\\text{ratio} \\in [0, 1]$ is the mixing parameter: $\\ell_1\\text{ratio} = 1$ gives Lasso, $\\ell_1\\text{ratio} = 0$ gives Ridge,\n", + "- $\\mathbf{A}\\beta + \\mathbf{b} \\geq \\mathbf{0}$ represents optional linear constraints on $\\beta$." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "L_j1q7cFEBxy" + }, + "source": [ + "#### Classification Example with GridSearchCV and Pipeline\n", + "\n", + "Here we show a classification example using `Pipeline`, `cross_val_score`, and `GridSearchCV`. Compared to the Ridge classifier, the key difference is the additional `l1_ratio` parameter in `param_grid`.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "39IeObaaHBDz" + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from sklearn.datasets import make_classification\n", + "from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.metrics import accuracy_score, classification_report, confusion_matrix" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "Rc33Ym8ZHB6a" + }, + "outputs": [], + "source": [ + "# generate the dataset\n", + "X, y = make_classification(\n", + " n_samples=2000,\n", + " n_features=20,\n", + " n_informative=8,\n", + " n_redundant=4,\n", + " n_repeated=0,\n", + " n_classes=2,\n", + " weights=[0.7, 0.3],\n", + " class_sep=1.2,\n", + " flip_y=0.01,\n", + " random_state=42,\n", + ")\n", + "\n", + "X_train, X_test, y_train, y_test = train_test_split(\n", + " X, y, test_size=0.25, stratify=y, random_state=42\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "id": "Q54w-eLSHDlq" + }, + "outputs": [], + "source": [ + "from rehline import plq_ElasticNet_Classifier\n", + "\n", + "# set the pipeline\n", + "pipe = Pipeline([\n", + " (\"scaler\", StandardScaler()),\n", + " (\"clf\", plq_ElasticNet_Classifier(loss={\"name\": \"svm\"})),\n", + "])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "id": "c8hG_-p5HFRk" + }, + "outputs": [], + "source": [ + "# set the parameter grid\n", + "param_grid = {\n", + " \"clf__loss\": [{\"name\": \"svm\"}, {\"name\": \"sSVM\"}],\n", + " \"clf__C\": [0.1, 1.0, 3.0],\n", + " \"clf__l1_ratio\": [0.0, 0.3, 0.5, 0.8],\n", + " \"clf__fit_intercept\": [True, False],\n", + " \"clf__intercept_scaling\": [0.5, 1.0, 2.0],\n", + " \"clf__max_iter\": [5000, 10000],\n", + " \"clf__class_weight\": [None, \"balanced\", {0: 1.0, 1: 2.0}],\n", + " \"clf__constraint\": [\n", + " [],\n", + " [{\"name\": \"nonnegative\"}],\n", + " [{\"name\": \"fair\", \"sen_idx\": [0], \"tol_sen\": 0.1}],\n", + " ],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "TrRcyQP8HILw", + "outputId": "3d4e7e02-2a0a-4f36-b71e-5283f59d8f2f" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CV scores: [0.79666667 0.82 0.82666667 0.81 0.81 ]\n" + ] + } + ], + "source": [ + "# cross_val_score\n", + "cv_scores = cross_val_score(\n", + " pipe,\n", + " X_train, y_train,\n", + " cv=5,\n", + " scoring=\"accuracy\",\n", + " n_jobs=-1,\n", + ")\n", + "print(\"CV scores:\", cv_scores)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 207 + }, + "id": "hMvSW0ifHJnZ", + "outputId": "eaeb9c6f-e206-401c-fd61-9e6de3f41499" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting 5 folds for each of 2592 candidates, totalling 12960 fits\n" + ] + }, + { + "data": { + "text/html": [ + "
GridSearchCV(cv=5,\n",
+              "             estimator=Pipeline(steps=[('scaler', StandardScaler()),\n",
+              "                                       ('clf',\n",
+              "                                        plq_ElasticNet_Classifier(loss={'name': 'svm'}))]),\n",
+              "             n_jobs=-1,\n",
+              "             param_grid={'clf__C': [0.1, 1.0, 3.0],\n",
+              "                         'clf__class_weight': [None, 'balanced',\n",
+              "                                               {0: 1.0, 1: 2.0}],\n",
+              "                         'clf__constraint': [[], [{'name': 'nonnegative'}],\n",
+              "                                             [{'name': 'fair', 'sen_idx': [0],\n",
+              "                                               'tol_sen': 0.1}]],\n",
+              "                         'clf__fit_intercept': [True, False],\n",
+              "                         'clf__intercept_scaling': [0.5, 1.0, 2.0],\n",
+              "                         'clf__l1_ratio': [0.0, 0.3, 0.5, 0.8],\n",
+              "                         'clf__loss': [{'name': 'svm'}, {'name': 'sSVM'}],\n",
+              "                         'clf__max_iter': [5000, 10000]},\n",
+              "             scoring='accuracy', verbose=1)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "GridSearchCV(cv=5,\n", + " estimator=Pipeline(steps=[('scaler', StandardScaler()),\n", + " ('clf',\n", + " plq_ElasticNet_Classifier(loss={'name': 'svm'}))]),\n", + " n_jobs=-1,\n", + " param_grid={'clf__C': [0.1, 1.0, 3.0],\n", + " 'clf__class_weight': [None, 'balanced',\n", + " {0: 1.0, 1: 2.0}],\n", + " 'clf__constraint': [[], [{'name': 'nonnegative'}],\n", + " [{'name': 'fair', 'sen_idx': [0],\n", + " 'tol_sen': 0.1}]],\n", + " 'clf__fit_intercept': [True, False],\n", + " 'clf__intercept_scaling': [0.5, 1.0, 2.0],\n", + " 'clf__l1_ratio': [0.0, 0.3, 0.5, 0.8],\n", + " 'clf__loss': [{'name': 'svm'}, {'name': 'sSVM'}],\n", + " 'clf__max_iter': [5000, 10000]},\n", + " scoring='accuracy', verbose=1)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# GridSearchCV\n", + "grid = GridSearchCV(\n", + " estimator=pipe,\n", + " param_grid=param_grid,\n", + " scoring=\"accuracy\",\n", + " cv=5,\n", + " n_jobs=-1,\n", + " refit=True,\n", + " verbose=1,\n", + ")\n", + "\n", + "grid.fit(X_train, y_train)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "AXyFwRedHKWh", + "outputId": "8d515a49-2532-4bc0-9f5c-962e49687823" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Best params: {'clf__C': 0.1, 'clf__class_weight': None, 'clf__constraint': [{'name': 'fair', 'sen_idx': [0], 'tol_sen': 0.1}], 'clf__fit_intercept': True, 'clf__intercept_scaling': 1.0, 'clf__l1_ratio': 0.0, 'clf__loss': {'name': 'sSVM'}, 'clf__max_iter': 5000}\n", + "Best CV accuracy: 0.8133333333333332\n" + ] + } + ], + "source": [ + "print(\"Best params:\", grid.best_params_)\n", + "print(\"Best CV accuracy:\", grid.best_score_)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "Aj-AMD1THMFu", + "outputId": "a47b6f0b-3daa-4514-df47-1337ebef31bc" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Test accuracy: 0.808\n", + "\n", + "Classification report:\n", + " precision recall f1-score support\n", + "\n", + " 0 0.8155 0.9370 0.8720 349\n", + " 1 0.7778 0.5099 0.6160 151\n", + "\n", + " accuracy 0.8080 500\n", + " macro avg 0.7966 0.7234 0.7440 500\n", + "weighted avg 0.8041 0.8080 0.7947 500\n", + "\n", + "Confusion matrix:\n", + " [[327 22]\n", + " [ 74 77]]\n" + ] + } + ], + "source": [ + "best_model = grid.best_estimator_\n", + "y_pred = best_model.predict(X_test)\n", + "test_acc = accuracy_score(y_test, y_pred)\n", + "\n", + "print(\"Test accuracy:\", test_acc)\n", + "print(\"\\nClassification report:\\n\", classification_report(y_test, y_pred, digits=4))\n", + "print(\"Confusion matrix:\\n\", confusion_matrix(y_test, y_pred))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BHtMgQH3EN_R" + }, + "source": [ + "#### Regression Example\n", + "\n", + "Here we show a regression example using `Pipeline`, `cross_val_score`, and `GridSearchCV`. The `l1_ratio` controls the balance between lasso and ridge penalty.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "id": "OyLbApGNHN1M" + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from sklearn.datasets import make_regression\n", + "from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.metrics import mean_squared_error, r2_score" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "id": "vBKg2LAHHPT-" + }, + "outputs": [], + "source": [ + "# generate the data\n", + "X, y = make_regression(\n", + " n_samples=1500,\n", + " n_features=15,\n", + " n_informative=10,\n", + " noise=10.0,\n", + " random_state=42\n", + ")\n", + "\n", + "X_train, X_test, y_train, y_test = train_test_split(\n", + " X, y, test_size=0.25, random_state=42\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "DDFvFnM0HQoX" + }, + "outputs": [], + "source": [ + "from rehline import plq_ElasticNet_Regressor\n", + "\n", + "# set the pipeline\n", + "pipe = Pipeline([\n", + " (\"scaler\", StandardScaler()),\n", + " (\"reg\", plq_ElasticNet_Regressor(loss={\"name\": \"QR\", \"qt\": 0.5})),\n", + "])" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "id": "8XU49fbKHUbE" + }, + "outputs": [], + "source": [ + "# set the param_grid\n", + "param_grid = {\n", + " \"reg__loss\": [\n", + " {\"name\": \"QR\", \"qt\": 0.5},\n", + " {\"name\": \"huber\", \"tau\": 1.0},\n", + " {\"name\": \"SVR\", \"epsilon\": 0.1},\n", + " ],\n", + " \"reg__C\": [0.1, 1.0, 10.0],\n", + " \"reg__l1_ratio\": [0.0, 0.3, 0.5, 0.8],\n", + " \"reg__fit_intercept\": [True, False],\n", + " \"reg__intercept_scaling\": [0.5, 1.0],\n", + " \"reg__max_iter\": [5000, 8000],\n", + " \"reg__constraint\": [\n", + " [],\n", + " [{\"name\": \"nonnegative\"}],\n", + " [{\"name\": \"fair\", \"sen_idx\": [0], \"tol_sen\": 0.1}],\n", + " ],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "1MQDxCkoHWI9", + "outputId": "47dc0b50-9b3a-496c-a520-18a7480dbc79" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CV R^2 scores: [0.99668483 0.99654706 0.99704323 0.99627612 0.99609029]\n", + "Mean CV R^2: 0.9965283057432174\n" + ] + } + ], + "source": [ + "# cross_val_score\n", + "cv_scores = cross_val_score(\n", + " pipe,\n", + " X_train, y_train,\n", + " cv=5,\n", + " scoring=\"r2\",\n", + " n_jobs=-1,\n", + ")\n", + "print(\"CV R^2 scores:\", cv_scores)\n", + "print(\"Mean CV R^2:\", np.mean(cv_scores))" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 207 + }, + "id": "Wh8IfBb3HX5v", + "outputId": "0f8f28b1-c8aa-4f7c-ef2d-c8a0ffdfe7dc" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting 5 folds for each of 864 candidates, totalling 4320 fits\n" + ] + }, + { + "data": { + "text/html": [ + "
GridSearchCV(cv=5,\n",
+              "             estimator=Pipeline(steps=[('scaler', StandardScaler()),\n",
+              "                                       ('reg', plq_ElasticNet_Regressor())]),\n",
+              "             n_jobs=-1,\n",
+              "             param_grid={'reg__C': [0.1, 1.0, 10.0],\n",
+              "                         'reg__constraint': [[], [{'name': 'nonnegative'}],\n",
+              "                                             [{'name': 'fair', 'sen_idx': [0],\n",
+              "                                               'tol_sen': 0.1}]],\n",
+              "                         'reg__fit_intercept': [True, False],\n",
+              "                         'reg__intercept_scaling': [0.5, 1.0],\n",
+              "                         'reg__l1_ratio': [0.0, 0.3, 0.5, 0.8],\n",
+              "                         'reg__loss': [{'name': 'QR', 'qt': 0.5},\n",
+              "                                       {'name': 'huber', 'tau': 1.0},\n",
+              "                                       {'epsilon': 0.1, 'name': 'SVR'}],\n",
+              "                         'reg__max_iter': [5000, 8000]},\n",
+              "             scoring='r2', verbose=1)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "GridSearchCV(cv=5,\n", + " estimator=Pipeline(steps=[('scaler', StandardScaler()),\n", + " ('reg', plq_ElasticNet_Regressor())]),\n", + " n_jobs=-1,\n", + " param_grid={'reg__C': [0.1, 1.0, 10.0],\n", + " 'reg__constraint': [[], [{'name': 'nonnegative'}],\n", + " [{'name': 'fair', 'sen_idx': [0],\n", + " 'tol_sen': 0.1}]],\n", + " 'reg__fit_intercept': [True, False],\n", + " 'reg__intercept_scaling': [0.5, 1.0],\n", + " 'reg__l1_ratio': [0.0, 0.3, 0.5, 0.8],\n", + " 'reg__loss': [{'name': 'QR', 'qt': 0.5},\n", + " {'name': 'huber', 'tau': 1.0},\n", + " {'epsilon': 0.1, 'name': 'SVR'}],\n", + " 'reg__max_iter': [5000, 8000]},\n", + " scoring='r2', verbose=1)" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# GridSearchCV\n", + "grid = GridSearchCV(\n", + " estimator=pipe,\n", + " param_grid=param_grid,\n", + " scoring=\"r2\",\n", + " cv=5,\n", + " n_jobs=-1,\n", + " refit=True,\n", + " verbose=1,\n", + ")\n", + "\n", + "grid.fit(X_train, y_train)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "AM_OSqTZHaCL", + "outputId": "7d1cf617-7c65-411a-9ba1-564ee822676c" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Best params: {'reg__C': 0.1, 'reg__constraint': [{'name': 'nonnegative'}], 'reg__fit_intercept': True, 'reg__intercept_scaling': 1.0, 'reg__l1_ratio': 0.0, 'reg__loss': {'name': 'huber', 'tau': 1.0}, 'reg__max_iter': 5000}\n", + "Best CV R^2: 0.9967196763855011\n" + ] + } + ], + "source": [ + "print(\"Best params:\", grid.best_params_)\n", + "print(\"Best CV R^2:\", grid.best_score_)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "FQAcMLBsHanr", + "outputId": "41a9afa4-ad3d-43ed-a6e1-0b90ac9fbabb" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Test R^2: 0.9967743380626125\n", + "Test MSE: 104.74629973212267\n" + ] + } + ], + "source": [ + "best_model = grid.best_estimator_\n", + "y_pred = best_model.predict(X_test)\n", + "\n", + "print(\"Test R^2:\", r2_score(y_test, y_pred))\n", + "print(\"Test MSE:\", mean_squared_error(y_test, y_pred))" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/doc/source/tutorials/ReHLine_sklearn.rst b/doc/source/tutorials/ReHLine_sklearn.rst index b401120..0e32896 100644 --- a/doc/source/tutorials/ReHLine_sklearn.rst +++ b/doc/source/tutorials/ReHLine_sklearn.rst @@ -114,3 +114,4 @@ Example :name: rst-link-gallery ../examples/Sklearn_Mixin.ipynb + ../examples/ElasticNet.ipynb From 0d01f86448ad599129c6212576f702c49deff7c2 Mon Sep 17 00:00:00 2001 From: Leona <129308028+Leona-LYT@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:33:05 +0800 Subject: [PATCH 3/3] add test for ElasticNet classifier and regressor --- tests/test_sklearn_elasticnet.py | 258 +++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 tests/test_sklearn_elasticnet.py diff --git a/tests/test_sklearn_elasticnet.py b/tests/test_sklearn_elasticnet.py new file mode 100644 index 0000000..a84a1cc --- /dev/null +++ b/tests/test_sklearn_elasticnet.py @@ -0,0 +1,258 @@ +""" +Tests for plq_ElasticNet_Classifier and plq_ElasticNet_Regressor. +""" + +import numpy as np +import pytest +from sklearn.datasets import make_classification, make_regression +from sklearn.metrics import accuracy_score +from sklearn.model_selection import cross_val_score, train_test_split +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +from rehline import plq_ElasticNet_Classifier, plq_ElasticNet_Regressor + + +# --------------------------------------------------------------------------- +# Dataset helpers +# --------------------------------------------------------------------------- + +def _binary_dataset(seed=42): + return make_classification( + n_samples=500, n_features=10, n_informative=5, + n_redundant=2, n_classes=2, random_state=seed, + ) + + +def _multiclass_dataset(n_classes=3, seed=42): + return make_classification( + n_samples=600, n_features=10, n_informative=6, + n_redundant=2, n_classes=n_classes, + n_clusters_per_class=1, random_state=seed, + ) + + +def _reg_dataset(seed=42): + return make_regression( + n_samples=500, n_features=10, n_informative=7, + noise=5.0, random_state=seed, + ) + + +# =========================================================================== +# plq_ElasticNet_Classifier — binary +# =========================================================================== + +def test_elasticnet_clf_binary_pipeline_fits_and_predicts(): + X, y = _binary_dataset() + pipe = Pipeline([ + ("scaler", StandardScaler()), + ("clf", plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=0.5)), + ]) + pipe.fit(X, y) + preds = pipe.predict(X) + assert preds.shape == (len(y),) + assert accuracy_score(y, preds) > 0.5 + + +def test_elasticnet_clf_binary_cross_val_score(): + X, y = _binary_dataset() + pipe = Pipeline([ + ("scaler", StandardScaler()), + ("clf", plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=0.5)), + ]) + scores = cross_val_score(pipe, X, y, cv=3, scoring="accuracy") + assert scores.shape == (3,) + assert np.mean(scores) > 0.5 + + +def test_elasticnet_clf_binary_with_intercept(): + X, y = _binary_dataset() + X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, + fit_intercept=True, intercept_scaling=1.0, + ) + clf.fit(X_tr, y_tr) + assert hasattr(clf, "intercept_") + assert clf.coef_.shape == (X_tr.shape[1],) + assert clf.predict(X_te).shape == (len(y_te),) + + +def test_elasticnet_clf_binary_without_intercept(): + X, y = _binary_dataset() + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, fit_intercept=False, + ) + clf.fit(X, y) + assert clf.intercept_ == 0.0 + assert clf.coef_.shape == (X.shape[1],) + + +def test_elasticnet_clf_l1_ratio_zero(): + """l1_ratio=0 is pure Ridge — should fit without error.""" + X, y = _binary_dataset() + clf = plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=0.0) + clf.fit(X, y) + assert clf.predict(X).shape == (len(y),) + + +def test_elasticnet_clf_l1_ratio_invalid_raises(): + with pytest.raises(ValueError, match="l1_ratio"): + plq_ElasticNet_Classifier(loss={"name": "svm"}, C=1.0, l1_ratio=1.0) + + +# =========================================================================== +# plq_ElasticNet_Classifier — multiclass OvR +# =========================================================================== + +def test_elasticnet_clf_ovr_fits_and_predicts(): + X, y = _multiclass_dataset(n_classes=3) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="ovr" + ) + clf.fit(X, y) + preds = clf.predict(X) + assert preds.shape == (len(y),) + assert set(np.unique(preds)).issubset(set(np.unique(y))) + assert accuracy_score(y, preds) > 1 / 3 + + +def test_elasticnet_clf_ovr_estimators_shape(): + X, y = _multiclass_dataset(n_classes=3) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="ovr" + ) + clf.fit(X, y) + K = len(clf.classes_) + assert len(clf.estimators_) == K + assert clf.coef_.shape == (K, X.shape[1]) + assert clf.intercept_.shape == (K,) + + +def test_elasticnet_clf_ovr_pipeline(): + X, y = _multiclass_dataset(n_classes=3) + pipe = Pipeline([ + ("scaler", StandardScaler()), + ("clf", plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="ovr" + )), + ]) + pipe.fit(X, y) + assert pipe.predict(X).shape == (len(y),) + + +# =========================================================================== +# plq_ElasticNet_Classifier — multiclass OvO +# =========================================================================== + +def test_elasticnet_clf_ovo_fits_and_predicts(): + X, y = _multiclass_dataset(n_classes=3) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="ovo" + ) + clf.fit(X, y) + preds = clf.predict(X) + assert preds.shape == (len(y),) + assert accuracy_score(y, preds) > 1 / 3 + + +def test_elasticnet_clf_ovo_estimators_shape(): + """OvO: K*(K-1)/2 binary classifiers.""" + X, y = _multiclass_dataset(n_classes=3) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="ovo" + ) + clf.fit(X, y) + K = len(clf.classes_) + expected = K * (K - 1) // 2 + assert len(clf.estimators_) == expected + assert clf.coef_.shape == (expected, X.shape[1]) + + +def test_elasticnet_clf_multiclass_invalid_strategy_raises(): + X, y = _multiclass_dataset(n_classes=3) + clf = plq_ElasticNet_Classifier( + loss={"name": "svm"}, C=1.0, l1_ratio=0.5, multi_class="bad" + ) + with pytest.raises(ValueError, match="multi_class"): + clf.fit(X, y) + + +# =========================================================================== +# plq_ElasticNet_Regressor +# =========================================================================== + +def test_elasticnet_reg_pipeline_fits_and_predicts(): + X, y = _reg_dataset() + pipe = Pipeline([ + ("scaler", StandardScaler()), + ("reg", plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5)), + ]) + pipe.fit(X, y) + preds = pipe.predict(X) + assert preds.shape == (len(y),) + assert np.all(np.isfinite(preds)) + + +def test_elasticnet_reg_cross_val_score(): + X, y = _reg_dataset() + pipe = Pipeline([ + ("scaler", StandardScaler()), + ("reg", plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5)), + ]) + scores = cross_val_score(pipe, X, y, cv=3, scoring="r2") + assert scores.shape == (3,) + assert np.mean(scores) > 0.0 + + +def test_elasticnet_reg_multiple_losses(): + X, y = _reg_dataset() + X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0) + for loss in [{"name": "huber", "tau": 1.0}, {"name": "SVR", "epsilon": 0.1}]: + reg = plq_ElasticNet_Regressor(loss=loss, C=1.0, l1_ratio=0.3) + reg.fit(X_tr, y_tr) + preds = reg.predict(X_te) + assert preds.shape == (len(y_te),) + assert np.all(np.isfinite(preds)), f"Non-finite predictions for loss={loss}" + + +def test_elasticnet_reg_l1_ratio_zero(): + """l1_ratio=0 is pure Ridge — should fit without error.""" + X, y = _reg_dataset() + reg = plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.0) + reg.fit(X, y) + assert reg.predict(X).shape == (len(y),) + + +def test_elasticnet_reg_l1_ratio_invalid_raises(): + with pytest.raises(ValueError, match="l1_ratio"): + plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=1.0) + + +def test_elasticnet_reg_intercept_on(): + X, y = _reg_dataset() + reg = plq_ElasticNet_Regressor( + loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5, fit_intercept=True, + ) + reg.fit(X, y) + assert isinstance(reg.intercept_, float) + assert reg.coef_.shape == (X.shape[1],) + + +def test_elasticnet_reg_intercept_off(): + X, y = _reg_dataset() + reg = plq_ElasticNet_Regressor( + loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5, fit_intercept=False, + ) + reg.fit(X, y) + assert reg.intercept_ == 0.0 + assert reg.coef_.shape == (X.shape[1],) + + +def test_elasticnet_reg_predict_equals_decision_function(): + X, y = _reg_dataset() + X_tr, X_te, y_tr, _ = train_test_split(X, y, test_size=0.2, random_state=0) + reg = plq_ElasticNet_Regressor(loss={"name": "QR", "qt": 0.5}, C=1.0, l1_ratio=0.5) + reg.fit(X_tr, y_tr) + np.testing.assert_array_equal(reg.predict(X_te), reg.decision_function(X_te))