ferrolearn_linear/sgd.rs
1//! Stochastic Gradient Descent (SGD) linear models.
2//!
3//! This module provides [`SGDClassifier`] and [`SGDRegressor`], two linear
4//! models trained using stochastic gradient descent. Both support online /
5//! streaming learning via the [`PartialFit`] trait and a range of configurable
6//! loss functions and learning-rate schedules.
7//!
8//! # Classifier
9//!
10//! ```
11//! use ferrolearn_linear::sgd::{SGDClassifier, ClassifierLoss};
12//! use ferrolearn_core::{Fit, Predict};
13//! use ndarray::{array, Array2};
14//!
15//! let x = Array2::from_shape_vec((6, 2), vec![
16//! 1.0, 2.0, 2.0, 3.0, 3.0, 1.0,
17//! 8.0, 7.0, 9.0, 8.0, 7.0, 9.0,
18//! ]).unwrap();
19//! let y = array![0, 0, 0, 1, 1, 1];
20//!
21//! let model = SGDClassifier::<f64>::new();
22//! let fitted = model.fit(&x, &y).unwrap();
23//! let preds = fitted.predict(&x).unwrap();
24//! assert_eq!(preds.len(), 6);
25//! ```
26//!
27//! # Regressor
28//!
29//! ```
30//! use ferrolearn_linear::sgd::{SGDRegressor, RegressorLoss};
31//! use ferrolearn_core::{Fit, Predict};
32//! use ndarray::{array, Array2};
33//!
34//! let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
35//! let y = array![2.0, 4.0, 6.0, 8.0];
36//!
37//! let model = SGDRegressor::<f64>::new();
38//! let fitted = model.fit(&x, &y).unwrap();
39//! let preds = fitted.predict(&x).unwrap();
40//! assert_eq!(preds.len(), 4);
41//! ```
42//!
43//! ## REQ status (per `.design/linear/sgd.md`, mirrors `sklearn/linear_model/_stochastic_gradient.py` + `_sgd_fast.pyx.tp` @ 1.5.2, commit 156ef14)
44//!
45//! Parity is framed on the deterministic schedule/loss/penalty math + defaults;
46//! random-shuffle full-fit weight parity is out of scope (cross-PRNG boundary,
47//! `_sgd_fast.pyx.tp:579-580` vs `StdRng`). Two states only per R-DEFER-2.
48//!
49//! | REQ | Status | Evidence |
50//! |---|---|---|
51//! | REQ-1 (classifier losses hinge/log/modified_huber/squared_error incl. Hinge boundary) | SHIPPED | `impl Loss for Hinge/LogLoss/ModifiedHuber/SquaredError`. Hinge `gradient` now uses the NON-strict boundary `margin <= 1` matching `_sgd_fast.pyx.tp:224` (`if z <= threshold: return -y`). Consumer: `fn dispatch_train_binary` -> `Fit for SGDClassifier` -> `impl PipelineEstimator for SGDClassifier`. Tests: `test_hinge_loss_*`, divergence `sgd_hinge_gradient_boundary`. Closed #539. |
52//! | REQ-2 (squared_hinge, perceptron) | SHIPPED | `pub struct SquaredHinge` (`loss = (1-py)^2 if >0 else 0`, `gradient = -2y(1-py)`, `_sgd_fast.pyx.tp:248-258` with `threshold=1.0`, `_stochastic_gradient.py:511`) + `pub struct Perceptron` (`Hinge(threshold=0.0)`: `loss = max(0,-py)`, `gradient = -y if py<=0 else 0`, `_sgd_fast.pyx.tp:216-226`, `_stochastic_gradient.py:512`); `enum ClassifierLoss::{SquaredHinge,Perceptron}` wired in `fn dispatch_train_binary`. Consumer: `fn dispatch_train_binary` -> `fn fit_ova` -> `Fit for SGDClassifier` -> `impl PipelineEstimator for SGDClassifier`. Tests: divergence `sgd_squared_hinge_loss` (live oracle coef `[0.0569485774276016, 0.09335170687740356]` intercept `-0.20237316143907`), `sgd_perceptron_loss` (live oracle coef `[0.009957048471181063, 0.009961042575429069]` intercept `-0.04`). Closes #523. |
53//! | REQ-3 (regressor losses incl. squared_epsilon_insensitive) | SHIPPED | `pub struct SquaredEpsilonInsensitive<F> { epsilon }` (`loss = max(0,|y-p|-eps)^2`, `gradient = -2(z-eps) if z>eps; 2(-z-eps) if z<-eps; else 0` for `z=y-p`, `_sgd_fast.pyx.tp:375-387`, `_stochastic_gradient.py:1405` default `epsilon=0.1`) + `enum RegressorLoss::SquaredEpsilonInsensitive(F)` wired in `fn dispatch_train_regressor`. Consumer: `fn dispatch_train_regressor` -> `Fit for SGDRegressor` -> `impl PipelineEstimator for SGDRegressor`. Test: divergence `sgd_squared_epsilon_insensitive_loss` (live oracle single-sample coef `[0.9558857922397863, -0.47794289611989316]` intercept `0.478752180393125`, multi-sample shuffle=false coef `[0.5631419328099845, 0.41545070758814734]` intercept `0.16944283314514064`). Closes #524. |
54//! | REQ-4 (L2 penalty = clamped wscale shrink) | SHIPPED | `fn train_binary_sgd`/`train_regressor_sgd` apply `shrink = max(0, 1 - eta*alpha)` then `w = w*shrink - eta*grad*x`, mirroring `w.scale(max(0, 1-eta*alpha))` (`_sgd_fast.pyx.tp:632-635`); intercept unregularized. Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Test: divergence `sgd_l2_wscale_clamp`. Closed #525. |
55//! | REQ-5 (l1/elasticnet + l1_ratio) | SHIPPED | `enum Penalty {L2,L1,ElasticNet}` + `pub penalty`/`pub l1_ratio` fields on `SGDClassifier`/`SGDRegressor` with `fn with_penalty`/`fn with_l1_ratio` builders (defaults `L2`/`0.15`, `_stochastic_gradient.py:1231-1256`). `fn train_binary_sgd`/`train_regressor_sgd` derive `eff` via `fn effective_l1_ratio` (`L2->0`, `L1->1`, `ElasticNet->l1_ratio`, `_sgd_fast.pyx.tp:558-561`), apply the L2 shrink `max(0, 1-(1-eff)*eta*alpha)` BEFORE the gradient add (`:632-635`), then the Tsuruoka cumulative-penalty L1 truncation with fit-persistent scalar `u` and per-feature `q` AFTER (`:656-658,750-778`, `wscale=1`). Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Tests: divergence `sgd_l1_truncated_gradient` (live oracle coef [0.9204,-0.4452]), `sgd_elasticnet_l1_ratio` (l1_ratio=0.3, coef [0.92340705,-0.45723495]). Closed #526. NOTE (partial_fit+l1): `u`/`q` are scoped per `train_*_sgd` call, so they persist across the epochs of a single `fit` (the parity-critical path) but reset per `partial_fit` call. This MATCHES sklearn, which re-allocates `q=np.zeros(...)`/`u=0.0` at the top of every `_plain_sgd` call (`_sgd_fast.pyx.tp:551-556`) and only carries `t_` across `partial_fit` (`_stochastic_gradient.py` re-invokes `_plain_sgd` per call). The full `fit` path is exact. |
56//! | REQ-6 (constant + invscaling schedules) | SHIPPED | `fn compute_lr`: `Constant => eta0`, `InvScaling => eta0 / t^power_t` (`_sgd_fast.pyx.tp:479,593-594`). Consumer: per-step in `fn train_binary_sgd`/`train_regressor_sgd`. Tests: `test_constant_lr`, `test_invscaling_lr`. |
57//! | REQ-7 (optimal schedule t0 offset) | SHIPPED | `fn compute_lr` Optimal arm now `1/(alpha*(optimal_init + t - 1))` with `optimal_init` from `fn optimal_init` (`typw=sqrt(1/sqrt(alpha))`, `e0=typw/max(1,|gradient(1,-typw)|)`, `optimal_init=1/(e0*alpha)`), mirroring `_sgd_fast.pyx.tp:565-570,592`. Computed once per fit before the epoch loop. Consumer: `fn train_*_sgd`. Tests: `test_optimal_lr`, `test_optimal_init_matches_oracle`, divergence `sgd_optimal_schedule_t0_offset`. Closed #527. |
58//! | REQ-8 (adaptive /5 + n_iter_no_change trigger) | SHIPPED | `fn convergence_tail` (shared by `fn train_binary_sgd`/`train_regressor_sgd`) divides `current_eta` by 5 (not 2) when `no_improve_count >= n_iter_no_change` AND the schedule is `Adaptive` AND `eta > 1e-6`, resetting the count — the SAME `best_loss`/`sumloss > best_loss - tol*n` machinery as convergence (`_sgd_fast.pyx.tp:697-707`). The old `>= prev_loss` 5-epoch `/2` trigger is deleted. Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Test: divergence `sgd_adaptive_schedule_divisor` (live oracle coef `[0.8065190275590332, 0.15336844797680402]` intercept `0.12731338963662575`, n_iter_ 80). Closes #528. |
59//! | REQ-9 (default params per estimator) | SHIPPED (classifier defaults) | `SGDClassifier::new` now sets `learning_rate=Optimal, eta0=0.0, power_t=0.5` (`_stochastic_gradient.py:1242-1244`); `fn schedule_requires_eta0` gates the `eta0>0` validation to constant/invscaling/adaptive (`_stochastic_gradient.py:149-153`). Consumer: `Fit for SGDClassifier`. Tests: divergence `sgd_classifier_default_learning_rate`, `test_sgd_classifier_default`, `test_sgd_classifier_optimal_eta0_zero_ok`. Closed #529. `epsilon` is validated to `[0, inf)` in `fn validate_reg_params` (`RegressorLoss::{Huber,EpsilonInsensitive,SquaredEpsilonInsensitive}(e)` reject `e < 0` with `FerroError::InvalidParameter { name: "epsilon" }`, mirroring `_stochastic_gradient.py:2024` `"epsilon": [Interval(Real, 0, None, closed="left")]`; test `sgd_epsilon_negative_rejected`, closes #544). Remaining missing fields (`fit_intercept`, `early_stopping`, `validation_fraction`, `average`, `warm_start`, `class_weight`, `C`) tracked under their own blockers. (`penalty`/`l1_ratio` shipped under REQ-5; `shuffle` under REQ-12; `n_iter_no_change` folded into REQ-10.) |
60//! | REQ-10 (convergence best_loss/n_iter_no_change/sumloss) | SHIPPED | `fn convergence_tail` (shared by `fn train_binary_sgd`/`train_regressor_sgd`) tracks `best_loss` (running min, init `+inf`) and increments `no_improve_count` when `tol_active && sumloss > best_loss - tol*n_samples`, resetting otherwise; breaks once `no_improve_count >= hyper.n_iter_no_change` (non-adaptive), exactly mirroring `_sgd_fast.pyx.tp:688-707`. `sumloss` is now the SUM of per-sample losses over the epoch (the `/= n_samples` mean division is removed, `_sgd_fast.pyx.tp:597`); `tol_active = hyper.tol > -inf` encodes sklearn's `tol=None -> -INFINITY` disable (`:690`). The per-sample gradient is clipped to `[-1e12, 1e12]` via `fn max_dloss` before the update (`_sgd_fast.pyx.tp:546,613-620`). `n_iter_no_change` is now a settable `pub` field (default 5) with `fn with_n_iter_no_change` on both estimators, threaded through `SGDHyper`/`clf_hyper`/`reg_hyper` (`_stochastic_gradient.py` `n_iter_no_change=5`). Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Test: divergence `sgd_convergence_n_iter_no_change` (live oracle coef `[0.8037686404055491, 0.16059017315681692]` intercept `0.12903834217696583`, n_iter_ 49). Closes #530. |
61//! | REQ-11 (fit_intercept) | SHIPPED | `pub fit_intercept: bool` field on `SGDClassifier`/`SGDRegressor` + `fn with_fit_intercept` builders (default `true`, sklearn `_stochastic_gradient.py` `fit_intercept=True`, constraint `["boolean"]` at `:86`), threaded through `SGDHyper.fit_intercept` + `fn clf_hyper`/`reg_hyper`. `fn train_binary_sgd`/`train_regressor_sgd` gate the intercept update: `if hyper.fit_intercept { *intercept = *intercept - eta * grad; }`, mirroring `if fit_intercept == 1: intercept_update = update; ... intercept += intercept_update * intercept_decay` (`_sgd_fast.pyx.tp:639-644`, `intercept_decay=1` on the standard path). When `false` the intercept is never modified and stays at its init value `0` (`b = F::zero()` before training in `fn fit_ova`/regressor `Fit::fit`), so `coef_` matches sklearn and `intercept_` is exactly `0`. Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Test: divergence `sgd_fit_intercept_false` (live oracle coef `[0.5326796739094939, 0.44573604649819804]`, intercept exactly `0.0`). Closes #531. |
62//! | REQ-12 (shuffle flag) | SHIPPED | `pub shuffle: bool` field on `SGDClassifier`/`SGDRegressor` + `fn with_shuffle` builders (default `true`, `_stochastic_gradient.py:107` `shuffle=True`, constraint `["boolean"]` at `:89`), threaded through `SGDHyper.shuffle` + `fn clf_hyper`/`reg_hyper`. `fn train_binary_sgd`/`train_regressor_sgd` gate the per-epoch shuffle: `if hyper.shuffle { indices.shuffle(&mut rng); }`, mirroring `if shuffle: dataset.shuffle(seed)` (`_sgd_fast.pyx.tp:579-580`); when off, `indices` stays `0..n-1` each epoch matching sklearn's no-shuffle index order (`:581` `for i in range(n_samples)`). Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Tests: divergence `sgd_shuffle_false_multisample_kernel_parity` (4-sample/2-feature/5-epoch L2 oracle coef `[0.5103165909636498, 0.42319810364130317]` intercept `0.16255331549195393`; elasticnet l1_ratio=0.3 oracle coef `[0.5102136050112174, 0.4230749783888256]` intercept `0.16265294456399926`). Closes #532. This `shuffle=false` parity ALSO validates REQ-4/REQ-5/REQ-6 (L2 shrink + elasticnet truncated gradient + constant schedule) over MULTIPLE samples and epochs against the live oracle — previously only single-sample. |
63//! | REQ-13 (early_stopping + validation_fraction + n_iter_no_change-on-val-score) | SHIPPED (for the verifiable logic; the validation-split SELECTION is numpy-RNG-coupled so full fitted-coef parity is NOT oracle-verifiable — same barrier as `shuffle`) | `pub early_stopping: bool` (default `false`) + `pub validation_fraction: F` (default `0.1` via `cst`) fields on `SGDClassifier`/`SGDRegressor` + `fn with_early_stopping`/`with_validation_fraction` builders (`_stochastic_gradient.py:114-115`, constraints `["boolean"]`/`Interval(Real, 0, 1, closed="neither")` at `:524-525`), threaded through `SGDHyper.early_stopping`/`validation_fraction` (`fn clf_hyper`/`reg_hyper`; one-class hardwires `false`/`0.1`). `fn validate_validation_fraction` rejects `validation_fraction` outside the OPEN interval `(0, 1)` (`FerroError::InvalidParameter`, called from `fn validate_clf_params`/`validate_reg_params`). When `early_stopping`, the `Fit` path (`fn fit_ova` for the classifier, `SGDRegressor::fit_with_sample_weight` for the regressor) splits the data via `fn make_validation_split` BEFORE the kernel: a seeded (`StdRng::seed_from_u64(random_state.unwrap_or(0))`) hold-out of `fn validation_count` (`ceil(validation_fraction*n)`, clamped to `[1,n-1]`) samples — STRATIFIED per class for the classifier (mirrors `StratifiedShuffleSplit`, `:280-287`; the multiclass split is computed ONCE on the full `y` and SHARED across OvA subproblems, `:796`), plain `ShuffleSplit` for the regressor — returning `Err` on an empty train/val subset (`:295-307`). The kernel trains on the TRAIN subset only (sklearn does NOT refit on full data) and, at epoch-end, when early stopping, scores the CURRENT (weights,intercept) on the held-out val set via `fn convergence_tail_score` with `best_score` init `-inf`: `if tol_active && score < best_score + tol { no_improve++ } else { 0 }; if score > best_score { best_score = score }` then the SHARED `no_improve >= n_iter_no_change` adaptive-÷5/break tail (`_sgd_fast.pyx.tp:678-707`). The val score is `fn r2_score` for the regressor (`1 - SS_res/SS_tot`, `SS_tot==0` -> `1.0`/`0.0` edge) and `fn binary_accuracy` for each classifier subproblem (relabeled `{-1,+1}` target, `decision>=0 -> +1` tie convention), mirroring `_ValidationScoreCallback.__call__` = `est.score(X_val,y_val)` (R²/accuracy, `_stochastic_gradient.py:79`, `fit_binary`'s `classes=[-1,1]`+`y_i` callback at `:451-454`). `early_stopping=false` leaves the training-loss `fn convergence_tail` path byte-identical (the 25 prior divergence tests stay green). Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `impl PipelineEstimator`. VERIFIED DETERMINISTICALLY: `fn r2_score`/`binary_accuracy` against the live `sklearn.metrics.r2_score`/`accuracy_score` oracle in `mod tests` (`test_validation_r2_matches_sklearn` -> `0.8887362637362637`, `test_validation_r2_constant_y_edge_cases` -> `1.0`/`0.0`, `test_validation_binary_accuracy_matches_sklearn` -> `0.75`); the `(0,1)` constraint (divergence `sgd_validation_fraction_invalid`); the behavioral early-stop (divergence `sgd_early_stopping_stops_early` — `early_stopping=true` yields a finite model DIFFERENT from `early_stopping=false`; `sgd_early_stopping_classifier_valid`). NOT VERIFIABLE (honest): the validation-subset SELECTION uses numpy Mersenne-Twister (`ShuffleSplit`/`StratifiedShuffleSplit`) whereas ferrolearn uses `StdRng`, so the exact held-out indices — and hence the full fitted `coef_` under early stopping — are NOT cross-impl reproducible (`_stochastic_gradient.py:284-287`, the SAME PRNG barrier as `shuffle`, `_sgd_fast.pyx.tp:579-580`). `_sgd_fast.pyx.tp:678-689` / `_stochastic_gradient.py:63-79,257-310,524-525`. Closes #533. NOTE: early stopping on `partial_fit` is OFF (sklearn raises `early_stopping should be False with partial_fit`, `:147-148`); the kernel receives `val_set=None` there. |
64//! | REQ-14 (average / ASGD) | SHIPPED | `pub average: usize` field on `SGDClassifier`/`SGDRegressor` (default `0` = OFF) + `fn with_average` builders (sklearn `average=True`≡`1`, `average=N`≡`N`, `average=False`≡`0`; `_stochastic_gradient.py:1256,2068`), threaded through `SGDHyper.average` + `fn clf_hyper`/`reg_hyper` (one-class path hardwires `0`). `fn train_binary_sgd`/`train_regressor_sgd` allocate `average_coef`/`average_intercept` before the epoch loop and, AFTER the weight/intercept update + L1 truncation, when `hyper.average > 0 && t >= hyper.average`, accumulate the DIRECT running mean `avg += (current - avg) / (t - average + 1)` — the plain-array equivalent of sklearn's lazy `w.add_average(..., t - average + 1)` / `average_intercept += (intercept - average_intercept) / (t - average + 1)` (`_sgd_fast.pyx.tp:646-654`); the accumulator is passive (does NOT alter the live trajectory). FINALIZE: at fit-end, `if hyper.average > 0 && hyper.average <= t { weights = average_coef; intercept = average_intercept; }`, mirroring `if self.average > 0: if self.average <= self.t_ - 1: coef_ = average_coef` (`_stochastic_gradient.py:834-836`); `t` (= `n_iter_ * n_samples`, `initial_t=0`) equals sklearn's `self.t_ - 1` (sklearn inits `self.t_ = 1`). `average=0` skips both blocks, leaving the trajectory byte-identical (the 22 prior divergence tests stay green). Consumer: `Fit for SGDRegressor`/`SGDClassifier` -> `PipelineEstimator`. Tests: divergence `sgd_average_from_start` (`SGDRegressor(average=True)` live oracle coef `[0.42614902504529534, 0.3665230497098742]` intercept `0.14648807826338486`), `sgd_average_threshold` (`SGDRegressor(average=20)`, begins mid-run, oracle coef `[0.5042444287230554, 0.41888001003992603]` intercept `0.16902090306985734`), `sgd_average_classifier` (`SGDClassifier(average=True)` oracle coef `[0.11902998815794437, 0.060826180676538694]` intercept `-0.10666666666666665`). `_sgd_fast.pyx.tp:646-654` / `_stochastic_gradient.py:834-836`. Closes #534. NOTE: averaging on the `partial_fit` path is OFF (`average` not yet carried into the `partial_fit_ova` hyper, which sets `max_iter=1`) — the full `fit` path (the parity-critical one) is exact; partial_fit ASGD state carry-over is a follow-up. |
65//! | REQ-15 (class_weight + sample_weight) | SHIPPED | `pub enum ClassWeight<F> {None,Balanced,Explicit(Vec<(usize,F)>)}` + `pub class_weight` field on `SGDClassifier` (default `ClassWeight::None`) with `fn with_class_weight`; `fn compute_class_weight` returns the expanded per-class weights (`None->1.0`; `Balanced-> n_samples/(n_classes*count_c)`; `Explicit->1.0 default, override by label`) faithful to `sklearn.utils.compute_class_weight` (`sklearn/utils/class_weight.py:63-81`, `_stochastic_gradient.py:624`). `fn fit_with_sample_weight` on `SGDClassifier` AND `SGDRegressor` validates `sample_weight.len()==n_samples` (else `ShapeMismatch`); `Fit::fit` delegates with `ones(n)` (byte-identical default path — the 17 prior divergence tests stay green). `fn fit_ova` builds the per-subproblem per-sample weight `w_i = class_weight_for_sample(i) * sample_weight[i]` with the sklearn OvA mapping (binary `pos=expanded[1]`/`neg=expanded[0]`, `_stochastic_gradient.py:765-766`; multiclass class k `pos=expanded[k]`/`neg=1.0`, `:816`) and passes `&[F]` into `fn train_binary_sgd`. The kernel scales ONLY the gradient term `g = grad * sample_w[i]` (`update *= class_weight*sample_weight`, `_sgd_fast.pyx.tp:630`): the weight data term `w[j]*shrink - eta*g*x[j]` and the (gated) intercept gradient term `-eta*g` use `g`; the L2 shrink (`:632-635`), L1 truncation (`:656-658`), one-class `-2*eta*alpha` offset (`:642`) and the unweighted `sumloss` (`:597`) are UNSCALED. `fn train_regressor_sgd` mirrors the same scaling (`class_weight=1` for regression). Consumer: `Fit for SGDClassifier`/`SGDRegressor` -> `PipelineEstimator`; `fit_with_sample_weight` consumed by `Fit::fit`. Tests: divergence `sgd_class_weight_balanced` (oracle coef `[0.4806667587635881, 0.4620316761984426]` intercept `-1.2811684177087947`), `sgd_class_weight_explicit` (`{0:1.0,1:3.0}` coef `[0.5705300651778317, 0.5660417632427646]` intercept `-1.7542279278451731`), `sgd_sample_weight` (coef `[0.25648548424261425, 0.7995046753090618]` intercept `-1.221373410658307`), `sgd_class_weight_balanced_multiclass` (class-0 coef `[-0.586000112348521, -0.369263665877338]` + argmax preds), `sgd_regressor_sample_weight` (coef `[0.9425558668838198, 1.3974216923953962]` intercept `0.7259434415390171`). `_sgd_fast.pyx.tp:599-602,630` / `_stochastic_gradient.py:624,765-766,816`. Closes #535. NOTE: `class_weight`/`sample_weight` on the `partial_fit` path are uniform `1.0` (no `class_weight`/`sample_weight` arg on `PartialFit` yet) — tracked under the partial_fit surface, not this REQ. |
66//! | REQ-16 (partial_fit semantics) | SHIPPED | `fn partial_fit (PartialFit for SGDClassifier/FittedSGDClassifier/SGDRegressor/FittedSGDRegressor)` sets `max_iter=1` and carries `self.t` (`_stochastic_gradient.py:581-674`). Consumer: `PartialFit` trait (`ferrolearn-core`). Tests: `test_sgd_*_partial_fit*`. |
67//! | REQ-17 (multiclass one-vs-all) | SHIPPED | `fn fit_ova` (one binary per class) + `fn predict` argmax (`_stochastic_gradient.py:788-844`). Consumer: `Fit for SGDClassifier` -> `PipelineEstimator`. Test: `test_sgd_classifier_multiclass`. |
68//! | REQ-18 (SGDOneClassSVM) | SHIPPED | `pub struct SGDOneClassSVM<F>` (`nu`/`fit_intercept`/`max_iter`/`tol`/`shuffle`/`learning_rate`/`eta0`/`power_t`/`random_state`/`n_iter_no_change` + `new`/`#[must_use]` builders, defaults `_stochastic_gradient.py:2245-2281`) with `fn fit_one_class` + `impl Fit<Array2<F>, ()> for SGDOneClassSVM` (X-only fit, `y` ignored, `_stochastic_gradient.py:2554`): builds `y = ones(n)`, `alpha = nu/2` (`:2588`), `penalty = L2`, `l1_ratio = 0`, `one_class = true` (`:2262-2289,2312`), inits the SGD intercept `b = 1` (offset init 0 -> `1 - 0`, `:2238,2325`), calls the reused `fn train_binary_sgd` Hinge kernel, then stores `coef_ = w`, `offset_ = 1 - b` (`:2377`). The one-class intercept term lives in `fn train_binary_sgd`: when `hyper.one_class` the gated intercept update gains `- 2*eta*alpha` (`intercept_update = -eta*grad - 2*eta*alpha`), mirroring `_sgd_fast.pyx.tp:641-642` (`if one_class: intercept_update -= 2.*eta*alpha`); `pub one_class: bool` was added to `SGDHyper` (default `false` via `fn clf_hyper`/`reg_hyper`, leaving the clf/reg intercept update byte-identical — the existing 15 divergence tests stay green). `pub struct FittedSGDOneClassSVM<F>` exposes `coef()`/`offset()`/`decision_function()` (`X·coef_ - offset_`, `:2622`)/`score_samples()` (`+ offset_ = X·coef_`, `:2639`) and `impl Predict<Array2<F>>` returning `Array1<isize>` of `+1`/`-1` (`(decision >= 0) ? +1 : -1`, `:2655-2657`). Consumer: `pub use sgd::{SGDOneClassSVM, FittedSGDOneClassSVM}` from `ferrolearn-linear/src/lib.rs` (the grandfathered public-API boundary, matching `SGDClassifier`/`SGDRegressor`). Tests: divergence `sgd_one_class_svm_decision` (live oracle nu=0.5/eta0=0.05/constant/max_iter=10/shuffle=false: coef `[0.009883660184666337, 0.009883660184666337]`, offset `1.1102230246251565e-16`, 1e-7) and `sgd_one_class_svm_predict` (nu=0.8/eta0=0.1/max_iter=15: coef `[0.20020636453962284, 0.12292535592963398]`, offset `0.10000000000000009`, predict `[1,-1,1,-1]`). `_stochastic_gradient.py:2084-2668` / `_sgd_fast.pyx.tp:639-644`. Closes #536. |
69//! | REQ-19 (anti-pattern cleanup) | SHIPPED | `fn compute_lr`'s `_Phantom` arm returns `eta0` (the `unreachable!()` macro was removed earlier), and every production `F::from(<f64 literal>).unwrap()` / `F::from(<literal>).unwrap_or_else(|| ...)` constant-construction site is now `fn cst<F: Float>(x: f64) -> F { F::from(x).unwrap_or_else(F::zero) }` (a private module-level infallible-for-f32/f64 constant helper, defined after the imports). 23 call sites replaced: LogLoss `18.0`/`-18.0`/`1e18` (`_sgd_fast.pyx.tp:267-283`), SquaredError/Huber `0.5` (`:291-295,315-331`), ModifiedHuber `4.0`/`-2.0` (`:178-194`), SquaredHinge/SquaredEpsilonInsensitive/intercept/one-class `2.0` (`:254-258,379-387,641-642,2588`), and the `SGDClassifier`/`SGDRegressor`/`SGDOneClassSVM` `::new` defaults (`0.0`/`0.0001`/`0.15`/`1e-3`/`0.5`/`0.25`/`0.01`/`0.01`/`0.5`, `_stochastic_gradient.py:1242-1256,2042-2068,2245-2281`). No numeric literal changed -> byte-identical for f32/f64; all 25 `divergence_sgd_fit` + full lib/doctest suites stay green. No production panicking constant-conversion remains outside `#[cfg(test)]` in `sgd.rs` (verified by grep). Per R-APG-1 / R-CODE-2. The runtime `F::from(<usize>)` conversions (`t`, `n_samples`, `num_iter`, `count`, `from_usize`) and the deliberately-non-zero-fallback constants (`max_dloss` `1e12`->`F::max_value`, `eta_floor` `1e-6`, `divisor` `5.0`) already used `unwrap_or_else` and were already gate-compliant. Closes #537. |
70//! | REQ-20 (ferray substrate migration) | NOT-STARTED | blocker #538. Still `ndarray` + `StdRng` (R-SUBSTRATE-1). |
71//! | REQ-21 (non-finite input rejected) | SHIPPED | All three SGD fit entries reject any NaN/+/-inf in their float inputs BEFORE the SGD kernel with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_stochastic_gradient.py:1476` clf/reg base, `:2392` one-class) + `_check_sample_weight` (`:1501`) → `ValueError("Input X contains NaN.")` / `"Input y contains NaN."` / `"... contains infinity ..."`. `SGDClassifier::fit_with_sample_weight` checks X + `sample_weight` (`y: Array1<usize>` finite by type); `SGDRegressor::fit_with_sample_weight` checks X + y (`Array1<F>`) + `sample_weight`; the SEPARATE `SGDOneClassSVM::fit_one_class` arm (X-only fit, no y/sample_weight) checks X. `Fit::fit` delegates to the `fit_with_sample_weight` entries with unit weights, so the guard covers the default path too. `.iter().any(|v| !v.is_finite())` catches NaN and Inf; finite paths byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X for SGDClassifier/SGDRegressor, NaN/inf in y + sample_weight for SGDRegressor, NaN in sample_weight for SGDClassifier all raise `ValueError` (`tests/divergence_linear_nonfinite_batch4.rs::sgd_*`). Non-test consumer: the existing `Fit for SGDClassifier`/`SGDRegressor` + `pub use sgd::{...}` boundary. (#2263) |
72
73use ferrolearn_core::error::FerroError;
74use ferrolearn_core::introspection::HasCoefficients;
75use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
76use ferrolearn_core::traits::{Fit, PartialFit, Predict};
77use ndarray::{Array1, Array2, ScalarOperand};
78use num_traits::{Float, FromPrimitive, ToPrimitive};
79use rand::SeedableRng;
80use rand::seq::SliceRandom;
81
82/// Convert an `f64` literal constant to `F`. The conversion is infallible for
83/// the supported real types (`f32`/`f64`); the `F::zero()` fallback is
84/// unreachable for those and exists only to keep the call non-panicking
85/// (no `.unwrap()` in production, per the anti-pattern gate R-APG-1 / R-CODE-2).
86#[inline]
87fn cst<F: Float>(x: f64) -> F {
88 F::from(x).unwrap_or_else(F::zero)
89}
90
91// ---------------------------------------------------------------------------
92// Loss functions
93// ---------------------------------------------------------------------------
94
95/// A loss function for SGD optimization.
96///
97/// Provides the loss value and its gradient with respect to the prediction.
98pub trait Loss<F: Float>: Clone + Send + Sync {
99 /// Compute the loss for a single sample.
100 fn loss(&self, y_true: F, y_pred: F) -> F;
101
102 /// Compute the gradient of the loss with respect to `y_pred`.
103 fn gradient(&self, y_true: F, y_pred: F) -> F;
104}
105
106/// Hinge loss for linear SVM-style classification.
107///
108/// `L(y, p) = max(0, 1 - y * p)` where `y in {-1, +1}`.
109#[derive(Debug, Clone, Copy)]
110pub struct Hinge;
111
112impl<F: Float> Loss<F> for Hinge {
113 fn loss(&self, y_true: F, y_pred: F) -> F {
114 let margin = y_true * y_pred;
115 if margin < F::one() {
116 F::one() - margin
117 } else {
118 F::zero()
119 }
120 }
121
122 fn gradient(&self, y_true: F, y_pred: F) -> F {
123 // sklearn `Hinge.dloss` uses a NON-strict boundary at the threshold
124 // (`_sgd_fast.pyx.tp:224`: `if z <= self.threshold: return -y`), so at
125 // the exact margin `z == 1` the gradient is `-y`, not `0`.
126 let margin = y_true * y_pred;
127 if margin <= F::one() {
128 -y_true
129 } else {
130 F::zero()
131 }
132 }
133}
134
135/// Squared hinge loss for (quadratically penalized) linear SVM classification.
136///
137/// `L(y, p) = max(0, 1 - y * p)^2` where `y in {-1, +1}`. This is sklearn's
138/// `SquaredHinge(threshold=1.0)` (`_sgd_fast.pyx.tp:232-258`); the
139/// `squared_hinge` classifier loss maps to it (`_stochastic_gradient.py:511`).
140#[derive(Debug, Clone, Copy)]
141pub struct SquaredHinge;
142
143impl<F: Float> Loss<F> for SquaredHinge {
144 fn loss(&self, y_true: F, y_pred: F) -> F {
145 // `_sgd_fast.pyx.tp:248-252`: `z = threshold - p*y; z*z if z > 0 else 0`
146 // with `threshold = 1.0` (`_stochastic_gradient.py:511`).
147 let z = F::one() - y_pred * y_true;
148 if z > F::zero() { z * z } else { F::zero() }
149 }
150
151 fn gradient(&self, y_true: F, y_pred: F) -> F {
152 // `_sgd_fast.pyx.tp:254-258`: `z = threshold - p*y; -2*y*z if z > 0 else 0`.
153 let z = F::one() - y_pred * y_true;
154 if z > F::zero() {
155 -cst::<F>(2.0) * y_true * z
156 } else {
157 F::zero()
158 }
159 }
160}
161
162/// Perceptron loss for linear classification.
163///
164/// `L(y, p) = max(0, -y * p)` where `y in {-1, +1}`. This is sklearn's
165/// `Hinge(threshold=0.0)` (`_sgd_fast.pyx.tp:200-226`); the `perceptron`
166/// classifier loss maps to it (`_stochastic_gradient.py:512`). The existing
167/// [`Hinge`] hardcodes `threshold = 1.0`, so this is a separate type.
168#[derive(Debug, Clone, Copy)]
169pub struct Perceptron;
170
171impl<F: Float> Loss<F> for Perceptron {
172 fn loss(&self, y_true: F, y_pred: F) -> F {
173 // `_sgd_fast.pyx.tp:216-220`: `z = p*y; threshold - z if z <= threshold
174 // else 0` with `threshold = 0.0` (`_stochastic_gradient.py:512`), i.e.
175 // `max(0, -z)`.
176 let z = y_pred * y_true;
177 if z <= F::zero() { -z } else { F::zero() }
178 }
179
180 fn gradient(&self, y_true: F, y_pred: F) -> F {
181 // `_sgd_fast.pyx.tp:222-226`: `z = p*y; -y if z <= threshold else 0`
182 // with `threshold = 0.0`.
183 let z = y_pred * y_true;
184 if z <= F::zero() { -y_true } else { F::zero() }
185 }
186}
187
188/// Log loss (logistic regression / cross-entropy).
189///
190/// `L(y, p) = log(1 + exp(-y * p))` where `y in {-1, +1}`.
191#[derive(Debug, Clone, Copy)]
192pub struct LogLoss;
193
194impl<F: Float> Loss<F> for LogLoss {
195 fn loss(&self, y_true: F, y_pred: F) -> F {
196 let z = y_true * y_pred;
197 if z > cst(18.0) {
198 (-z).exp()
199 } else if z < cst(-18.0) {
200 -z
201 } else {
202 (F::one() + (-z).exp()).ln()
203 }
204 }
205
206 fn gradient(&self, y_true: F, y_pred: F) -> F {
207 let z = y_true * y_pred;
208 let exp_nz = if z > cst(18.0) {
209 (-z).exp()
210 } else if z < cst(-18.0) {
211 cst(1e18)
212 } else {
213 (-z).exp()
214 };
215 -y_true * exp_nz / (F::one() + exp_nz)
216 }
217}
218
219/// Squared error loss for regression.
220///
221/// `L(y, p) = 0.5 * (y - p)^2`.
222#[derive(Debug, Clone, Copy)]
223pub struct SquaredError;
224
225impl<F: Float> Loss<F> for SquaredError {
226 fn loss(&self, y_true: F, y_pred: F) -> F {
227 let diff = y_true - y_pred;
228 cst::<F>(0.5) * diff * diff
229 }
230
231 fn gradient(&self, y_true: F, y_pred: F) -> F {
232 y_pred - y_true
233 }
234}
235
236/// Modified Huber loss for classification.
237///
238/// Smooth approximation to hinge with quadratic behaviour near the margin:
239///
240/// ```text
241/// L(y, p) = max(0, 1 - y*p)^2 if y*p >= -1
242/// = -4 * y * p otherwise
243/// ```
244#[derive(Debug, Clone, Copy)]
245pub struct ModifiedHuber;
246
247impl<F: Float> Loss<F> for ModifiedHuber {
248 fn loss(&self, y_true: F, y_pred: F) -> F {
249 let z = y_true * y_pred;
250 if z >= -F::one() {
251 let margin = F::one() - z;
252 if margin > F::zero() {
253 margin * margin
254 } else {
255 F::zero()
256 }
257 } else {
258 -cst::<F>(4.0) * z
259 }
260 }
261
262 fn gradient(&self, y_true: F, y_pred: F) -> F {
263 let z = y_true * y_pred;
264 if z >= -F::one() {
265 if z < F::one() {
266 cst::<F>(-2.0) * y_true * (F::one() - z)
267 } else {
268 F::zero()
269 }
270 } else {
271 -cst::<F>(4.0) * y_true
272 }
273 }
274}
275
276/// Huber loss for robust regression.
277///
278/// `L(y, p) = 0.5 * (y - p)^2` if `|y - p| <= epsilon`, else
279/// `epsilon * (|y - p| - 0.5 * epsilon)`.
280#[derive(Debug, Clone, Copy)]
281pub struct Huber<F> {
282 /// Threshold parameter for switching from quadratic to linear loss.
283 pub epsilon: F,
284}
285
286impl<F: Float + Send + Sync> Loss<F> for Huber<F> {
287 fn loss(&self, y_true: F, y_pred: F) -> F {
288 let diff = y_true - y_pred;
289 let abs_diff = diff.abs();
290 if abs_diff <= self.epsilon {
291 cst::<F>(0.5) * diff * diff
292 } else {
293 self.epsilon * (abs_diff - cst::<F>(0.5) * self.epsilon)
294 }
295 }
296
297 fn gradient(&self, y_true: F, y_pred: F) -> F {
298 let diff = y_pred - y_true;
299 let abs_diff = diff.abs();
300 if abs_diff <= self.epsilon {
301 diff
302 } else if diff > F::zero() {
303 self.epsilon
304 } else {
305 -self.epsilon
306 }
307 }
308}
309
310/// Epsilon-insensitive loss for support vector regression.
311///
312/// `L(y, p) = max(0, |y - p| - epsilon)`.
313#[derive(Debug, Clone, Copy)]
314pub struct EpsilonInsensitive<F> {
315 /// Insensitivity margin.
316 pub epsilon: F,
317}
318
319impl<F: Float + Send + Sync> Loss<F> for EpsilonInsensitive<F> {
320 fn loss(&self, y_true: F, y_pred: F) -> F {
321 let diff = (y_true - y_pred).abs();
322 if diff > self.epsilon {
323 diff - self.epsilon
324 } else {
325 F::zero()
326 }
327 }
328
329 fn gradient(&self, y_true: F, y_pred: F) -> F {
330 let diff = y_pred - y_true;
331 if diff > self.epsilon {
332 F::one()
333 } else if diff < -self.epsilon {
334 -F::one()
335 } else {
336 F::zero()
337 }
338 }
339}
340
341/// Squared epsilon-insensitive loss for support vector regression.
342///
343/// `L(y, p) = max(0, |y - p| - epsilon)^2`. This is sklearn's
344/// `SquaredEpsilonInsensitive` (`_sgd_fast.pyx.tp:364-388`); the
345/// `squared_epsilon_insensitive` regressor loss maps to it
346/// (`_stochastic_gradient.py:1405`, default `epsilon = DEFAULT_EPSILON = 0.1`).
347#[derive(Debug, Clone, Copy)]
348pub struct SquaredEpsilonInsensitive<F> {
349 /// Insensitivity margin.
350 pub epsilon: F,
351}
352
353impl<F: Float + Send + Sync> Loss<F> for SquaredEpsilonInsensitive<F> {
354 fn loss(&self, y_true: F, y_pred: F) -> F {
355 // `_sgd_fast.pyx.tp:375-377`: `ret = |y - p| - epsilon;
356 // ret*ret if ret > 0 else 0`.
357 let ret = (y_true - y_pred).abs() - self.epsilon;
358 if ret > F::zero() {
359 ret * ret
360 } else {
361 F::zero()
362 }
363 }
364
365 fn gradient(&self, y_true: F, y_pred: F) -> F {
366 // `_sgd_fast.pyx.tp:379-387`: `z = y - p;
367 // -2*(z-epsilon) if z > epsilon; 2*(-z-epsilon) if z < -epsilon; else 0`.
368 let two = cst::<F>(2.0);
369 let z = y_true - y_pred;
370 if z > self.epsilon {
371 -two * (z - self.epsilon)
372 } else if z < -self.epsilon {
373 two * (-z - self.epsilon)
374 } else {
375 F::zero()
376 }
377 }
378}
379
380// ---------------------------------------------------------------------------
381// Learning rate schedules
382// ---------------------------------------------------------------------------
383
384/// Learning rate schedule for SGD.
385#[derive(Debug, Clone, Copy)]
386pub enum LearningRateSchedule<F> {
387 /// Fixed learning rate `eta0` throughout training.
388 Constant,
389 /// Optimal schedule: `eta = 1 / (alpha * t)`.
390 Optimal,
391 /// Inverse scaling: `eta = eta0 / t^power_t`.
392 InvScaling,
393 /// Adaptive: starts at `eta0`, halved when loss fails to decrease for
394 /// 5 consecutive epochs. Stops when `eta < 1e-6`.
395 Adaptive,
396 #[doc(hidden)]
397 _Phantom(std::marker::PhantomData<F>),
398}
399
400/// Compute the learning rate for a given step.
401///
402/// `optimal_init` is the `t0` offset of the `optimal` schedule, derived once
403/// per fit from `alpha` and the loss's `dloss(1, -typw)` bound (see
404/// [`optimal_init`]). It is ignored by the other schedules.
405fn compute_lr<F: Float>(
406 schedule: &LearningRateSchedule<F>,
407 eta0: F,
408 alpha: F,
409 power_t: F,
410 optimal_init: F,
411 t: usize,
412) -> F {
413 let t_f = F::from(t.max(1)).unwrap_or_else(F::one);
414 match schedule {
415 LearningRateSchedule::Constant => eta0,
416 // sklearn `_sgd_fast.pyx.tp:592`: `eta = 1/(alpha*(optimal_init+t-1))`,
417 // so the first sample (t=1) sees `eta = 1/(alpha*optimal_init) = e0`.
418 LearningRateSchedule::Optimal => F::one() / (alpha * (optimal_init + t_f - F::one())),
419 LearningRateSchedule::InvScaling => eta0 / t_f.powf(power_t),
420 LearningRateSchedule::Adaptive => eta0,
421 // `_Phantom` is an uninhabited marker arm; fall back to `eta0` rather
422 // than aborting (R-APG-1 forbids the unreach macro in production).
423 LearningRateSchedule::_Phantom(_) => eta0,
424 }
425}
426
427/// Compute the `optimal` schedule's `t0` offset `optimal_init`.
428///
429/// Mirrors `_sgd_fast.pyx.tp:565-570`:
430/// `typw = sqrt(1/sqrt(alpha))`,
431/// `initial_eta0 = typw / max(1, dloss(1, -typw))`,
432/// `optimal_init = 1/(initial_eta0 * alpha)`.
433///
434/// sklearn calls `loss.dloss(1.0, -typw)` where the cython signature is
435/// `dloss(self, y, p)` (y first, p second), so `y = 1.0`, `p = -typw`. The
436/// ferrolearn signature is `gradient(y_true, y_pred)`, mapping to
437/// `gradient(1.0, -typw)`; its absolute value matches `max(1.0, dloss(...))`.
438/// Returns `1.0` when `alpha == 0` (the schedule is unused / guarded upstream).
439fn optimal_init<F, L>(loss_fn: &L, alpha: F) -> F
440where
441 F: Float,
442 L: Loss<F>,
443{
444 if alpha <= F::zero() {
445 return F::one();
446 }
447 let typw = (F::one() / alpha.sqrt()).sqrt();
448 let dloss = loss_fn.gradient(F::one(), -typw).abs();
449 let initial_eta0 = typw / dloss.max(F::one());
450 F::one() / (initial_eta0 * alpha)
451}
452
453// ---------------------------------------------------------------------------
454// Class weights
455// ---------------------------------------------------------------------------
456
457/// Per-class weighting strategy for [`SGDClassifier`].
458///
459/// Mirrors sklearn's `class_weight` parameter
460/// (`_stochastic_gradient.py` constraint `[dict, "balanced", None]`); the
461/// expanded per-class weights are computed by [`compute_class_weight`] following
462/// `sklearn.utils.compute_class_weight` semantics and fed into the per-sample
463/// `update *= class_weight * sample_weight` scaling (`_sgd_fast.pyx.tp:630`).
464#[derive(Debug, Clone)]
465pub enum ClassWeight<F> {
466 /// Uniform weights (all classes weighted `1.0`). The default.
467 None,
468 /// Balanced weights `n_samples / (n_classes * count_c)` per class `c`,
469 /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
470 /// (`class_weight.py:73`).
471 Balanced,
472 /// Explicit class-label -> weight map. Classes absent from the map default
473 /// to `1.0`, matching the dict branch of `compute_class_weight`
474 /// (`class_weight.py:77-81`).
475 Explicit(Vec<(usize, F)>),
476}
477
478/// Compute the expanded per-class weight vector aligned to `classes`
479/// (sorted ascending, matching sklearn's `classes_`).
480///
481/// Faithful to `sklearn.utils.compute_class_weight`
482/// (`sklearn/utils/class_weight.py:63-81`):
483/// - `None` -> all `1.0` (`:63-65`).
484/// - `Balanced` -> `n_samples / (n_classes * count_c)` per class `c`,
485/// where `count_c` is the number of samples with label `c` (`:66-74`).
486/// - `Explicit(map)` -> `1.0` default, overridden by the map entries matched by
487/// class label (`:75-81`).
488///
489/// `classes` is the sorted unique label set; `y` is the per-sample label array.
490fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
491 match cw {
492 ClassWeight::None => vec![F::one(); classes.len()],
493 ClassWeight::Balanced => {
494 // `recip_freq = len(y) / (n_classes * bincount(y_ind))`
495 // (`class_weight.py:73`), indexed per class.
496 let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
497 let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
498 classes
499 .iter()
500 .map(|&c| {
501 let count = y.iter().filter(|&&label| label == c).count();
502 let count_f = F::from(count).unwrap_or_else(F::one);
503 if count_f > F::zero() {
504 n_samples / (n_classes * count_f)
505 } else {
506 F::one()
507 }
508 })
509 .collect()
510 }
511 ClassWeight::Explicit(map) => classes
512 .iter()
513 .map(|&c| {
514 map.iter()
515 .find(|(label, _)| *label == c)
516 .map_or_else(F::one, |(_, w)| *w)
517 })
518 .collect(),
519 }
520}
521
522// ---------------------------------------------------------------------------
523// Penalty (regularization term)
524// ---------------------------------------------------------------------------
525
526/// Regularization penalty for SGD.
527///
528/// Mirrors sklearn's `penalty` parameter
529/// (`_stochastic_gradient.py:997-1012`). The effective `l1_ratio` passed to the
530/// kernel is derived per `_sgd_fast.pyx.tp:558-561`: `l2 -> 0.0`, `l1 -> 1.0`,
531/// `elasticnet -> user l1_ratio`.
532#[derive(Debug, Clone, Copy)]
533pub enum Penalty {
534 /// L2 (ridge) penalty — multiplicative `wscale` shrink only (the default).
535 L2,
536 /// L1 (lasso) penalty — Tsuruoka cumulative-penalty truncated gradient.
537 L1,
538 /// Elastic-net — convex mix of L2 and L1 controlled by `l1_ratio`.
539 ElasticNet,
540}
541
542/// Compute the effective `l1_ratio` for the truncated-gradient kernel.
543///
544/// Mirrors `_sgd_fast.pyx.tp:558-561`: `L2 -> 0.0`, `L1 -> 1.0`,
545/// `ElasticNet -> user l1_ratio`.
546fn effective_l1_ratio<F: Float>(penalty: Penalty, l1_ratio: F) -> F {
547 match penalty {
548 Penalty::L2 => F::zero(),
549 Penalty::L1 => F::one(),
550 Penalty::ElasticNet => l1_ratio,
551 }
552}
553
554/// The `MAX_DLOSS` gradient clip bound (`_sgd_fast.pyx.tp:546`, `1e12`).
555///
556/// sklearn clips `dloss` to `[-MAX_DLOSS, MAX_DLOSS]` before forming the update
557/// `update = -eta * dloss` (`_sgd_fast.pyx.tp:613-620`) to avoid numerical
558/// instabilities. Falls back to `F::max_value()` (an even looser, never-active
559/// bound) if `1e12` is not representable in `F` — so the clamp is always a safe
560/// no-op widening rather than a panic.
561#[inline]
562fn max_dloss<F: Float>() -> F {
563 F::from(1e12_f64).unwrap_or_else(F::max_value)
564}
565
566/// Coefficient of determination `R^2 = 1 - SS_res / SS_tot` of a linear model
567/// `(weights, intercept)` on `(x_val, y_val)`.
568///
569/// Mirrors `sklearn.metrics.r2_score` for the dense single-output, uniformly
570/// weighted case (`RegressorMixin.score` -> `r2_score`, the regressor
571/// `_ValidationScoreCallback.__call__`, `_stochastic_gradient.py:79`):
572/// `SS_res = sum((y - y_pred)^2)`, `SS_tot = sum((y - mean(y))^2)`,
573/// `R^2 = 1 - SS_res/SS_tot`. The degenerate `SS_tot == 0` (constant `y_val`)
574/// case follows sklearn's `_metrics/_regression.py` convention: a perfect
575/// `SS_res == 0` scores `1.0`, otherwise `0.0`
576/// (`r2_score` `nonzero_denominator`/`nonzero_numerator` branch). Returns `0.0`
577/// for an empty validation set (no information).
578#[must_use]
579fn r2_score<F: Float>(
580 weights: &Array1<F>,
581 intercept: F,
582 x_val: &Array2<F>,
583 y_val: &Array1<F>,
584) -> F {
585 let n = y_val.len();
586 if n == 0 {
587 return F::zero();
588 }
589 let n_f = F::from(n).unwrap_or_else(F::one);
590 let mean = y_val.iter().fold(F::zero(), |acc, &v| acc + v) / n_f;
591 let mut ss_res = F::zero();
592 let mut ss_tot = F::zero();
593 let n_features = weights.len();
594 for i in 0..n {
595 let xi = x_val.row(i);
596 let mut pred = intercept;
597 for j in 0..n_features {
598 pred = pred + weights[j] * xi[j];
599 }
600 let res = y_val[i] - pred;
601 ss_res = ss_res + res * res;
602 let dev = y_val[i] - mean;
603 ss_tot = ss_tot + dev * dev;
604 }
605 if ss_tot > F::zero() {
606 F::one() - ss_res / ss_tot
607 } else if ss_res > F::zero() {
608 // constant `y_val` but imperfect prediction -> R^2 = 0 (sklearn).
609 F::zero()
610 } else {
611 // constant `y_val` and perfect prediction -> R^2 = 1 (sklearn).
612 F::one()
613 }
614}
615
616/// Binary classification accuracy of a linear decision on `(x_val, y_val)` where
617/// `y_val` is the relabeled `{-1, +1}` target.
618///
619/// Mirrors the classifier `_ValidationScoreCallback.__call__`
620/// (`_stochastic_gradient.py:79`, `est.score(X_val, y_val)` ->
621/// `ClassifierMixin.score` -> `accuracy_score`) for one One-vs-All binary
622/// subproblem: the callback is built with `classes = np.array([-1, 1])` and the
623/// relabeled binary target `y_i` (`fit_binary`, `:451-454`), so the score is the
624/// fraction of validation samples whose binary decision `sign(w·x + b)` matches
625/// the relabeled label. The decision uses the `>= 0 -> +1` tie convention
626/// matching [`FittedSGDClassifier::predict`] (`scores[i] >= 0 -> classes[1]`).
627/// Returns `0.0` for an empty validation set.
628#[must_use]
629fn binary_accuracy<F: Float>(
630 weights: &Array1<F>,
631 intercept: F,
632 x_val: &Array2<F>,
633 y_val: &Array1<F>,
634) -> F {
635 let n = y_val.len();
636 if n == 0 {
637 return F::zero();
638 }
639 let n_features = weights.len();
640 let mut correct = 0usize;
641 for i in 0..n {
642 let xi = x_val.row(i);
643 let mut decision = intercept;
644 for j in 0..n_features {
645 decision = decision + weights[j] * xi[j];
646 }
647 let pred = if decision >= F::zero() {
648 F::one()
649 } else {
650 -F::one()
651 };
652 // `y_val[i]` is the relabeled `{-1, +1}` target; a positive product
653 // means `pred` and the label agree.
654 if pred * y_val[i] > F::zero() {
655 correct += 1;
656 }
657 }
658 F::from(correct).unwrap_or_else(F::zero) / F::from(n).unwrap_or_else(F::one)
659}
660
661/// Shared SGD epoch-end convergence / adaptive-eta tail.
662///
663/// Mirrors `_sgd_fast.pyx.tp:688-707` exactly. `epoch_sumloss` is the SUM of
664/// per-sample losses over the epoch (`:597`, NOT the printed mean `sumloss /
665/// train_count`). The criterion increments `no_improve_count` whenever
666/// `sumloss > best_loss - tol * train_count` (an epoch that fails to beat the
667/// running minimum by at least `tol*n`) and resets it otherwise; `best_loss`
668/// tracks the running minimum. Once `no_improve_count >= n_iter_no_change`,
669/// under the adaptive schedule (`eta > 1e-6`) `eta` is divided by 5 and the
670/// count reset (`:699-701`); otherwise the caller breaks (convergence, `:702`).
671///
672/// Returns `true` iff the epoch loop should `break` (convergence). The two
673/// branches are mutually exclusive, exactly as upstream: adaptive decays eta
674/// and keeps running, non-adaptive (or eta already `<= 1e-6`) stops.
675#[allow(clippy::too_many_arguments, reason = "mirrors the upstream epoch tail")]
676#[inline]
677fn convergence_tail<F: Float>(
678 epoch_sumloss: F,
679 best_loss: &mut F,
680 no_improve_count: &mut usize,
681 current_eta: &mut F,
682 tol_active: bool,
683 tol: F,
684 n_samples: usize,
685 n_iter_no_change: usize,
686 adaptive: bool,
687) -> bool {
688 // `_sgd_fast.pyx.tp:690-693`: training-loss branch (early_stopping=False).
689 let n = F::from(n_samples).unwrap_or_else(F::zero);
690 if tol_active && epoch_sumloss > *best_loss - tol * n {
691 *no_improve_count += 1;
692 } else {
693 *no_improve_count = 0;
694 }
695 // `:694-695`: track the running minimum.
696 if epoch_sumloss < *best_loss {
697 *best_loss = epoch_sumloss;
698 }
699 // `:698-707`: convergence break OR adaptive eta/=5.
700 if *no_improve_count >= n_iter_no_change {
701 // `:699`: `if learning_rate == ADAPTIVE and eta > 1e-6`.
702 let eta_floor = F::from(1e-6_f64).unwrap_or_else(F::zero);
703 let divisor = F::from(5.0_f64).unwrap_or_else(F::one);
704 if adaptive && *current_eta > eta_floor {
705 *current_eta = *current_eta / divisor;
706 *no_improve_count = 0;
707 false
708 } else {
709 true
710 }
711 } else {
712 false
713 }
714}
715
716/// Score-based SGD epoch-end convergence / adaptive-eta tail (early stopping).
717///
718/// Mirrors the `if early_stopping:` branch of `_sgd_fast.pyx.tp:678-707`. The
719/// validation `score` (R^2 for the regressor, binary accuracy for the
720/// classifier subproblem) replaces the training loss, and — because a HIGHER
721/// score is better — `best_score` is initialized to `-inf` and the criterion
722/// flips sense relative to [`convergence_tail`]: `no_improvement_count`
723/// increments when `score < best_score + tol` (the epoch fails to beat the best
724/// score so far by at least `tol`, `:682-685`) and resets otherwise; `best_score`
725/// tracks the running MAXIMUM (`:686-687`). The shared
726/// `no_improvement_count >= n_iter_no_change` tail (`:698-707`, adaptive eta/=5
727/// or break) is identical to the loss path.
728///
729/// Returns `true` iff the epoch loop should `break` (convergence).
730#[allow(clippy::too_many_arguments, reason = "mirrors the upstream epoch tail")]
731#[inline]
732fn convergence_tail_score<F: Float>(
733 score: F,
734 best_score: &mut F,
735 no_improve_count: &mut usize,
736 current_eta: &mut F,
737 tol_active: bool,
738 tol: F,
739 n_iter_no_change: usize,
740 adaptive: bool,
741) -> bool {
742 // `_sgd_fast.pyx.tp:682-685`: validation-score branch (early_stopping=True).
743 if tol_active && score < *best_score + tol {
744 *no_improve_count += 1;
745 } else {
746 *no_improve_count = 0;
747 }
748 // `:686-687`: track the running maximum.
749 if score > *best_score {
750 *best_score = score;
751 }
752 // `:698-707`: convergence break OR adaptive eta/=5 (shared with the loss
753 // path — byte-identical logic).
754 if *no_improve_count >= n_iter_no_change {
755 let eta_floor = F::from(1e-6_f64).unwrap_or_else(F::zero);
756 let divisor = F::from(5.0_f64).unwrap_or_else(F::one);
757 if adaptive && *current_eta > eta_floor {
758 *current_eta = *current_eta / divisor;
759 *no_improve_count = 0;
760 false
761 } else {
762 true
763 }
764 } else {
765 false
766 }
767}
768
769// ---------------------------------------------------------------------------
770// Classifier loss enum
771// ---------------------------------------------------------------------------
772
773/// Available loss functions for [`SGDClassifier`].
774#[derive(Debug, Clone, Copy)]
775pub enum ClassifierLoss {
776 /// Hinge loss (linear SVM).
777 Hinge,
778 /// Squared hinge loss (quadratically penalized SVM,
779 /// `_stochastic_gradient.py:511`).
780 SquaredHinge,
781 /// Perceptron loss (`Hinge(threshold=0.0)`,
782 /// `_stochastic_gradient.py:512`).
783 Perceptron,
784 /// Log loss (logistic regression).
785 Log,
786 /// Squared error loss.
787 SquaredError,
788 /// Modified Huber loss.
789 ModifiedHuber,
790}
791
792/// Available loss functions for [`SGDRegressor`].
793#[derive(Debug, Clone, Copy)]
794pub enum RegressorLoss<F> {
795 /// Squared error loss (default).
796 SquaredError,
797 /// Huber loss with the given epsilon.
798 Huber(F),
799 /// Epsilon-insensitive loss with the given epsilon.
800 EpsilonInsensitive(F),
801 /// Squared epsilon-insensitive loss with the given epsilon
802 /// (`_stochastic_gradient.py:1405`, `_sgd_fast.pyx.tp:364-388`).
803 SquaredEpsilonInsensitive(F),
804}
805
806// ---------------------------------------------------------------------------
807// SGDClassifier
808// ---------------------------------------------------------------------------
809
810/// Stochastic Gradient Descent classifier.
811///
812/// Supports binary classification via a decision boundary and multiclass
813/// classification via one-vs-all decomposition.
814///
815/// # Type Parameters
816///
817/// - `F`: The floating-point type (`f32` or `f64`).
818///
819/// # Examples
820///
821/// ```
822/// use ferrolearn_linear::sgd::SGDClassifier;
823/// use ferrolearn_core::{Fit, Predict};
824/// use ndarray::{array, Array2};
825///
826/// let x = Array2::from_shape_vec((6, 2), vec![
827/// 1.0, 2.0, 2.0, 3.0, 3.0, 1.0,
828/// 8.0, 7.0, 9.0, 8.0, 7.0, 9.0,
829/// ]).unwrap();
830/// let y = array![0, 0, 0, 1, 1, 1];
831///
832/// let clf = SGDClassifier::<f64>::new();
833/// let fitted = clf.fit(&x, &y).unwrap();
834/// let preds = fitted.predict(&x).unwrap();
835/// ```
836#[derive(Debug, Clone)]
837pub struct SGDClassifier<F> {
838 /// The loss function to use.
839 pub loss: ClassifierLoss,
840 /// The learning rate schedule.
841 pub learning_rate: LearningRateSchedule<F>,
842 /// Initial learning rate.
843 pub eta0: F,
844 /// Regularization strength (`alpha`).
845 pub alpha: F,
846 /// Regularization penalty (`l2`/`l1`/`elasticnet`). Defaults to `L2`.
847 pub penalty: Penalty,
848 /// Elastic-net mixing parameter; only used when `penalty == ElasticNet`.
849 /// Defaults to `0.15` (sklearn default).
850 pub l1_ratio: F,
851 /// Maximum number of passes over the training data.
852 pub max_iter: usize,
853 /// Convergence tolerance. Training stops when the loss improvement
854 /// is below this threshold.
855 pub tol: F,
856 /// Optional random seed for sample shuffling.
857 pub random_state: Option<u64>,
858 /// Power parameter for inverse scaling schedule.
859 pub power_t: F,
860 /// Whether to shuffle the training data after each epoch. Defaults to
861 /// `true` (sklearn `SGDClassifier(shuffle=True)`,
862 /// `_stochastic_gradient.py:107`).
863 pub shuffle: bool,
864 /// Number of consecutive epochs with no loss improvement (beyond `tol`)
865 /// before convergence triggers, or — under the `adaptive` schedule — before
866 /// `eta` is divided by 5. Defaults to `5` (sklearn
867 /// `_stochastic_gradient.py` `n_iter_no_change=5`, `_sgd_fast.pyx.tp:698`).
868 pub n_iter_no_change: usize,
869 /// Whether to fit (update) the intercept. Defaults to `true` (sklearn
870 /// `SGDClassifier(fit_intercept=True)`, `_stochastic_gradient.py:104`,
871 /// constraint `["boolean"]` at `:86`). When `false` the intercept is never
872 /// updated and stays at its init value `0` (`_sgd_fast.pyx.tp:639-644`: the
873 /// intercept update is gated on `if fit_intercept == 1`).
874 pub fit_intercept: bool,
875 /// Per-class weighting strategy. Defaults to [`ClassWeight::None`] (uniform).
876 /// Mirrors sklearn's `class_weight` parameter (default `None`); the expanded
877 /// weights scale the per-sample gradient term via
878 /// `update *= class_weight * sample_weight` (`_sgd_fast.pyx.tp:599-602,630`).
879 pub class_weight: ClassWeight<F>,
880 /// Averaged-SGD (ASGD) threshold. `0` disables averaging (the default,
881 /// matching sklearn `average=False`); `1` averages from the first step
882 /// (sklearn `average=True`); `N > 1` begins averaging once the global step
883 /// counter `t >= N` (sklearn `average=N`). The averaged weights/intercept
884 /// replace the plain ones at fit-end when `average <= self.t_ - 1`
885 /// (`_sgd_fast.pyx.tp:646-654`, `_stochastic_gradient.py:834-836`).
886 pub average: usize,
887 /// Whether to stop training early based on a held-out validation score.
888 /// Defaults to `false` (sklearn `SGDClassifier(early_stopping=False)`,
889 /// `_stochastic_gradient.py:114`, constraint `["boolean"]` at `:524`). When
890 /// `true`, [`validation_fraction`](Self::validation_fraction) of the training
891 /// data is held out (stratified) as a validation set and the epoch-end
892 /// convergence rule uses the validation accuracy of each One-vs-All binary
893 /// subproblem instead of the training loss (`_sgd_fast.pyx.tp:678-687`).
894 pub early_stopping: bool,
895 /// Fraction of the training data held out as the validation set when
896 /// [`early_stopping`](Self::early_stopping) is `true`. Defaults to `0.1`
897 /// (sklearn `validation_fraction=0.1`, `_stochastic_gradient.py:115`). Must
898 /// lie in the open interval `(0, 1)`
899 /// (constraint `Interval(Real, 0, 1, closed="neither")` at `:525`).
900 pub validation_fraction: F,
901}
902
903impl<F: Float> SGDClassifier<F> {
904 /// Create a new `SGDClassifier` with default settings.
905 ///
906 /// Defaults match scikit-learn's `SGDClassifier.__init__`
907 /// (`_stochastic_gradient.py:1231-1256`): `loss = Hinge`,
908 /// `learning_rate = Optimal`, `eta0 = 0.0`, `alpha = 0.0001`,
909 /// `penalty = L2`, `l1_ratio = 0.15`, `max_iter = 1000`, `tol = 1e-3`,
910 /// `power_t = 0.5`.
911 #[must_use]
912 pub fn new() -> Self {
913 Self {
914 loss: ClassifierLoss::Hinge,
915 learning_rate: LearningRateSchedule::Optimal,
916 eta0: cst(0.0),
917 alpha: cst(0.0001),
918 penalty: Penalty::L2,
919 l1_ratio: cst(0.15),
920 max_iter: 1000,
921 tol: cst(1e-3),
922 random_state: None,
923 power_t: cst(0.5),
924 shuffle: true,
925 n_iter_no_change: 5,
926 fit_intercept: true,
927 class_weight: ClassWeight::None,
928 average: 0,
929 early_stopping: false,
930 validation_fraction: cst(0.1),
931 }
932 }
933
934 /// Set the loss function.
935 #[must_use]
936 pub fn with_loss(mut self, loss: ClassifierLoss) -> Self {
937 self.loss = loss;
938 self
939 }
940
941 /// Set the learning rate schedule.
942 #[must_use]
943 pub fn with_learning_rate(mut self, lr: LearningRateSchedule<F>) -> Self {
944 self.learning_rate = lr;
945 self
946 }
947
948 /// Set the initial learning rate.
949 #[must_use]
950 pub fn with_eta0(mut self, eta0: F) -> Self {
951 self.eta0 = eta0;
952 self
953 }
954
955 /// Set the regularization strength.
956 #[must_use]
957 pub fn with_alpha(mut self, alpha: F) -> Self {
958 self.alpha = alpha;
959 self
960 }
961
962 /// Set the regularization penalty (`l2`/`l1`/`elasticnet`).
963 #[must_use]
964 pub fn with_penalty(mut self, penalty: Penalty) -> Self {
965 self.penalty = penalty;
966 self
967 }
968
969 /// Set the elastic-net mixing parameter (`l1_ratio`, used only when
970 /// `penalty == ElasticNet`).
971 #[must_use]
972 pub fn with_l1_ratio(mut self, l1_ratio: F) -> Self {
973 self.l1_ratio = l1_ratio;
974 self
975 }
976
977 /// Set the maximum number of epochs.
978 #[must_use]
979 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
980 self.max_iter = max_iter;
981 self
982 }
983
984 /// Set the convergence tolerance.
985 #[must_use]
986 pub fn with_tol(mut self, tol: F) -> Self {
987 self.tol = tol;
988 self
989 }
990
991 /// Set the random seed for reproducibility.
992 #[must_use]
993 pub fn with_random_state(mut self, seed: u64) -> Self {
994 self.random_state = Some(seed);
995 self
996 }
997
998 /// Set the power parameter for inverse scaling.
999 #[must_use]
1000 pub fn with_power_t(mut self, power_t: F) -> Self {
1001 self.power_t = power_t;
1002 self
1003 }
1004
1005 /// Set whether the training data is shuffled after each epoch.
1006 ///
1007 /// Mirrors sklearn's `shuffle` parameter (default `True`,
1008 /// `_stochastic_gradient.py:107`). With `false` the samples are visited in
1009 /// index order `0..n-1` every epoch (`_sgd_fast.pyx.tp:579-581`), making the
1010 /// fit fully deterministic and cross-impl comparable to sklearn.
1011 #[must_use]
1012 pub fn with_shuffle(mut self, shuffle: bool) -> Self {
1013 self.shuffle = shuffle;
1014 self
1015 }
1016
1017 /// Set the number of consecutive non-improving epochs before convergence
1018 /// (or, under the `adaptive` schedule, before `eta` is divided by 5).
1019 ///
1020 /// Mirrors sklearn's `n_iter_no_change` parameter (default `5`,
1021 /// `_stochastic_gradient.py`); the epoch-end stop rule at
1022 /// `_sgd_fast.pyx.tp:698` triggers once `no_improvement_count` reaches it.
1023 #[must_use]
1024 pub fn with_n_iter_no_change(mut self, n_iter_no_change: usize) -> Self {
1025 self.n_iter_no_change = n_iter_no_change;
1026 self
1027 }
1028
1029 /// Set whether the intercept (bias) term is fit.
1030 ///
1031 /// Mirrors sklearn's `fit_intercept` parameter (default `True`,
1032 /// `_stochastic_gradient.py:104`, constraint `["boolean"]` at `:86`). With
1033 /// `false` the intercept update is skipped every step
1034 /// (`_sgd_fast.pyx.tp:639-644`: `if fit_intercept == 1: ... intercept += ...`)
1035 /// and the intercept stays at its init value `0`.
1036 #[must_use]
1037 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
1038 self.fit_intercept = fit_intercept;
1039 self
1040 }
1041
1042 /// Set the per-class weighting strategy.
1043 ///
1044 /// Mirrors sklearn's `class_weight` parameter (default `None`). The expanded
1045 /// per-class weights (via [`compute_class_weight`]) scale only the
1046 /// gradient-derived part of each per-sample update
1047 /// (`_sgd_fast.pyx.tp:599-602,630`); the L2 shrink, L1 truncation and the
1048 /// one-class offset are left unscaled.
1049 #[must_use]
1050 pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
1051 self.class_weight = class_weight;
1052 self
1053 }
1054
1055 /// Set the averaged-SGD (ASGD) threshold.
1056 ///
1057 /// Mirrors sklearn's `average` parameter (default `False`,
1058 /// `_stochastic_gradient.py:1256`). `0` disables averaging (the plain SGD
1059 /// trajectory, byte-identical to the unaveraged kernel); `with_average(1)`
1060 /// is sklearn `average=True` (average from the first step); `with_average(N)`
1061 /// is sklearn `average=N` (begin averaging once the global step counter
1062 /// `t >= N`). The running mean of the post-update weights/intercept replaces
1063 /// the plain `coef_`/`intercept_` at fit-end when `average <= self.t_ - 1`
1064 /// (`_sgd_fast.pyx.tp:646-654`, `_stochastic_gradient.py:834-836`).
1065 #[must_use]
1066 pub fn with_average(mut self, average: usize) -> Self {
1067 self.average = average;
1068 self
1069 }
1070
1071 /// Enable or disable early stopping on a held-out validation score.
1072 ///
1073 /// Mirrors sklearn's `early_stopping` parameter (default `False`,
1074 /// `_stochastic_gradient.py:114`, constraint `["boolean"]` at `:524`). When
1075 /// enabled, [`with_validation_fraction`](Self::with_validation_fraction) of
1076 /// the data is held out (stratified per class) and each One-vs-All binary
1077 /// subproblem's epoch-end convergence is driven by its validation accuracy
1078 /// rather than the training loss (`_sgd_fast.pyx.tp:678-687`).
1079 #[must_use]
1080 pub fn with_early_stopping(mut self, early_stopping: bool) -> Self {
1081 self.early_stopping = early_stopping;
1082 self
1083 }
1084
1085 /// Set the fraction of the training data held out for early-stopping
1086 /// validation.
1087 ///
1088 /// Mirrors sklearn's `validation_fraction` parameter (default `0.1`,
1089 /// `_stochastic_gradient.py:115`, constraint
1090 /// `Interval(Real, 0, 1, closed="neither")` at `:525`). Only used when
1091 /// [`early_stopping`](Self::early_stopping) is `true`; validated to the open
1092 /// interval `(0, 1)` at fit time.
1093 #[must_use]
1094 pub fn with_validation_fraction(mut self, validation_fraction: F) -> Self {
1095 self.validation_fraction = validation_fraction;
1096 self
1097 }
1098}
1099
1100impl<F: Float> Default for SGDClassifier<F> {
1101 fn default() -> Self {
1102 Self::new()
1103 }
1104}
1105
1106/// Extract hyperparameter bundle from an `SGDClassifier`.
1107fn clf_hyper<F: Float>(clf: &SGDClassifier<F>) -> SGDHyper<F> {
1108 SGDHyper {
1109 learning_rate: clf.learning_rate,
1110 eta0: clf.eta0,
1111 alpha: clf.alpha,
1112 max_iter: clf.max_iter,
1113 tol: clf.tol,
1114 random_state: clf.random_state,
1115 power_t: clf.power_t,
1116 penalty: clf.penalty,
1117 l1_ratio: clf.l1_ratio,
1118 shuffle: clf.shuffle,
1119 n_iter_no_change: clf.n_iter_no_change,
1120 fit_intercept: clf.fit_intercept,
1121 one_class: false,
1122 average: clf.average,
1123 early_stopping: clf.early_stopping,
1124 validation_fraction: clf.validation_fraction,
1125 }
1126}
1127
1128/// Internal hyperparameter bundle shared between Fit and PartialFit paths.
1129#[derive(Debug, Clone)]
1130struct SGDHyper<F> {
1131 learning_rate: LearningRateSchedule<F>,
1132 eta0: F,
1133 alpha: F,
1134 max_iter: usize,
1135 tol: F,
1136 random_state: Option<u64>,
1137 power_t: F,
1138 /// Regularization penalty (l2/l1/elasticnet).
1139 penalty: Penalty,
1140 /// Elastic-net mixing parameter (only meaningful for `ElasticNet`).
1141 l1_ratio: F,
1142 /// Whether to shuffle the sample order each epoch (`_sgd_fast.pyx.tp:579`).
1143 shuffle: bool,
1144 /// Number of consecutive non-improving epochs before convergence /
1145 /// adaptive-eta decay triggers (`_stochastic_gradient.py` default 5,
1146 /// `_sgd_fast.pyx.tp:698`).
1147 n_iter_no_change: usize,
1148 /// Whether to fit (update) the intercept each step. When `false` the
1149 /// intercept update is skipped (`_sgd_fast.pyx.tp:639-644`).
1150 fit_intercept: bool,
1151 /// Whether this is a one-class SVM fit. When `true` the (gated) intercept
1152 /// update gains the extra `- 2*eta*alpha` term, mirroring the `if one_class`
1153 /// branch in `_sgd_fast.pyx.tp:641-642`
1154 /// (`intercept_update -= 2. * eta * alpha`). `false` for the standard
1155 /// classifier/regressor paths, leaving their intercept update byte-identical.
1156 one_class: bool,
1157 /// Averaged-SGD (ASGD) threshold. `0` disables averaging (the default,
1158 /// byte-identical to the plain SGD trajectory). `N > 0` begins accumulating
1159 /// the running mean of the post-update weights/intercept once the global step
1160 /// counter `t >= N`, mirroring `if 0 < average <= t` (`_sgd_fast.pyx.tp:646`).
1161 /// sklearn `average=True` maps to `N = 1`; `average=N` maps to `N`.
1162 average: usize,
1163 /// Whether to use early stopping on a held-out validation score. When
1164 /// `true` the epoch-end convergence rule scores the current weights on the
1165 /// validation set (R^2 / accuracy) instead of the training loss
1166 /// (`_sgd_fast.pyx.tp:678-687`, `_stochastic_gradient.py:114`). The
1167 /// validation set itself is split off in the `Fit` path BEFORE the kernel
1168 /// and passed in separately; this flag only selects the score-based
1169 /// epoch-end branch. `false` (the default) leaves the training-loss
1170 /// convergence path byte-identical.
1171 early_stopping: bool,
1172 /// Fraction of the training data held out as the validation set when
1173 /// `early_stopping` is `true` (`_stochastic_gradient.py:115`, default `0.1`,
1174 /// constraint `Interval(Real, 0, 1, closed="neither")` at `:525`). Carried
1175 /// for validation/documentation; the actual split happens in the `Fit` path.
1176 validation_fraction: F,
1177}
1178
1179/// Train a single binary classifier via SGD, updating `weights` and
1180/// `intercept` in place. `y_binary` must be in `{-1, +1}`.
1181///
1182/// `sample_w[i]` is the per-sample weight `class_weight_i * sample_weight_i`
1183/// for sample `i` (`_sgd_fast.pyx.tp:599-602,630`). It scales ONLY the
1184/// gradient-derived part of the update (`update *= class_weight * sample_weight`
1185/// at `:630`); the L2 shrink (`:632-635`), the L1 truncation (`:656-658`) and
1186/// the one-class `-2*eta*alpha` offset (`:642`) are left unscaled. An all-ones
1187/// `sample_w` (the default `fit` path) reproduces the byte-identical unweighted
1188/// behaviour. `sample_w.len()` must equal `x.nrows()`.
1189///
1190/// Returns the cumulative loss and the step counter after training.
1191#[allow(
1192 clippy::too_many_arguments,
1193 reason = "threads the per-sample weight vector"
1194)]
1195fn train_binary_sgd<F, L>(
1196 x: &Array2<F>,
1197 y_binary: &Array1<F>,
1198 weights: &mut Array1<F>,
1199 intercept: &mut F,
1200 loss_fn: &L,
1201 hyper: &SGDHyper<F>,
1202 initial_t: usize,
1203 sample_w: &[F],
1204 val_set: Option<(&Array2<F>, &Array1<F>)>,
1205) -> (F, usize)
1206where
1207 F: Float + ScalarOperand + Send + Sync + 'static,
1208 L: Loss<F>,
1209{
1210 let n_samples = x.nrows();
1211 let n_features = x.ncols();
1212 let mut t = initial_t;
1213 // Epoch-end convergence state, mirroring `_sgd_fast.pyx.tp:525,532-534`:
1214 // `best_loss = INFINITY`, `no_improvement_count = 0`. `current_eta` carries
1215 // the adaptive-schedule eta (`eta = eta / 5` decay, `:700`). When early
1216 // stopping is active `best_score = -INFINITY` instead (higher score is
1217 // better — `_sgd_fast.pyx.tp:533`).
1218 let mut best_loss = F::infinity();
1219 let mut best_score = F::neg_infinity();
1220 // Early stopping uses the score branch only when a validation set was split
1221 // off in the `Fit` path (`val_set.is_some()`). The relabeled `{-1,+1}`
1222 // binary validation target is scored with accuracy (`binary_accuracy`,
1223 // `_stochastic_gradient.py:451-454,79`).
1224 let early_stopping = hyper.early_stopping && val_set.is_some();
1225 let mut current_eta = hyper.eta0;
1226 let mut no_improve_count: usize = 0;
1227 // `tol = None` upstream becomes `-INFINITY`, disabling the stop rule
1228 // (`tol > -INFINITY` is false, `:690`). ferrolearn encodes that as a
1229 // finite/-inf `tol`; the criterion is active iff `tol > -inf`.
1230 let tol_active = hyper.tol > F::neg_infinity();
1231 let max_dloss = max_dloss::<F>();
1232 let mut indices: Vec<usize> = (0..n_samples).collect();
1233 // `optimal_init` (the `optimal` schedule's t0 offset) depends on the loss
1234 // and alpha, so it is computed once per fit, before the epoch loop
1235 // (`_sgd_fast.pyx.tp:565-570`).
1236 let opt_init = optimal_init(loss_fn, hyper.alpha);
1237
1238 // Effective l1_ratio from the penalty (`_sgd_fast.pyx.tp:558-561`):
1239 // `L2 -> 0.0`, `L1 -> 1.0`, `ElasticNet -> user l1_ratio`.
1240 let eff = effective_l1_ratio(hyper.penalty, hyper.l1_ratio);
1241 let apply_l1 = matches!(hyper.penalty, Penalty::L1 | Penalty::ElasticNet);
1242 // Tsuruoka cumulative-penalty state. `u` (scalar) accumulates the total L1
1243 // penalty applied so far; `q` (per-feature) records how much penalty has
1244 // actually been applied to each weight. Both persist for the WHOLE fit —
1245 // allocated once before the epoch loop, mirroring `q = np.zeros(...)` and
1246 // `u = 0.0` allocated once per `_plain_sgd` call (`_sgd_fast.pyx.tp:551-556`).
1247 let mut u = F::zero();
1248 let mut q: Array1<F> = Array1::zeros(n_features);
1249
1250 // Averaged-SGD (ASGD) accumulators (`_sgd_fast.pyx.tp:646-654`). When
1251 // `hyper.average > 0`, once the global step `t >= average` we maintain the
1252 // running mean of the POST-update weights/intercept. This is the DIRECT
1253 // running-mean form of sklearn's lazy `w.add_average` (a wscale optimization
1254 // that is mathematically identical for plain arrays): with
1255 // `num_iter = t - average + 1` (= 1 at the first averaged step) and
1256 // `mu = 1/num_iter`, `avg += (current - avg) * mu`. The accumulator is a
1257 // PASSIVE observer — it never feeds back into the live `weights`/`intercept`.
1258 let mut average_coef: Array1<F> = Array1::zeros(n_features);
1259 let mut average_intercept = F::zero();
1260
1261 // Build the RNG for shuffling.
1262 let mut rng = match hyper.random_state {
1263 Some(seed) => rand::rngs::StdRng::seed_from_u64(seed),
1264 None => rand::rngs::StdRng::from_os_rng(),
1265 };
1266
1267 let mut total_loss = F::zero();
1268
1269 for _epoch in 0..hyper.max_iter {
1270 // sklearn shuffles the sample order each epoch only when `shuffle` is
1271 // set (`_sgd_fast.pyx.tp:579-580`: `if shuffle: dataset.shuffle(seed)`).
1272 // With `shuffle == false` the indices stay `0..n-1` every epoch, exactly
1273 // matching sklearn's no-shuffle index order (`:581 for i in range(n)`).
1274 if hyper.shuffle {
1275 indices.shuffle(&mut rng);
1276 }
1277 let mut epoch_loss = F::zero();
1278
1279 for &i in &indices {
1280 t += 1;
1281
1282 let eta = match hyper.learning_rate {
1283 LearningRateSchedule::Adaptive => current_eta,
1284 _ => compute_lr(
1285 &hyper.learning_rate,
1286 hyper.eta0,
1287 hyper.alpha,
1288 hyper.power_t,
1289 opt_init,
1290 t,
1291 ),
1292 };
1293
1294 // Compute prediction: w^T x_i + b.
1295 let mut y_pred = *intercept;
1296 let xi = x.row(i);
1297 for j in 0..n_features {
1298 y_pred = y_pred + weights[j] * xi[j];
1299 }
1300
1301 // Clip the gradient to `[-MAX_DLOSS, MAX_DLOSS]` before forming the
1302 // update, matching `_sgd_fast.pyx.tp:613-620`.
1303 let grad = loss_fn
1304 .gradient(y_binary[i], y_pred)
1305 .max(-max_dloss)
1306 .min(max_dloss);
1307 // Per-sample weight scaling: `update *= class_weight * sample_weight`
1308 // (`_sgd_fast.pyx.tp:630`). `update = -eta*dloss`, so scaling the
1309 // update by `w_i` is equivalent to scaling the (clipped) gradient
1310 // `dloss` by `w_i` BEFORE forming both the weight data term and the
1311 // (gated) intercept gradient term. This multiplies ONLY the
1312 // gradient-derived part; the L2 shrink, L1 truncation and the
1313 // one-class offset below are unaffected. `g` is the scaled gradient.
1314 let g = grad * sample_w[i];
1315 // `sumloss` is the SUM (not mean) of per-sample losses over the
1316 // epoch (`_sgd_fast.pyx.tp:597`), computed from the UNWEIGHTED loss
1317 // (the weight only multiplies `update`, not `loss.loss(y, p)`).
1318 epoch_loss = epoch_loss + loss_fn.loss(y_binary[i], y_pred);
1319
1320 // L2 shrink: scale the whole weight vector by the CLAMPED factor
1321 // `max(0, 1 - (1-eff)*eta*alpha)` BEFORE the gradient add, mirroring
1322 // `w.scale(max(0, 1 - (1-l1_ratio)*eta*alpha))`
1323 // (`_sgd_fast.pyx.tp:632-635`). For pure L2 (`eff=0`) this is
1324 // `max(0, 1-eta*alpha)`; for L1 (`eff=1`) it is `max(0, 1) = 1`
1325 // (no L2 shrink); for elasticnet the `(1-eff)` weakens the L2 part.
1326 let shrink = (F::one() - (F::one() - eff) * eta * hyper.alpha).max(F::zero());
1327 for j in 0..n_features {
1328 weights[j] = weights[j] * shrink;
1329 }
1330 // Gradient add `w.add(x, update)` with the scaled `update = -eta*g`
1331 // (`_sgd_fast.pyx.tp:637-638`); `g` is the sample-weighted gradient.
1332 for j in 0..n_features {
1333 weights[j] = weights[j] - eta * g * xi[j];
1334 }
1335 // The intercept update is gated on `fit_intercept` and is NOT
1336 // regularized (`intercept_decay=1`, `_sgd_fast.pyx.tp:639-644`:
1337 // `if fit_intercept == 1: intercept_update = update; if one_class:
1338 // intercept_update -= 2.*eta*alpha; intercept += intercept_update *
1339 // intercept_decay`). `update = -eta*g` is the SCALED update (sklearn
1340 // sets `intercept_update = update` at `:640`, after `update *=
1341 // class_weight*sample_weight` at `:630`), so the standard path is
1342 // `intercept -= eta*g`. For the one-class SVM the extra
1343 // `- 2*eta*alpha` term is added (`:641-642`) and is NOT scaled by the
1344 // sample weight. When `fit_intercept` is false the intercept is never
1345 // modified and stays at its init value (`0` clf/reg, `1` one-class).
1346 if hyper.fit_intercept {
1347 let two = cst::<F>(2.0);
1348 let mut intercept_update = -eta * g;
1349 if hyper.one_class {
1350 intercept_update = intercept_update - two * eta * hyper.alpha;
1351 }
1352 *intercept = *intercept + intercept_update;
1353 }
1354
1355 // L1 cumulative penalty (Tsuruoka truncated gradient), applied AFTER
1356 // the gradient add only for L1/ElasticNet (`_sgd_fast.pyx.tp:656-658`,
1357 // `l1penalty` at `:750-778` with `wscale = 1`).
1358 if apply_l1 {
1359 u = u + eff * eta * hyper.alpha;
1360 for j in 0..n_features {
1361 let z = weights[j];
1362 if weights[j] > F::zero() {
1363 weights[j] = (weights[j] - (u + q[j])).max(F::zero());
1364 } else if weights[j] < F::zero() {
1365 weights[j] = (weights[j] + (u - q[j])).min(F::zero());
1366 }
1367 q[j] = q[j] + (weights[j] - z);
1368 }
1369 }
1370
1371 // ASGD running-mean accumulation (`_sgd_fast.pyx.tp:646-654`:
1372 // `if 0 < average <= t: w.add_average(..., t - average + 1);
1373 // average_intercept += (intercept - average_intercept) /
1374 // (t - average + 1)`). Performed AFTER the weight/intercept update +
1375 // L1 truncation, so `weights`/`intercept` hold their final post-step
1376 // values for this sample. `t` here is the SAME 1-based global step
1377 // the schedule used above. `num_iter = t - average + 1` is `>= 1`
1378 // whenever `t >= average`.
1379 if hyper.average > 0 && t >= hyper.average {
1380 let num_iter = t - hyper.average + 1;
1381 let num_iter_f = F::from(num_iter).unwrap_or_else(F::one);
1382 let mu = F::one() / num_iter_f;
1383 for j in 0..n_features {
1384 average_coef[j] = average_coef[j] + (weights[j] - average_coef[j]) * mu;
1385 }
1386 average_intercept = average_intercept + (*intercept - average_intercept) * mu;
1387 }
1388 }
1389
1390 // `epoch_loss` is now the epoch `sumloss` (no mean division).
1391 total_loss = epoch_loss;
1392
1393 // Epoch-end stop rule (`_sgd_fast.pyx.tp:678-707`). When early stopping
1394 // is active, score the CURRENT weights/intercept on the held-out
1395 // validation set (binary accuracy of the relabeled `{-1,+1}` target,
1396 // `_stochastic_gradient.py:79`) and run the score-based branch
1397 // (`best_score` init `-inf`, higher is better, `:678-687`); otherwise
1398 // the training-loss branch (`sumloss` vs `best_loss`, `:688-695`).
1399 let should_break = if let (true, Some((x_val, y_val))) = (early_stopping, val_set) {
1400 let score = binary_accuracy(weights, *intercept, x_val, y_val);
1401 convergence_tail_score(
1402 score,
1403 &mut best_score,
1404 &mut no_improve_count,
1405 &mut current_eta,
1406 tol_active,
1407 hyper.tol,
1408 hyper.n_iter_no_change,
1409 matches!(hyper.learning_rate, LearningRateSchedule::Adaptive),
1410 )
1411 } else {
1412 convergence_tail(
1413 epoch_loss,
1414 &mut best_loss,
1415 &mut no_improve_count,
1416 &mut current_eta,
1417 tol_active,
1418 hyper.tol,
1419 n_samples,
1420 hyper.n_iter_no_change,
1421 matches!(hyper.learning_rate, LearningRateSchedule::Adaptive),
1422 )
1423 };
1424 if should_break {
1425 break;
1426 }
1427 }
1428
1429 // ASGD finalize: select the averaged weights/intercept when averaging was
1430 // enabled AND enough steps were taken (`_stochastic_gradient.py:834-836`:
1431 // `if self.average > 0: if self.average <= self.t_ - 1: coef_ =
1432 // average_coef`). Here `t` is the returned step counter `= n_iter_ *
1433 // n_samples` (`initial_t = 0` on the full-fit path), which equals sklearn's
1434 // `self.t_ - 1` (sklearn inits `self.t_ = 1`, then `self.t_ += n_iter_ *
1435 // n_samples`). So `average <= self.t_ - 1` maps to `hyper.average <= t`.
1436 if hyper.average > 0 && hyper.average <= t {
1437 for j in 0..n_features {
1438 weights[j] = average_coef[j];
1439 }
1440 *intercept = average_intercept;
1441 }
1442
1443 (total_loss, t)
1444}
1445
1446/// Fitted SGD classifier.
1447///
1448/// Holds the learned weight vectors and intercepts. For binary problems
1449/// there is a single weight vector; for multiclass problems there is one
1450/// per class (one-vs-all).
1451///
1452/// Implements [`Predict`] and [`PartialFit`] to support both inference and
1453/// online learning.
1454#[derive(Debug, Clone)]
1455pub struct FittedSGDClassifier<F> {
1456 /// Weight matrix: one row per binary sub-problem.
1457 /// Binary: shape `(1, n_features)`, multiclass: `(n_classes, n_features)`.
1458 weight_matrix: Vec<Array1<F>>,
1459 /// Intercept vector, one per sub-problem.
1460 intercepts: Vec<F>,
1461 /// Sorted unique class labels.
1462 classes: Vec<usize>,
1463 /// Number of features the model was trained on.
1464 n_features: usize,
1465 /// The loss function used during training.
1466 loss: ClassifierLoss,
1467 /// Hyperparameters for continued training via `partial_fit`.
1468 hyper: SGDHyper<F>,
1469 /// Global step counter across all partial_fit calls.
1470 t: usize,
1471}
1472
1473impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<usize>>
1474 for SGDClassifier<F>
1475{
1476 type Fitted = FittedSGDClassifier<F>;
1477 type Error = FerroError;
1478
1479 /// Fit the SGD classifier on the given data.
1480 ///
1481 /// # Errors
1482 ///
1483 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have mismatched
1484 /// sample counts.
1485 /// Returns [`FerroError::InsufficientSamples`] if fewer than 2 classes
1486 /// are present.
1487 /// Returns [`FerroError::InvalidParameter`] if `eta0` or `alpha` are
1488 /// not positive.
1489 fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedSGDClassifier<F>, FerroError> {
1490 // Delegate to the sample-weighted path with a uniform `ones(n)` weight
1491 // vector, so the default `fit` behaviour is byte-identical to the
1492 // unweighted kernel (`_check_sample_weight` returns `ones` when
1493 // `sample_weight=None`, `_stochastic_gradient.py:627`).
1494 let sample_weight = Array1::<F>::from_elem(x.nrows(), F::one());
1495 self.fit_with_sample_weight(x, y, &sample_weight)
1496 }
1497}
1498
1499impl<F: Float + Send + Sync + ScalarOperand + 'static> SGDClassifier<F> {
1500 /// Fit the SGD classifier with explicit per-sample weights.
1501 ///
1502 /// Mirrors `SGDClassifier.fit(X, y, sample_weight=...)`. The per-sample
1503 /// weight scales ONLY the gradient-derived part of each update
1504 /// (`update *= class_weight * sample_weight`, `_sgd_fast.pyx.tp:630`); the
1505 /// L2 shrink, L1 truncation and one-class offset are unscaled. The
1506 /// `class_weight` field (via [`compute_class_weight`],
1507 /// `_stochastic_gradient.py:624`) is combined multiplicatively per sample.
1508 ///
1509 /// [`Fit::fit`] delegates here with a uniform `ones(n)` weight vector.
1510 ///
1511 /// # Errors
1512 ///
1513 /// Returns [`FerroError::ShapeMismatch`] if `x`/`y`/`sample_weight` have
1514 /// mismatched sample counts.
1515 /// Returns [`FerroError::InsufficientSamples`] if fewer than 2 classes
1516 /// are present.
1517 /// Returns [`FerroError::InvalidParameter`] if `eta0` or `alpha` are
1518 /// invalid.
1519 pub fn fit_with_sample_weight(
1520 &self,
1521 x: &Array2<F>,
1522 y: &Array1<usize>,
1523 sample_weight: &Array1<F>,
1524 ) -> Result<FittedSGDClassifier<F>, FerroError> {
1525 validate_clf_params(
1526 x,
1527 y,
1528 &self.learning_rate,
1529 self.eta0,
1530 self.alpha,
1531 self.l1_ratio,
1532 self.validation_fraction,
1533 )?;
1534
1535 let n_samples = x.nrows();
1536 if sample_weight.len() != n_samples {
1537 return Err(FerroError::ShapeMismatch {
1538 expected: vec![n_samples],
1539 actual: vec![sample_weight.len()],
1540 context: "sample_weight length must match number of samples in X".into(),
1541 });
1542 }
1543
1544 // Non-finite input validation (#2263). sklearn `SGDClassifier.fit`
1545 // -> `self._validate_data(X, y, ...)` (`_stochastic_gradient.py:1476`)
1546 // keeps the default `force_all_finite=True`, so `check_array` rejects any
1547 // NaN or +/-inf in X with a `ValueError("Input X contains NaN.")` /
1548 // `"... contains infinity ..."` BEFORE the SGD kernel. sklearn also
1549 // validates `sample_weight` via `_check_sample_weight` (default
1550 // `force_all_finite=True`, `_stochastic_gradient.py:1501`). `y` is
1551 // `Array1<usize>` (integer class labels), finite by type, so only X +
1552 // sample_weight need the runtime check. `.iter().any(|v| !v.is_finite())`
1553 // rejects both NaN and Inf (bounds-safe, no panic, R-CODE-2); the finite
1554 // path is byte-identical. This is the SGDClassifier fit entry (`Fit::fit`
1555 // delegates here with unit weights).
1556 if x.iter().any(|v| !v.is_finite()) {
1557 return Err(FerroError::InvalidParameter {
1558 name: "X".into(),
1559 reason: "Input X contains NaN or infinity.".into(),
1560 });
1561 }
1562 if sample_weight.iter().any(|v| !v.is_finite()) {
1563 return Err(FerroError::InvalidParameter {
1564 name: "sample_weight".into(),
1565 reason: "Input sample_weight contains NaN or infinity.".into(),
1566 });
1567 }
1568
1569 let n_features = x.ncols();
1570 let mut classes: Vec<usize> = y.to_vec();
1571 classes.sort_unstable();
1572 classes.dedup();
1573
1574 if classes.len() < 2 {
1575 return Err(FerroError::InsufficientSamples {
1576 required: 2,
1577 actual: classes.len(),
1578 context: "SGDClassifier requires at least 2 distinct classes".into(),
1579 });
1580 }
1581
1582 let hyper = clf_hyper(self);
1583 let loss_enum = self.loss;
1584
1585 // Expanded per-class weights (`_stochastic_gradient.py:624`), aligned to
1586 // the sorted `classes` (= sklearn `classes_`).
1587 let expanded = compute_class_weight(&self.class_weight, &classes, &y.to_vec());
1588 let sw = sample_weight.to_vec();
1589
1590 let (weight_matrix, intercepts, t) = fit_ova(
1591 x, y, &classes, n_features, &loss_enum, &hyper, 0, &expanded, &sw,
1592 )?;
1593
1594 Ok(FittedSGDClassifier {
1595 weight_matrix,
1596 intercepts,
1597 classes,
1598 n_features,
1599 loss: loss_enum,
1600 hyper,
1601 t,
1602 })
1603 }
1604}
1605
1606/// Whether a learning-rate schedule requires `eta0 > 0`.
1607///
1608/// Mirrors sklearn `_more_validate_params` (`_stochastic_gradient.py:149-153`):
1609/// `eta0 > 0` is enforced only for `constant`/`invscaling`/`adaptive`; the
1610/// `optimal` schedule derives its own initial rate and accepts `eta0 == 0`.
1611fn schedule_requires_eta0<F: Float>(schedule: &LearningRateSchedule<F>) -> bool {
1612 matches!(
1613 schedule,
1614 LearningRateSchedule::Constant
1615 | LearningRateSchedule::InvScaling
1616 | LearningRateSchedule::Adaptive
1617 )
1618}
1619
1620/// Validate classifier input shapes and parameters.
1621/// Validate `validation_fraction` to the OPEN interval `(0, 1)`.
1622///
1623/// Mirrors sklearn `_parameter_constraints["validation_fraction"]`
1624/// (`_stochastic_gradient.py:525`, `Interval(Real, 0, 1, closed="neither")`):
1625/// the bounds are both EXCLUSIVE, so `0.0` and `1.0` are invalid. sklearn
1626/// validates this unconditionally (it is a constructor constraint), independent
1627/// of `early_stopping`.
1628fn validate_validation_fraction<F: Float>(validation_fraction: F) -> Result<(), FerroError> {
1629 if validation_fraction <= F::zero() || validation_fraction >= F::one() {
1630 return Err(FerroError::InvalidParameter {
1631 name: "validation_fraction".into(),
1632 reason: "must be in the open interval (0, 1)".into(),
1633 });
1634 }
1635 Ok(())
1636}
1637
1638#[allow(
1639 clippy::too_many_arguments,
1640 reason = "threads each validated parameter"
1641)]
1642fn validate_clf_params<F: Float>(
1643 x: &Array2<F>,
1644 y: &Array1<usize>,
1645 schedule: &LearningRateSchedule<F>,
1646 eta0: F,
1647 alpha: F,
1648 l1_ratio: F,
1649 validation_fraction: F,
1650) -> Result<(), FerroError> {
1651 let n_samples = x.nrows();
1652 if n_samples != y.len() {
1653 return Err(FerroError::ShapeMismatch {
1654 expected: vec![n_samples],
1655 actual: vec![y.len()],
1656 context: "y length must match number of samples in X".into(),
1657 });
1658 }
1659 if n_samples == 0 {
1660 return Err(FerroError::InsufficientSamples {
1661 required: 1,
1662 actual: 0,
1663 context: "SGDClassifier requires at least one sample".into(),
1664 });
1665 }
1666 if schedule_requires_eta0(schedule) && eta0 <= F::zero() {
1667 return Err(FerroError::InvalidParameter {
1668 name: "eta0".into(),
1669 reason: "must be positive".into(),
1670 });
1671 }
1672 if alpha < F::zero() {
1673 return Err(FerroError::InvalidParameter {
1674 name: "alpha".into(),
1675 reason: "must be non-negative".into(),
1676 });
1677 }
1678 if l1_ratio < F::zero() || l1_ratio > F::one() {
1679 return Err(FerroError::InvalidParameter {
1680 name: "l1_ratio".into(),
1681 reason: "must be in the range [0, 1]".into(),
1682 });
1683 }
1684 validate_validation_fraction(validation_fraction)?;
1685 Ok(())
1686}
1687
1688/// Result type for one-vs-all training: (weight_matrix, intercepts, step_counter).
1689type OvaResult<F> = (Vec<Array1<F>>, Vec<F>, usize);
1690
1691/// Number of validation samples for an early-stopping split: `ceil` of
1692/// `validation_fraction * n` clamped to `[1, n-1]` so that BOTH the train and
1693/// the validation subset are non-empty.
1694///
1695/// sklearn delegates the count to `ShuffleSplit`/`StratifiedShuffleSplit`, which
1696/// use `ceil(test_size * n)` and raise if either subset is empty
1697/// (`_stochastic_gradient.py:295-307`). The exact sample SELECTION is numpy-RNG
1698/// coupled and not cross-impl reproducible (the same barrier as `shuffle`); only
1699/// the count + non-emptiness are reproduced here.
1700fn validation_count<F: Float>(validation_fraction: F, n: usize) -> usize {
1701 let n_f = F::from(n).unwrap_or_else(F::zero);
1702 let raw = (validation_fraction * n_f).ceil();
1703 let n_val = raw.to_usize().unwrap_or(1).max(1);
1704 n_val.min(n.saturating_sub(1))
1705}
1706
1707/// Build a seeded, optionally stratified train/validation index partition for
1708/// early stopping.
1709///
1710/// Returns `(train_idx, val_idx)`. The first `n_val` entries of a seeded random
1711/// permutation form the validation set; for the classifier (`stratify = Some`)
1712/// the permutation is built per class so the validation set is proportional per
1713/// class, mirroring sklearn's `StratifiedShuffleSplit`
1714/// (`_stochastic_gradient.py:280-287`); for the regressor a plain `ShuffleSplit`
1715/// permutation is used. The RNG is `StdRng::seed_from_u64(random_state ?? 0)`.
1716///
1717/// The SELECTION is intentionally NOT identical to sklearn (numpy's
1718/// Mersenne-Twister permutation differs from `StdRng`); only the deterministic
1719/// contract — a valid, seeded, stratified-for-classifier, non-empty split — is
1720/// reproduced. Returns `None` if either subset would be empty (sklearn raises a
1721/// `ValueError`, `_stochastic_gradient.py:295-307`).
1722fn make_validation_split<F: Float>(
1723 n: usize,
1724 validation_fraction: F,
1725 random_state: Option<u64>,
1726 stratify: Option<&[usize]>,
1727) -> Option<(Vec<usize>, Vec<usize>)> {
1728 if n < 2 {
1729 return None;
1730 }
1731 let n_val = validation_count(validation_fraction, n);
1732 if n_val == 0 || n_val >= n {
1733 return None;
1734 }
1735 let mut rng = rand::rngs::StdRng::seed_from_u64(random_state.unwrap_or(0));
1736
1737 let mut val_mask = vec![false; n];
1738 match stratify {
1739 Some(labels) => {
1740 // Per-class proportional hold-out (`StratifiedShuffleSplit`). For each
1741 // class, shuffle its member indices and take `round(frac * count)`
1742 // (at least 1 when the class has >= 2 members) into validation.
1743 let mut classes: Vec<usize> = labels.to_vec();
1744 classes.sort_unstable();
1745 classes.dedup();
1746 for &c in &classes {
1747 let mut members: Vec<usize> = (0..n).filter(|&i| labels[i] == c).collect();
1748 members.shuffle(&mut rng);
1749 let count = members.len();
1750 let frac_f = F::from(count).unwrap_or_else(F::zero) * validation_fraction;
1751 let mut take = frac_f.round().to_usize().unwrap_or(0);
1752 if take == 0 && count >= 2 {
1753 take = 1;
1754 }
1755 take = take.min(count.saturating_sub(1)).min(count);
1756 for &idx in members.iter().take(take) {
1757 val_mask[idx] = true;
1758 }
1759 }
1760 }
1761 None => {
1762 // Plain shuffle hold-out (`ShuffleSplit`): first `n_val` of a seeded
1763 // permutation.
1764 let mut perm: Vec<usize> = (0..n).collect();
1765 perm.shuffle(&mut rng);
1766 for &idx in perm.iter().take(n_val) {
1767 val_mask[idx] = true;
1768 }
1769 }
1770 }
1771
1772 let val_idx: Vec<usize> = (0..n).filter(|&i| val_mask[i]).collect();
1773 let train_idx: Vec<usize> = (0..n).filter(|&i| !val_mask[i]).collect();
1774 if val_idx.is_empty() || train_idx.is_empty() {
1775 return None;
1776 }
1777 Some((train_idx, val_idx))
1778}
1779
1780/// Gather the rows of `x` indexed by `idx` into a fresh `Array2`.
1781fn gather_rows<F: Float>(x: &Array2<F>, idx: &[usize]) -> Array2<F> {
1782 let n_features = x.ncols();
1783 let mut out = Array2::<F>::zeros((idx.len(), n_features));
1784 for (r, &i) in idx.iter().enumerate() {
1785 let src = x.row(i);
1786 for j in 0..n_features {
1787 out[[r, j]] = src[j];
1788 }
1789 }
1790 out
1791}
1792
1793/// Gather the entries of `v` indexed by `idx` into a fresh `Array1`.
1794fn gather<F: Float>(v: &Array1<F>, idx: &[usize]) -> Array1<F> {
1795 Array1::from_iter(idx.iter().map(|&i| v[i]))
1796}
1797
1798/// Train one-vs-all binary classifiers, returning per-class weights, intercepts,
1799/// and the cumulative step counter.
1800///
1801/// `expanded_class_weight[k]` is the weight of `classes[k]` from
1802/// [`compute_class_weight`] (`_stochastic_gradient.py:624`). `sample_weight[i]`
1803/// is the user per-sample weight. For each binary subproblem the per-sample
1804/// weight passed to the kernel is `class_weight_for_sample(i) * sample_weight[i]`
1805/// where `class_weight_for_sample(i)` is `pos_weight` if sample `i` is the
1806/// positive class else `neg_weight`, with the sklearn OvA mapping:
1807/// binary (`_fit_binary`, `:765-766`) `pos = expanded[1]`, `neg = expanded[0]`;
1808/// multiclass class `k` (`_fit_multiclass`, `:816`) `pos = expanded[k]`,
1809/// `neg = 1.0`.
1810#[allow(clippy::too_many_arguments, reason = "threads class + sample weights")]
1811fn fit_ova<F: Float + Send + Sync + ScalarOperand + 'static>(
1812 x: &Array2<F>,
1813 y: &Array1<usize>,
1814 classes: &[usize],
1815 n_features: usize,
1816 loss_enum: &ClassifierLoss,
1817 hyper: &SGDHyper<F>,
1818 initial_t: usize,
1819 expanded_class_weight: &[F],
1820 sample_weight: &[F],
1821) -> Result<OvaResult<F>, FerroError> {
1822 let n_classes = classes.len();
1823 let mut weight_matrix: Vec<Array1<F>> = Vec::with_capacity(n_classes);
1824 let mut intercepts: Vec<F> = Vec::with_capacity(n_classes);
1825 let mut global_t = initial_t;
1826
1827 // Early-stopping validation split. Computed ONCE over the full multiclass
1828 // labels (so the hold-out is stratified per class and SHARED by every OvA
1829 // subproblem, exactly as sklearn precomputes the mask in `_fit_multiclass`,
1830 // `_stochastic_gradient.py:796`, and reuses it for each binary fit). The
1831 // split is stratified (`StratifiedShuffleSplit`, `:280-281`). When the split
1832 // is infeasible (too few samples) it returns `None`, and an empty validation
1833 // set raises (`:295-307`).
1834 let split = if hyper.early_stopping {
1835 match make_validation_split(
1836 x.nrows(),
1837 hyper.validation_fraction,
1838 hyper.random_state,
1839 Some(&y.to_vec()),
1840 ) {
1841 Some(s) => Some(s),
1842 None => {
1843 return Err(FerroError::InvalidParameter {
1844 name: "validation_fraction".into(),
1845 reason: "early_stopping split led to an empty train or validation set; \
1846 increase the number of samples or change validation_fraction"
1847 .into(),
1848 });
1849 }
1850 }
1851 } else {
1852 None
1853 };
1854
1855 // Closure: run one OvA binary subproblem (relabel + per-sample weights +
1856 // optional validation slice + kernel call). `pos`/`neg` are the class-weight
1857 // mappings for this subproblem.
1858 let run_subproblem =
1859 |cls: usize, pos_weight: F, neg_weight: F, w: &mut Array1<F>, b: &mut F, t0: usize| {
1860 let y_binary: Array1<F> =
1861 y.mapv(|label| if label == cls { F::one() } else { -F::one() });
1862 // Per-sample weight = class_weight_for_sample(i) * sample_weight[i]
1863 // (`_sgd_fast.pyx.tp:599-602,630`). `y_binary[i] > 0` selects pos.
1864 let sample_w_full: Vec<F> = (0..x.nrows())
1865 .map(|i| {
1866 let cw = if y_binary[i] > F::zero() {
1867 pos_weight
1868 } else {
1869 neg_weight
1870 };
1871 cw * sample_weight[i]
1872 })
1873 .collect();
1874
1875 if let Some((train_idx, val_idx)) = &split {
1876 // Train on the train subset only; score on the held-out
1877 // validation subset (relabeled `{-1,+1}` target, accuracy —
1878 // `_stochastic_gradient.py:451-454,79`). sklearn does NOT refit
1879 // on the full data; the train-subset weights are final
1880 // (`_ValidationScoreCallback` only reads weights, never writes
1881 // back to the live fit).
1882 let x_tr = gather_rows(x, train_idx);
1883 let y_tr = gather(&y_binary, train_idx);
1884 let sw_tr: Vec<F> = train_idx.iter().map(|&i| sample_w_full[i]).collect();
1885 let x_val = gather_rows(x, val_idx);
1886 let y_val = gather(&y_binary, val_idx);
1887 dispatch_train_binary(
1888 &x_tr,
1889 &y_tr,
1890 w,
1891 b,
1892 loss_enum,
1893 hyper,
1894 t0,
1895 &sw_tr,
1896 Some((&x_val, &y_val)),
1897 )
1898 } else {
1899 dispatch_train_binary(
1900 x,
1901 &y_binary,
1902 w,
1903 b,
1904 loss_enum,
1905 hyper,
1906 t0,
1907 &sample_w_full,
1908 None,
1909 )
1910 }
1911 };
1912
1913 if n_classes == 2 {
1914 // Single binary problem: class[0] -> -1, class[1] -> +1.
1915 // OvA weight mapping (`_fit_binary`, `_stochastic_gradient.py:765-766`):
1916 // pos_weight = expanded[1], neg_weight = expanded[0].
1917 let pos_weight = expanded_class_weight[1];
1918 let neg_weight = expanded_class_weight[0];
1919 let mut w = Array1::<F>::zeros(n_features);
1920 let mut b = F::zero();
1921 let (_, t) = run_subproblem(classes[1], pos_weight, neg_weight, &mut w, &mut b, global_t);
1922 global_t = t;
1923 weight_matrix.push(w);
1924 intercepts.push(b);
1925 } else {
1926 // One-vs-all: one binary problem per class. Multiclass mapping
1927 // (`_fit_multiclass`, `_stochastic_gradient.py:816`): for class k,
1928 // pos_weight = expanded[k], neg_weight = 1.0.
1929 for (k, &cls) in classes.iter().enumerate() {
1930 let pos_weight = expanded_class_weight[k];
1931 let neg_weight = F::one();
1932 let mut w = Array1::<F>::zeros(n_features);
1933 let mut b = F::zero();
1934 let (_, t) = run_subproblem(cls, pos_weight, neg_weight, &mut w, &mut b, global_t);
1935 global_t = t;
1936 weight_matrix.push(w);
1937 intercepts.push(b);
1938 }
1939 }
1940
1941 Ok((weight_matrix, intercepts, global_t))
1942}
1943
1944/// Train one-vs-all using existing weight vectors (for partial_fit).
1945#[allow(clippy::too_many_arguments)]
1946fn partial_fit_ova<F: Float + Send + Sync + ScalarOperand + 'static>(
1947 x: &Array2<F>,
1948 y: &Array1<usize>,
1949 classes: &[usize],
1950 weight_matrix: &mut [Array1<F>],
1951 intercepts: &mut [F],
1952 loss_enum: &ClassifierLoss,
1953 hyper: &SGDHyper<F>,
1954 initial_t: usize,
1955) -> usize {
1956 let n_classes = classes.len();
1957 let mut global_t = initial_t;
1958 // `partial_fit` does not (yet) carry `class_weight`/`sample_weight`, so the
1959 // per-sample weight is uniform `1.0` — the all-ones path is byte-identical to
1960 // the pre-weighting kernel (`update *= 1*1`, `_sgd_fast.pyx.tp:630`).
1961 let sample_w: Vec<F> = vec![F::one(); x.nrows()];
1962
1963 if n_classes == 2 {
1964 let y_binary: Array1<F> = y.mapv(|label| {
1965 if label == classes[1] {
1966 F::one()
1967 } else {
1968 -F::one()
1969 }
1970 });
1971 let (_, t) = dispatch_train_binary(
1972 x,
1973 &y_binary,
1974 &mut weight_matrix[0],
1975 &mut intercepts[0],
1976 loss_enum,
1977 hyper,
1978 global_t,
1979 &sample_w,
1980 None,
1981 );
1982 global_t = t;
1983 } else {
1984 for (idx, &cls) in classes.iter().enumerate() {
1985 let y_binary: Array1<F> =
1986 y.mapv(|label| if label == cls { F::one() } else { -F::one() });
1987 let (_, t) = dispatch_train_binary(
1988 x,
1989 &y_binary,
1990 &mut weight_matrix[idx],
1991 &mut intercepts[idx],
1992 loss_enum,
1993 hyper,
1994 global_t,
1995 &sample_w,
1996 None,
1997 );
1998 global_t = t;
1999 }
2000 }
2001
2002 global_t
2003}
2004
2005/// Dispatch to the appropriate typed loss training function.
2006///
2007/// `sample_w[i] = class_weight_i * sample_weight_i` is the per-sample weight
2008/// (`_sgd_fast.pyx.tp:599-602,630`), forwarded verbatim to the kernel.
2009#[allow(
2010 clippy::too_many_arguments,
2011 reason = "threads the per-sample weight vector"
2012)]
2013fn dispatch_train_binary<F: Float + Send + Sync + ScalarOperand + 'static>(
2014 x: &Array2<F>,
2015 y_binary: &Array1<F>,
2016 w: &mut Array1<F>,
2017 b: &mut F,
2018 loss_enum: &ClassifierLoss,
2019 hyper: &SGDHyper<F>,
2020 initial_t: usize,
2021 sample_w: &[F],
2022 val_set: Option<(&Array2<F>, &Array1<F>)>,
2023) -> (F, usize) {
2024 match loss_enum {
2025 ClassifierLoss::Hinge => train_binary_sgd(
2026 x, y_binary, w, b, &Hinge, hyper, initial_t, sample_w, val_set,
2027 ),
2028 ClassifierLoss::SquaredHinge => train_binary_sgd(
2029 x,
2030 y_binary,
2031 w,
2032 b,
2033 &SquaredHinge,
2034 hyper,
2035 initial_t,
2036 sample_w,
2037 val_set,
2038 ),
2039 ClassifierLoss::Perceptron => train_binary_sgd(
2040 x,
2041 y_binary,
2042 w,
2043 b,
2044 &Perceptron,
2045 hyper,
2046 initial_t,
2047 sample_w,
2048 val_set,
2049 ),
2050 ClassifierLoss::Log => train_binary_sgd(
2051 x, y_binary, w, b, &LogLoss, hyper, initial_t, sample_w, val_set,
2052 ),
2053 ClassifierLoss::SquaredError => train_binary_sgd(
2054 x,
2055 y_binary,
2056 w,
2057 b,
2058 &SquaredError,
2059 hyper,
2060 initial_t,
2061 sample_w,
2062 val_set,
2063 ),
2064 ClassifierLoss::ModifiedHuber => train_binary_sgd(
2065 x,
2066 y_binary,
2067 w,
2068 b,
2069 &ModifiedHuber,
2070 hyper,
2071 initial_t,
2072 sample_w,
2073 val_set,
2074 ),
2075 }
2076}
2077
2078impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
2079 for FittedSGDClassifier<F>
2080{
2081 type Output = Array1<usize>;
2082 type Error = FerroError;
2083
2084 /// Predict class labels for the given feature matrix.
2085 ///
2086 /// For binary classification, uses `sign(w^T x + b)`.
2087 /// For multiclass, returns the class whose one-vs-all score is highest.
2088 ///
2089 /// # Errors
2090 ///
2091 /// Returns [`FerroError::ShapeMismatch`] if the number of features
2092 /// does not match the fitted model.
2093 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
2094 let n_features = x.ncols();
2095 if n_features != self.n_features {
2096 return Err(FerroError::ShapeMismatch {
2097 expected: vec![self.n_features],
2098 actual: vec![n_features],
2099 context: "number of features must match fitted model".into(),
2100 });
2101 }
2102
2103 let n_samples = x.nrows();
2104 let mut predictions = Array1::<usize>::zeros(n_samples);
2105
2106 if self.classes.len() == 2 {
2107 // Binary: single weight vector.
2108 let scores = x.dot(&self.weight_matrix[0]) + self.intercepts[0];
2109 for i in 0..n_samples {
2110 predictions[i] = if scores[i] >= F::zero() {
2111 self.classes[1]
2112 } else {
2113 self.classes[0]
2114 };
2115 }
2116 } else {
2117 // Multiclass: one-vs-all, pick highest score.
2118 for i in 0..n_samples {
2119 let xi = x.row(i);
2120 let mut best_class = 0;
2121 let mut best_score = F::neg_infinity();
2122 for (c, w) in self.weight_matrix.iter().enumerate() {
2123 let score = xi.dot(w) + self.intercepts[c];
2124 if score > best_score {
2125 best_score = score;
2126 best_class = c;
2127 }
2128 }
2129 predictions[i] = self.classes[best_class];
2130 }
2131 }
2132
2133 Ok(predictions)
2134 }
2135}
2136
2137impl<F: Float + Send + Sync + ScalarOperand + 'static> PartialFit<Array2<F>, Array1<usize>>
2138 for FittedSGDClassifier<F>
2139{
2140 type FitResult = FittedSGDClassifier<F>;
2141 type Error = FerroError;
2142
2143 /// Incrementally train the classifier on a new batch of data.
2144 ///
2145 /// # Errors
2146 ///
2147 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have mismatched
2148 /// sizes or `x` has the wrong number of features.
2149 fn partial_fit(
2150 mut self,
2151 x: &Array2<F>,
2152 y: &Array1<usize>,
2153 ) -> Result<FittedSGDClassifier<F>, FerroError> {
2154 let n_samples = x.nrows();
2155 if n_samples != y.len() {
2156 return Err(FerroError::ShapeMismatch {
2157 expected: vec![n_samples],
2158 actual: vec![y.len()],
2159 context: "y length must match number of samples in X".into(),
2160 });
2161 }
2162 if x.ncols() != self.n_features {
2163 return Err(FerroError::ShapeMismatch {
2164 expected: vec![self.n_features],
2165 actual: vec![x.ncols()],
2166 context: "number of features must match fitted model".into(),
2167 });
2168 }
2169
2170 // sklearn `SGDClassifier.partial_fit` validates X through
2171 // `_validate_data(force_all_finite=True)` (`_stochastic_gradient.py:596`),
2172 // raising `ValueError("Input X contains NaN.")` for any non-finite X
2173 // BEFORE the kernel. `y` is integer labels (always finite by type).
2174 // Mirrors the #2263 `fit_with_sample_weight` guard.
2175 if x.iter().any(|v| !v.is_finite()) {
2176 return Err(FerroError::InvalidParameter {
2177 name: "X".into(),
2178 reason: "Input X contains NaN or infinity.".into(),
2179 });
2180 }
2181
2182 // Use a single-epoch hyper for partial_fit.
2183 let mut hyper = self.hyper.clone();
2184 hyper.max_iter = 1;
2185
2186 let t = partial_fit_ova(
2187 x,
2188 y,
2189 &self.classes,
2190 &mut self.weight_matrix,
2191 &mut self.intercepts,
2192 &self.loss,
2193 &hyper,
2194 self.t,
2195 );
2196 self.t = t;
2197
2198 Ok(self)
2199 }
2200}
2201
2202impl<F: Float + Send + Sync + ScalarOperand + 'static> PartialFit<Array2<F>, Array1<usize>>
2203 for SGDClassifier<F>
2204{
2205 type FitResult = FittedSGDClassifier<F>;
2206 type Error = FerroError;
2207
2208 /// Initial call to `partial_fit` on an unfitted classifier.
2209 ///
2210 /// Equivalent to `fit` but with a single epoch, enabling subsequent
2211 /// incremental calls.
2212 ///
2213 /// # Errors
2214 ///
2215 /// Same as [`Fit::fit`].
2216 fn partial_fit(
2217 self,
2218 x: &Array2<F>,
2219 y: &Array1<usize>,
2220 ) -> Result<FittedSGDClassifier<F>, FerroError> {
2221 validate_clf_params(
2222 x,
2223 y,
2224 &self.learning_rate,
2225 self.eta0,
2226 self.alpha,
2227 self.l1_ratio,
2228 self.validation_fraction,
2229 )?;
2230
2231 // sklearn `SGDClassifier.partial_fit` validates X through
2232 // `_validate_data(force_all_finite=True)` (`_stochastic_gradient.py:596`),
2233 // raising `ValueError("Input X contains NaN.")` for any non-finite X
2234 // BEFORE the kernel (`validate_clf_params` checks shape/params, not
2235 // finiteness). `y` is integer labels (always finite by type).
2236 if x.iter().any(|v| !v.is_finite()) {
2237 return Err(FerroError::InvalidParameter {
2238 name: "X".into(),
2239 reason: "Input X contains NaN or infinity.".into(),
2240 });
2241 }
2242
2243 let n_features = x.ncols();
2244 let mut classes: Vec<usize> = y.to_vec();
2245 classes.sort_unstable();
2246 classes.dedup();
2247
2248 if classes.len() < 2 {
2249 return Err(FerroError::InsufficientSamples {
2250 required: 2,
2251 actual: classes.len(),
2252 context: "SGDClassifier requires at least 2 distinct classes".into(),
2253 });
2254 }
2255
2256 let mut hyper = clf_hyper(&self);
2257 hyper.max_iter = 1;
2258 let loss_enum = self.loss;
2259
2260 // Initial `partial_fit` does not carry per-sample/class weights here, so
2261 // the expanded class weights and sample weights are uniform `1.0` — the
2262 // all-ones path is byte-identical to the pre-weighting kernel.
2263 let expanded: Vec<F> = vec![F::one(); classes.len()];
2264 let sw: Vec<F> = vec![F::one(); x.nrows()];
2265
2266 let (weight_matrix, intercepts, t) = fit_ova(
2267 x, y, &classes, n_features, &loss_enum, &hyper, 0, &expanded, &sw,
2268 )?;
2269
2270 Ok(FittedSGDClassifier {
2271 weight_matrix,
2272 intercepts,
2273 classes,
2274 n_features,
2275 loss: loss_enum,
2276 hyper: clf_hyper(&self),
2277 t,
2278 })
2279 }
2280}
2281
2282impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
2283 for FittedSGDClassifier<F>
2284{
2285 /// Returns the coefficient vector for the first (or only) binary classifier.
2286 fn coefficients(&self) -> &Array1<F> {
2287 &self.weight_matrix[0]
2288 }
2289
2290 /// Returns the intercept for the first (or only) binary classifier.
2291 fn intercept(&self) -> F {
2292 self.intercepts[0]
2293 }
2294}
2295
2296// Pipeline integration.
2297impl<F> PipelineEstimator<F> for SGDClassifier<F>
2298where
2299 F: Float + ToPrimitive + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2300{
2301 fn fit_pipeline(
2302 &self,
2303 x: &Array2<F>,
2304 y: &Array1<F>,
2305 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
2306 let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
2307 let fitted = self.fit(x, &y_usize)?;
2308 Ok(Box::new(FittedSGDClassifierPipeline(fitted)))
2309 }
2310}
2311
2312/// Wrapper for pipeline integration that converts predictions to float.
2313struct FittedSGDClassifierPipeline<F>(FittedSGDClassifier<F>)
2314where
2315 F: Float + Send + Sync + 'static;
2316
2317// Safety: inner type fields are Send + Sync.
2318unsafe impl<F> Send for FittedSGDClassifierPipeline<F> where F: Float + Send + Sync + 'static {}
2319unsafe impl<F> Sync for FittedSGDClassifierPipeline<F> where F: Float + Send + Sync + 'static {}
2320
2321impl<F> FittedPipelineEstimator<F> for FittedSGDClassifierPipeline<F>
2322where
2323 F: Float + ToPrimitive + FromPrimitive + ScalarOperand + Send + Sync + 'static,
2324{
2325 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
2326 let preds = self.0.predict(x)?;
2327 Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
2328 }
2329}
2330
2331// ---------------------------------------------------------------------------
2332// SGDRegressor
2333// ---------------------------------------------------------------------------
2334
2335/// Stochastic Gradient Descent regressor.
2336///
2337/// Supports several loss functions for regression, trained using stochastic
2338/// gradient descent with configurable learning rate schedules.
2339///
2340/// # Type Parameters
2341///
2342/// - `F`: The floating-point type (`f32` or `f64`).
2343///
2344/// # Examples
2345///
2346/// ```
2347/// use ferrolearn_linear::sgd::SGDRegressor;
2348/// use ferrolearn_core::{Fit, Predict};
2349/// use ndarray::{array, Array2};
2350///
2351/// let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2352/// let y = array![2.0, 4.0, 6.0, 8.0];
2353///
2354/// let model = SGDRegressor::<f64>::new();
2355/// let fitted = model.fit(&x, &y).unwrap();
2356/// let preds = fitted.predict(&x).unwrap();
2357/// ```
2358#[derive(Debug, Clone)]
2359pub struct SGDRegressor<F> {
2360 /// The loss function to use.
2361 pub loss: RegressorLoss<F>,
2362 /// The learning rate schedule.
2363 pub learning_rate: LearningRateSchedule<F>,
2364 /// Initial learning rate.
2365 pub eta0: F,
2366 /// Regularization strength (`alpha`).
2367 pub alpha: F,
2368 /// Regularization penalty (`l2`/`l1`/`elasticnet`). Defaults to `L2`.
2369 pub penalty: Penalty,
2370 /// Elastic-net mixing parameter; only used when `penalty == ElasticNet`.
2371 /// Defaults to `0.15` (sklearn default).
2372 pub l1_ratio: F,
2373 /// Maximum number of passes over the training data.
2374 pub max_iter: usize,
2375 /// Convergence tolerance.
2376 pub tol: F,
2377 /// Optional random seed for sample shuffling.
2378 pub random_state: Option<u64>,
2379 /// Power parameter for inverse scaling schedule.
2380 pub power_t: F,
2381 /// Whether to shuffle the training data after each epoch. Defaults to
2382 /// `true` (sklearn `SGDRegressor(shuffle=True)`,
2383 /// `_stochastic_gradient.py:2038`).
2384 pub shuffle: bool,
2385 /// Number of consecutive epochs with no loss improvement (beyond `tol`)
2386 /// before convergence triggers, or — under the `adaptive` schedule — before
2387 /// `eta` is divided by 5. Defaults to `5` (sklearn
2388 /// `_stochastic_gradient.py` `n_iter_no_change=5`, `_sgd_fast.pyx.tp:698`).
2389 pub n_iter_no_change: usize,
2390 /// Whether to fit (update) the intercept. Defaults to `true` (sklearn
2391 /// `SGDRegressor(fit_intercept=True)`, `_stochastic_gradient.py:2031`,
2392 /// constraint `["boolean"]` at `:86`). When `false` the intercept is never
2393 /// updated and stays at its init value `0` (`_sgd_fast.pyx.tp:639-644`: the
2394 /// intercept update is gated on `if fit_intercept == 1`).
2395 pub fit_intercept: bool,
2396 /// Averaged-SGD (ASGD) threshold. `0` disables averaging (the default,
2397 /// matching sklearn `average=False`); `1` averages from the first step
2398 /// (sklearn `average=True`); `N > 1` begins averaging once the global step
2399 /// counter `t >= N` (sklearn `average=N`). The averaged weights/intercept
2400 /// replace the plain ones at fit-end when `average <= self.t_ - 1`
2401 /// (`_sgd_fast.pyx.tp:646-654`, `_stochastic_gradient.py:834-836`).
2402 pub average: usize,
2403 /// Whether to stop training early based on a held-out validation score.
2404 /// Defaults to `false` (sklearn `SGDRegressor(early_stopping=False)`,
2405 /// `_stochastic_gradient.py:114`, constraint `["boolean"]` at `:524`). When
2406 /// `true`, [`validation_fraction`](Self::validation_fraction) of the training
2407 /// data is held out as a validation set and the epoch-end convergence rule
2408 /// uses the validation `R^2` instead of the training loss
2409 /// (`_sgd_fast.pyx.tp:678-687`).
2410 pub early_stopping: bool,
2411 /// Fraction of the training data held out as the validation set when
2412 /// [`early_stopping`](Self::early_stopping) is `true`. Defaults to `0.1`
2413 /// (sklearn `validation_fraction=0.1`, `_stochastic_gradient.py:115`). Must
2414 /// lie in the open interval `(0, 1)`
2415 /// (constraint `Interval(Real, 0, 1, closed="neither")` at `:525`).
2416 pub validation_fraction: F,
2417}
2418
2419impl<F: Float> SGDRegressor<F> {
2420 /// Create a new `SGDRegressor` with default settings.
2421 ///
2422 /// Defaults match scikit-learn's `SGDRegressor.__init__`
2423 /// (`_stochastic_gradient.py:2042-2068`): `loss = SquaredError`,
2424 /// `learning_rate = InvScaling`, `eta0 = 0.01`, `alpha = 0.0001`,
2425 /// `penalty = L2`, `l1_ratio = 0.15`, `max_iter = 1000`, `tol = 1e-3`,
2426 /// `power_t = 0.25`.
2427 #[must_use]
2428 pub fn new() -> Self {
2429 Self {
2430 loss: RegressorLoss::SquaredError,
2431 n_iter_no_change: 5,
2432 average: 0,
2433 fit_intercept: true,
2434 shuffle: true,
2435 penalty: Penalty::L2,
2436 l1_ratio: cst(0.15),
2437 learning_rate: LearningRateSchedule::InvScaling,
2438 eta0: cst(0.01),
2439 alpha: cst(0.0001),
2440 max_iter: 1000,
2441 tol: cst(1e-3),
2442 random_state: None,
2443 power_t: cst(0.25),
2444 early_stopping: false,
2445 validation_fraction: cst(0.1),
2446 }
2447 }
2448
2449 /// Set the loss function.
2450 #[must_use]
2451 pub fn with_loss(mut self, loss: RegressorLoss<F>) -> Self {
2452 self.loss = loss;
2453 self
2454 }
2455
2456 /// Set the learning rate schedule.
2457 #[must_use]
2458 pub fn with_learning_rate(mut self, lr: LearningRateSchedule<F>) -> Self {
2459 self.learning_rate = lr;
2460 self
2461 }
2462
2463 /// Set the initial learning rate.
2464 #[must_use]
2465 pub fn with_eta0(mut self, eta0: F) -> Self {
2466 self.eta0 = eta0;
2467 self
2468 }
2469
2470 /// Set the regularization strength.
2471 #[must_use]
2472 pub fn with_alpha(mut self, alpha: F) -> Self {
2473 self.alpha = alpha;
2474 self
2475 }
2476
2477 /// Set the regularization penalty (`l2`/`l1`/`elasticnet`).
2478 #[must_use]
2479 pub fn with_penalty(mut self, penalty: Penalty) -> Self {
2480 self.penalty = penalty;
2481 self
2482 }
2483
2484 /// Set the elastic-net mixing parameter (`l1_ratio`, used only when
2485 /// `penalty == ElasticNet`).
2486 #[must_use]
2487 pub fn with_l1_ratio(mut self, l1_ratio: F) -> Self {
2488 self.l1_ratio = l1_ratio;
2489 self
2490 }
2491
2492 /// Set the maximum number of epochs.
2493 #[must_use]
2494 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
2495 self.max_iter = max_iter;
2496 self
2497 }
2498
2499 /// Set the convergence tolerance.
2500 #[must_use]
2501 pub fn with_tol(mut self, tol: F) -> Self {
2502 self.tol = tol;
2503 self
2504 }
2505
2506 /// Set the random seed for reproducibility.
2507 #[must_use]
2508 pub fn with_random_state(mut self, seed: u64) -> Self {
2509 self.random_state = Some(seed);
2510 self
2511 }
2512
2513 /// Set the power parameter for inverse scaling.
2514 #[must_use]
2515 pub fn with_power_t(mut self, power_t: F) -> Self {
2516 self.power_t = power_t;
2517 self
2518 }
2519
2520 /// Set whether the training data is shuffled after each epoch.
2521 ///
2522 /// Mirrors sklearn's `shuffle` parameter (default `True`,
2523 /// `_stochastic_gradient.py:2038`). With `false` the samples are visited in
2524 /// index order `0..n-1` every epoch (`_sgd_fast.pyx.tp:579-581`), making the
2525 /// fit fully deterministic and cross-impl comparable to sklearn.
2526 #[must_use]
2527 pub fn with_shuffle(mut self, shuffle: bool) -> Self {
2528 self.shuffle = shuffle;
2529 self
2530 }
2531
2532 /// Set the number of consecutive non-improving epochs before convergence
2533 /// (or, under the `adaptive` schedule, before `eta` is divided by 5).
2534 ///
2535 /// Mirrors sklearn's `n_iter_no_change` parameter (default `5`,
2536 /// `_stochastic_gradient.py`); the epoch-end stop rule at
2537 /// `_sgd_fast.pyx.tp:698` triggers once `no_improvement_count` reaches it.
2538 #[must_use]
2539 pub fn with_n_iter_no_change(mut self, n_iter_no_change: usize) -> Self {
2540 self.n_iter_no_change = n_iter_no_change;
2541 self
2542 }
2543
2544 /// Set whether the intercept (bias) term is fit.
2545 ///
2546 /// Mirrors sklearn's `fit_intercept` parameter (default `True`,
2547 /// `_stochastic_gradient.py:2031`, constraint `["boolean"]` at `:86`). With
2548 /// `false` the intercept update is skipped every step
2549 /// (`_sgd_fast.pyx.tp:639-644`: `if fit_intercept == 1: ... intercept += ...`)
2550 /// and the intercept stays at its init value `0`.
2551 #[must_use]
2552 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
2553 self.fit_intercept = fit_intercept;
2554 self
2555 }
2556
2557 /// Set the averaged-SGD (ASGD) threshold.
2558 ///
2559 /// Mirrors sklearn's `average` parameter (default `False`,
2560 /// `_stochastic_gradient.py:2068`). `0` disables averaging (the plain SGD
2561 /// trajectory, byte-identical to the unaveraged kernel); `with_average(1)`
2562 /// is sklearn `average=True` (average from the first step); `with_average(N)`
2563 /// is sklearn `average=N` (begin averaging once the global step counter
2564 /// `t >= N`). The running mean of the post-update weights/intercept replaces
2565 /// the plain `coef_`/`intercept_` at fit-end when `average <= self.t_ - 1`
2566 /// (`_sgd_fast.pyx.tp:646-654`, `_stochastic_gradient.py:834-836`).
2567 #[must_use]
2568 pub fn with_average(mut self, average: usize) -> Self {
2569 self.average = average;
2570 self
2571 }
2572
2573 /// Enable or disable early stopping on a held-out validation score.
2574 ///
2575 /// Mirrors sklearn's `early_stopping` parameter (default `False`,
2576 /// `_stochastic_gradient.py:114`, constraint `["boolean"]` at `:524`). When
2577 /// enabled, [`with_validation_fraction`](Self::with_validation_fraction) of
2578 /// the data is held out and the epoch-end convergence is driven by the
2579 /// validation `R^2` rather than the training loss
2580 /// (`_sgd_fast.pyx.tp:678-687`).
2581 #[must_use]
2582 pub fn with_early_stopping(mut self, early_stopping: bool) -> Self {
2583 self.early_stopping = early_stopping;
2584 self
2585 }
2586
2587 /// Set the fraction of the training data held out for early-stopping
2588 /// validation.
2589 ///
2590 /// Mirrors sklearn's `validation_fraction` parameter (default `0.1`,
2591 /// `_stochastic_gradient.py:115`, constraint
2592 /// `Interval(Real, 0, 1, closed="neither")` at `:525`). Only used when
2593 /// [`early_stopping`](Self::early_stopping) is `true`; validated to the open
2594 /// interval `(0, 1)` at fit time.
2595 #[must_use]
2596 pub fn with_validation_fraction(mut self, validation_fraction: F) -> Self {
2597 self.validation_fraction = validation_fraction;
2598 self
2599 }
2600}
2601
2602impl<F: Float> Default for SGDRegressor<F> {
2603 fn default() -> Self {
2604 Self::new()
2605 }
2606}
2607
2608/// Extract hyperparameter bundle from an `SGDRegressor`.
2609fn reg_hyper<F: Float>(reg: &SGDRegressor<F>) -> SGDHyper<F> {
2610 SGDHyper {
2611 learning_rate: reg.learning_rate,
2612 eta0: reg.eta0,
2613 alpha: reg.alpha,
2614 max_iter: reg.max_iter,
2615 tol: reg.tol,
2616 random_state: reg.random_state,
2617 power_t: reg.power_t,
2618 penalty: reg.penalty,
2619 l1_ratio: reg.l1_ratio,
2620 shuffle: reg.shuffle,
2621 n_iter_no_change: reg.n_iter_no_change,
2622 fit_intercept: reg.fit_intercept,
2623 one_class: false,
2624 average: reg.average,
2625 early_stopping: reg.early_stopping,
2626 validation_fraction: reg.validation_fraction,
2627 }
2628}
2629
2630/// Train a single regressor via SGD, updating `weights` and `intercept`
2631/// in place. Returns the final loss and step counter.
2632///
2633/// `sample_w[i]` is the per-sample weight `sample_weight[i]`, scaling ONLY the
2634/// gradient-derived part of each update (`update *= class_weight * sample_weight`
2635/// with `class_weight = 1` for regression, `_sgd_fast.pyx.tp:599-602,630`); the
2636/// L2 shrink and L1 truncation are unscaled. An all-ones `sample_w` (the default
2637/// `fit` path) reproduces the byte-identical unweighted behaviour.
2638#[allow(
2639 clippy::too_many_arguments,
2640 reason = "threads the per-sample weight vector"
2641)]
2642fn train_regressor_sgd<F, L>(
2643 x: &Array2<F>,
2644 y: &Array1<F>,
2645 weights: &mut Array1<F>,
2646 intercept: &mut F,
2647 loss_fn: &L,
2648 hyper: &SGDHyper<F>,
2649 initial_t: usize,
2650 sample_w: &[F],
2651 val_set: Option<(&Array2<F>, &Array1<F>)>,
2652) -> (F, usize)
2653where
2654 F: Float + ScalarOperand + Send + Sync + 'static,
2655 L: Loss<F>,
2656{
2657 let n_samples = x.nrows();
2658 let n_features = x.ncols();
2659 let mut t = initial_t;
2660 // Epoch-end convergence state, mirroring `_sgd_fast.pyx.tp:525,532-534`:
2661 // `best_loss = INFINITY`, `no_improvement_count = 0`. `current_eta` carries
2662 // the adaptive-schedule eta (`eta = eta / 5` decay, `:700`). Under early
2663 // stopping `best_score = -INFINITY` (higher score is better, `:533`).
2664 let mut best_loss = F::infinity();
2665 let mut best_score = F::neg_infinity();
2666 // Early stopping uses the validation-R^2 branch only when a validation set
2667 // was split off in the `Fit` path (`_stochastic_gradient.py:79`,
2668 // `RegressorMixin.score` -> `r2_score`).
2669 let early_stopping = hyper.early_stopping && val_set.is_some();
2670 let mut current_eta = hyper.eta0;
2671 let mut no_improve_count: usize = 0;
2672 // `tol = None` upstream becomes `-INFINITY`, disabling the stop rule
2673 // (`tol > -INFINITY` is false, `:690`). ferrolearn encodes that as a
2674 // finite/-inf `tol`; the criterion is active iff `tol > -inf`.
2675 let tol_active = hyper.tol > F::neg_infinity();
2676 let max_dloss = max_dloss::<F>();
2677 let mut indices: Vec<usize> = (0..n_samples).collect();
2678 // `optimal_init` (the `optimal` schedule's t0 offset) depends on the loss
2679 // and alpha, so it is computed once per fit, before the epoch loop
2680 // (`_sgd_fast.pyx.tp:565-570`).
2681 let opt_init = optimal_init(loss_fn, hyper.alpha);
2682
2683 // Effective l1_ratio from the penalty (`_sgd_fast.pyx.tp:558-561`):
2684 // `L2 -> 0.0`, `L1 -> 1.0`, `ElasticNet -> user l1_ratio`.
2685 let eff = effective_l1_ratio(hyper.penalty, hyper.l1_ratio);
2686 let apply_l1 = matches!(hyper.penalty, Penalty::L1 | Penalty::ElasticNet);
2687 // Tsuruoka cumulative-penalty state (`u` scalar, `q` per-feature), allocated
2688 // once and persisting for the whole fit (`_sgd_fast.pyx.tp:551-556`).
2689 let mut u = F::zero();
2690 let mut q: Array1<F> = Array1::zeros(n_features);
2691
2692 // Averaged-SGD (ASGD) accumulators (`_sgd_fast.pyx.tp:646-654`). Direct
2693 // running-mean form of sklearn's lazy `w.add_average` (mathematically
2694 // identical for plain arrays): once `t >= average`, `avg += (current - avg)
2695 // / (t - average + 1)`. A passive observer — never fed back into the live
2696 // `weights`/`intercept` trajectory.
2697 let mut average_coef: Array1<F> = Array1::zeros(n_features);
2698 let mut average_intercept = F::zero();
2699
2700 let mut rng = match hyper.random_state {
2701 Some(seed) => rand::rngs::StdRng::seed_from_u64(seed),
2702 None => rand::rngs::StdRng::from_os_rng(),
2703 };
2704
2705 let mut total_loss = F::zero();
2706
2707 for _epoch in 0..hyper.max_iter {
2708 // sklearn shuffles the sample order each epoch only when `shuffle` is
2709 // set (`_sgd_fast.pyx.tp:579-580`: `if shuffle: dataset.shuffle(seed)`).
2710 // With `shuffle == false` the indices stay `0..n-1` every epoch, exactly
2711 // matching sklearn's no-shuffle index order (`:581 for i in range(n)`).
2712 if hyper.shuffle {
2713 indices.shuffle(&mut rng);
2714 }
2715 let mut epoch_loss = F::zero();
2716
2717 for &i in &indices {
2718 t += 1;
2719
2720 let eta = match hyper.learning_rate {
2721 LearningRateSchedule::Adaptive => current_eta,
2722 _ => compute_lr(
2723 &hyper.learning_rate,
2724 hyper.eta0,
2725 hyper.alpha,
2726 hyper.power_t,
2727 opt_init,
2728 t,
2729 ),
2730 };
2731
2732 let xi = x.row(i);
2733 let mut y_pred = *intercept;
2734 for j in 0..n_features {
2735 y_pred = y_pred + weights[j] * xi[j];
2736 }
2737
2738 // Clip the gradient to `[-MAX_DLOSS, MAX_DLOSS]` before forming the
2739 // update, matching `_sgd_fast.pyx.tp:613-620`.
2740 let grad = loss_fn
2741 .gradient(y[i], y_pred)
2742 .max(-max_dloss)
2743 .min(max_dloss);
2744 // Per-sample weight scaling: `update *= class_weight * sample_weight`
2745 // with `class_weight = 1` for regression (`_sgd_fast.pyx.tp:630`).
2746 // `g` is the sample-weighted gradient, scaling ONLY the gradient term.
2747 let g = grad * sample_w[i];
2748 // `sumloss` is the SUM (not mean) of per-sample losses over the
2749 // epoch (`_sgd_fast.pyx.tp:597`), computed from the UNWEIGHTED loss.
2750 epoch_loss = epoch_loss + loss_fn.loss(y[i], y_pred);
2751
2752 // L2 shrink: clamped multiplicative factor
2753 // `max(0, 1 - (1-eff)*eta*alpha)` applied to the whole weight vector
2754 // BEFORE the gradient add (`_sgd_fast.pyx.tp:632-635`); for pure L2
2755 // (`eff=0`) this is `max(0, 1-eta*alpha)`, for L1 (`eff=1`) it is 1.
2756 let shrink = (F::one() - (F::one() - eff) * eta * hyper.alpha).max(F::zero());
2757 for j in 0..n_features {
2758 weights[j] = weights[j] * shrink;
2759 }
2760 // Gradient add `w.add(x, -eta*g)` with the scaled `g`
2761 // (`_sgd_fast.pyx.tp:637-638`).
2762 for j in 0..n_features {
2763 weights[j] = weights[j] - eta * g * xi[j];
2764 }
2765 // The intercept update is gated on `fit_intercept` and is NOT
2766 // regularized (`_sgd_fast.pyx.tp:639-644`: `if fit_intercept == 1:
2767 // intercept_update = update; ... intercept += intercept_update *
2768 // intercept_decay`). `update = -eta*g` is the SCALED update. When
2769 // `fit_intercept` is false the intercept is never modified and stays
2770 // at its init value `0` (`intercept` enters this fn as `0`).
2771 if hyper.fit_intercept {
2772 *intercept = *intercept - eta * g;
2773 }
2774
2775 // L1 cumulative penalty (Tsuruoka truncated gradient), applied AFTER
2776 // the gradient add only for L1/ElasticNet (`_sgd_fast.pyx.tp:656-658`,
2777 // `l1penalty` at `:750-778` with `wscale = 1`).
2778 if apply_l1 {
2779 u = u + eff * eta * hyper.alpha;
2780 for j in 0..n_features {
2781 let z = weights[j];
2782 if weights[j] > F::zero() {
2783 weights[j] = (weights[j] - (u + q[j])).max(F::zero());
2784 } else if weights[j] < F::zero() {
2785 weights[j] = (weights[j] + (u - q[j])).min(F::zero());
2786 }
2787 q[j] = q[j] + (weights[j] - z);
2788 }
2789 }
2790
2791 // ASGD running-mean accumulation (`_sgd_fast.pyx.tp:646-654`),
2792 // AFTER the weight/intercept update + L1 truncation so
2793 // `weights`/`intercept` are the final post-step values for this
2794 // sample. `t` is the SAME 1-based global step the schedule used;
2795 // `num_iter = t - average + 1` is `>= 1` whenever `t >= average`.
2796 if hyper.average > 0 && t >= hyper.average {
2797 let num_iter = t - hyper.average + 1;
2798 let num_iter_f = F::from(num_iter).unwrap_or_else(F::one);
2799 let mu = F::one() / num_iter_f;
2800 for j in 0..n_features {
2801 average_coef[j] = average_coef[j] + (weights[j] - average_coef[j]) * mu;
2802 }
2803 average_intercept = average_intercept + (*intercept - average_intercept) * mu;
2804 }
2805 }
2806
2807 // `epoch_loss` is now the epoch `sumloss` (no mean division).
2808 total_loss = epoch_loss;
2809
2810 // Epoch-end stop rule (`_sgd_fast.pyx.tp:678-707`). When early stopping
2811 // is active, score the CURRENT weights on the held-out validation set
2812 // (R^2, `_stochastic_gradient.py:79`) and run the score-based branch
2813 // (`best_score` init `-inf`, higher is better, `:678-687`); otherwise the
2814 // training-loss branch (`sumloss` vs `best_loss`, `:688-695`).
2815 let should_break = if let (true, Some((x_val, y_val))) = (early_stopping, val_set) {
2816 let score = r2_score(weights, *intercept, x_val, y_val);
2817 convergence_tail_score(
2818 score,
2819 &mut best_score,
2820 &mut no_improve_count,
2821 &mut current_eta,
2822 tol_active,
2823 hyper.tol,
2824 hyper.n_iter_no_change,
2825 matches!(hyper.learning_rate, LearningRateSchedule::Adaptive),
2826 )
2827 } else {
2828 convergence_tail(
2829 epoch_loss,
2830 &mut best_loss,
2831 &mut no_improve_count,
2832 &mut current_eta,
2833 tol_active,
2834 hyper.tol,
2835 n_samples,
2836 hyper.n_iter_no_change,
2837 matches!(hyper.learning_rate, LearningRateSchedule::Adaptive),
2838 )
2839 };
2840 if should_break {
2841 break;
2842 }
2843 }
2844
2845 // ASGD finalize (`_stochastic_gradient.py:834-836`: averaged coef/intercept
2846 // chosen when `average <= self.t_ - 1`). `t` here equals sklearn's
2847 // `self.t_ - 1` on the full-fit path (`initial_t = 0`, sklearn inits
2848 // `self.t_ = 1` then adds `n_iter_ * n_samples`), so the condition is
2849 // `hyper.average <= t`.
2850 if hyper.average > 0 && hyper.average <= t {
2851 for j in 0..n_features {
2852 weights[j] = average_coef[j];
2853 }
2854 *intercept = average_intercept;
2855 }
2856
2857 (total_loss, t)
2858}
2859
2860/// Dispatch regressor training to the appropriate typed loss function.
2861///
2862/// `sample_w[i] = sample_weight[i]` is forwarded verbatim to the kernel
2863/// (`_sgd_fast.pyx.tp:630`, `class_weight = 1` for regression).
2864#[allow(
2865 clippy::too_many_arguments,
2866 reason = "threads the per-sample weight vector"
2867)]
2868fn dispatch_train_regressor<F: Float + Send + Sync + ScalarOperand + 'static>(
2869 x: &Array2<F>,
2870 y: &Array1<F>,
2871 w: &mut Array1<F>,
2872 b: &mut F,
2873 loss_enum: &RegressorLoss<F>,
2874 hyper: &SGDHyper<F>,
2875 initial_t: usize,
2876 sample_w: &[F],
2877 val_set: Option<(&Array2<F>, &Array1<F>)>,
2878) -> (F, usize) {
2879 match loss_enum {
2880 RegressorLoss::SquaredError => train_regressor_sgd(
2881 x,
2882 y,
2883 w,
2884 b,
2885 &SquaredError,
2886 hyper,
2887 initial_t,
2888 sample_w,
2889 val_set,
2890 ),
2891 RegressorLoss::Huber(eps) => train_regressor_sgd(
2892 x,
2893 y,
2894 w,
2895 b,
2896 &Huber { epsilon: *eps },
2897 hyper,
2898 initial_t,
2899 sample_w,
2900 val_set,
2901 ),
2902 RegressorLoss::EpsilonInsensitive(eps) => train_regressor_sgd(
2903 x,
2904 y,
2905 w,
2906 b,
2907 &EpsilonInsensitive { epsilon: *eps },
2908 hyper,
2909 initial_t,
2910 sample_w,
2911 val_set,
2912 ),
2913 RegressorLoss::SquaredEpsilonInsensitive(eps) => train_regressor_sgd(
2914 x,
2915 y,
2916 w,
2917 b,
2918 &SquaredEpsilonInsensitive { epsilon: *eps },
2919 hyper,
2920 initial_t,
2921 sample_w,
2922 val_set,
2923 ),
2924 }
2925}
2926
2927/// Fitted SGD regressor.
2928///
2929/// Holds the learned weight vector and intercept. Implements [`Predict`]
2930/// and [`PartialFit`] to support both inference and online learning.
2931#[derive(Debug, Clone)]
2932pub struct FittedSGDRegressor<F> {
2933 /// Learned weight vector (one per feature).
2934 weights: Array1<F>,
2935 /// Learned intercept (bias) term.
2936 intercept: F,
2937 /// Number of features the model was trained on.
2938 n_features: usize,
2939 /// The loss function used during training.
2940 loss: RegressorLoss<F>,
2941 /// Hyperparameters for continued training.
2942 hyper: SGDHyper<F>,
2943 /// Global step counter.
2944 t: usize,
2945}
2946
2947/// Validate regressor input shapes and parameters.
2948#[allow(
2949 clippy::too_many_arguments,
2950 reason = "threads each validated parameter"
2951)]
2952fn validate_reg_params<F: Float>(
2953 x: &Array2<F>,
2954 y: &Array1<F>,
2955 schedule: &LearningRateSchedule<F>,
2956 eta0: F,
2957 alpha: F,
2958 l1_ratio: F,
2959 loss: &RegressorLoss<F>,
2960 validation_fraction: F,
2961) -> Result<(), FerroError> {
2962 let n_samples = x.nrows();
2963 if n_samples != y.len() {
2964 return Err(FerroError::ShapeMismatch {
2965 expected: vec![n_samples],
2966 actual: vec![y.len()],
2967 context: "y length must match number of samples in X".into(),
2968 });
2969 }
2970 if n_samples == 0 {
2971 return Err(FerroError::InsufficientSamples {
2972 required: 1,
2973 actual: 0,
2974 context: "SGDRegressor requires at least one sample".into(),
2975 });
2976 }
2977 if schedule_requires_eta0(schedule) && eta0 <= F::zero() {
2978 return Err(FerroError::InvalidParameter {
2979 name: "eta0".into(),
2980 reason: "must be positive".into(),
2981 });
2982 }
2983 if alpha < F::zero() {
2984 return Err(FerroError::InvalidParameter {
2985 name: "alpha".into(),
2986 reason: "must be non-negative".into(),
2987 });
2988 }
2989 if l1_ratio < F::zero() || l1_ratio > F::one() {
2990 return Err(FerroError::InvalidParameter {
2991 name: "l1_ratio".into(),
2992 reason: "must be in the range [0, 1]".into(),
2993 });
2994 }
2995 // sklearn `_stochastic_gradient.py:2024`:
2996 // `"epsilon": [Interval(Real, 0, None, closed="left")]` — epsilon must be
2997 // `>= 0` (a negative epsilon raises `InvalidParameterError`). ferrolearn
2998 // carries epsilon inside the loss variant, so the faithful equivalent is to
2999 // reject a negative epsilon on the variants that have one (SquaredError has
3000 // none, boundary 0 is valid — closed-left).
3001 let epsilon = match loss {
3002 RegressorLoss::Huber(e)
3003 | RegressorLoss::EpsilonInsensitive(e)
3004 | RegressorLoss::SquaredEpsilonInsensitive(e) => Some(*e),
3005 RegressorLoss::SquaredError => None,
3006 };
3007 if let Some(e) = epsilon
3008 && e < F::zero()
3009 {
3010 return Err(FerroError::InvalidParameter {
3011 name: "epsilon".into(),
3012 reason: "must be in the range [0, inf)".into(),
3013 });
3014 }
3015 validate_validation_fraction(validation_fraction)?;
3016 Ok(())
3017}
3018
3019impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<F>>
3020 for SGDRegressor<F>
3021{
3022 type Fitted = FittedSGDRegressor<F>;
3023 type Error = FerroError;
3024
3025 /// Fit the SGD regressor on the given data.
3026 ///
3027 /// # Errors
3028 ///
3029 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have mismatched
3030 /// sample counts.
3031 /// Returns [`FerroError::InvalidParameter`] if `eta0` or `alpha` are
3032 /// invalid.
3033 fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedSGDRegressor<F>, FerroError> {
3034 // Delegate to the sample-weighted path with a uniform `ones(n)` weight
3035 // vector — byte-identical to the unweighted kernel
3036 // (`_check_sample_weight` -> ones, `_stochastic_gradient.py:627`).
3037 let sample_weight = Array1::<F>::from_elem(x.nrows(), F::one());
3038 self.fit_with_sample_weight(x, y, &sample_weight)
3039 }
3040}
3041
3042impl<F: Float + Send + Sync + ScalarOperand + 'static> SGDRegressor<F> {
3043 /// Fit the SGD regressor with explicit per-sample weights.
3044 ///
3045 /// Mirrors `SGDRegressor.fit(X, y, sample_weight=...)`. The per-sample weight
3046 /// scales ONLY the gradient-derived part of each update
3047 /// (`update *= class_weight * sample_weight` with `class_weight = 1` for
3048 /// regression, `_sgd_fast.pyx.tp:630`); the L2 shrink and L1 truncation are
3049 /// unscaled. [`Fit::fit`] delegates here with a uniform `ones(n)` vector.
3050 ///
3051 /// # Errors
3052 ///
3053 /// Returns [`FerroError::ShapeMismatch`] if `x`/`y`/`sample_weight` have
3054 /// mismatched sample counts.
3055 /// Returns [`FerroError::InvalidParameter`] if `eta0` or `alpha` are
3056 /// invalid.
3057 pub fn fit_with_sample_weight(
3058 &self,
3059 x: &Array2<F>,
3060 y: &Array1<F>,
3061 sample_weight: &Array1<F>,
3062 ) -> Result<FittedSGDRegressor<F>, FerroError> {
3063 validate_reg_params(
3064 x,
3065 y,
3066 &self.learning_rate,
3067 self.eta0,
3068 self.alpha,
3069 self.l1_ratio,
3070 &self.loss,
3071 self.validation_fraction,
3072 )?;
3073
3074 let n_samples = x.nrows();
3075 if sample_weight.len() != n_samples {
3076 return Err(FerroError::ShapeMismatch {
3077 expected: vec![n_samples],
3078 actual: vec![sample_weight.len()],
3079 context: "sample_weight length must match number of samples in X".into(),
3080 });
3081 }
3082
3083 // Non-finite input validation (#2263). sklearn `SGDRegressor.fit`
3084 // -> `self._validate_data(X, y, ...)` (`_stochastic_gradient.py:1476`,
3085 // the shared `BaseSGD` base path) keeps the default
3086 // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
3087 // X OR y with a `ValueError("Input X contains NaN.")` /
3088 // `"Input y contains NaN."` / `"... contains infinity ..."` BEFORE the
3089 // SGD kernel. sklearn also validates `sample_weight` via
3090 // `_check_sample_weight` (default `force_all_finite=True`,
3091 // `_stochastic_gradient.py:1501`). `y` is `Array1<F>` (float targets), so
3092 // X, y, AND sample_weight all need the runtime check.
3093 // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf (bounds-safe,
3094 // no panic, R-CODE-2); the finite path is byte-identical. This is the
3095 // SGDRegressor fit entry (`Fit::fit` delegates here with unit weights).
3096 if x.iter().any(|v| !v.is_finite()) {
3097 return Err(FerroError::InvalidParameter {
3098 name: "X".into(),
3099 reason: "Input X contains NaN or infinity.".into(),
3100 });
3101 }
3102 if y.iter().any(|v| !v.is_finite()) {
3103 return Err(FerroError::InvalidParameter {
3104 name: "y".into(),
3105 reason: "Input y contains NaN or infinity.".into(),
3106 });
3107 }
3108 if sample_weight.iter().any(|v| !v.is_finite()) {
3109 return Err(FerroError::InvalidParameter {
3110 name: "sample_weight".into(),
3111 reason: "Input sample_weight contains NaN or infinity.".into(),
3112 });
3113 }
3114
3115 let n_features = x.ncols();
3116 let hyper = reg_hyper(self);
3117 let mut w = Array1::<F>::zeros(n_features);
3118 let mut b = F::zero();
3119 let sw = sample_weight.to_vec();
3120
3121 // Early-stopping validation split (`ShuffleSplit`,
3122 // `_stochastic_gradient.py:282-287`). The hold-out is split off BEFORE
3123 // training; the kernel trains on the train subset and scores its R^2 on
3124 // the held-out validation subset each epoch. sklearn does NOT refit on
3125 // the full data — the train-subset weights are final.
3126 let t = if hyper.early_stopping {
3127 let (train_idx, val_idx) = make_validation_split(
3128 n_samples,
3129 hyper.validation_fraction,
3130 hyper.random_state,
3131 None,
3132 )
3133 .ok_or_else(|| FerroError::InvalidParameter {
3134 name: "validation_fraction".into(),
3135 reason: "early_stopping split led to an empty train or validation set; \
3136 increase the number of samples or change validation_fraction"
3137 .into(),
3138 })?;
3139 let x_tr = gather_rows(x, &train_idx);
3140 let y_tr = gather(y, &train_idx);
3141 let sw_tr: Vec<F> = train_idx.iter().map(|&i| sw[i]).collect();
3142 let x_val = gather_rows(x, &val_idx);
3143 let y_val = gather(y, &val_idx);
3144 let (_, t) = dispatch_train_regressor(
3145 &x_tr,
3146 &y_tr,
3147 &mut w,
3148 &mut b,
3149 &self.loss,
3150 &hyper,
3151 0,
3152 &sw_tr,
3153 Some((&x_val, &y_val)),
3154 );
3155 t
3156 } else {
3157 let (_, t) =
3158 dispatch_train_regressor(x, y, &mut w, &mut b, &self.loss, &hyper, 0, &sw, None);
3159 t
3160 };
3161
3162 Ok(FittedSGDRegressor {
3163 weights: w,
3164 intercept: b,
3165 n_features,
3166 loss: self.loss,
3167 hyper,
3168 t,
3169 })
3170 }
3171}
3172
3173impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
3174 for FittedSGDRegressor<F>
3175{
3176 type Output = Array1<F>;
3177 type Error = FerroError;
3178
3179 /// Predict target values for the given feature matrix.
3180 ///
3181 /// Computes `X @ weights + intercept`.
3182 ///
3183 /// # Errors
3184 ///
3185 /// Returns [`FerroError::ShapeMismatch`] if the number of features
3186 /// does not match the fitted model.
3187 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3188 let n_features = x.ncols();
3189 if n_features != self.n_features {
3190 return Err(FerroError::ShapeMismatch {
3191 expected: vec![self.n_features],
3192 actual: vec![n_features],
3193 context: "number of features must match fitted model".into(),
3194 });
3195 }
3196
3197 let preds = x.dot(&self.weights) + self.intercept;
3198 Ok(preds)
3199 }
3200}
3201
3202impl<F: Float + Send + Sync + ScalarOperand + 'static> PartialFit<Array2<F>, Array1<F>>
3203 for FittedSGDRegressor<F>
3204{
3205 type FitResult = FittedSGDRegressor<F>;
3206 type Error = FerroError;
3207
3208 /// Incrementally train the regressor on a new batch of data.
3209 ///
3210 /// # Errors
3211 ///
3212 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have mismatched
3213 /// sizes or `x` has the wrong number of features.
3214 fn partial_fit(
3215 mut self,
3216 x: &Array2<F>,
3217 y: &Array1<F>,
3218 ) -> Result<FittedSGDRegressor<F>, FerroError> {
3219 let n_samples = x.nrows();
3220 if n_samples != y.len() {
3221 return Err(FerroError::ShapeMismatch {
3222 expected: vec![n_samples],
3223 actual: vec![y.len()],
3224 context: "y length must match number of samples in X".into(),
3225 });
3226 }
3227 if x.ncols() != self.n_features {
3228 return Err(FerroError::ShapeMismatch {
3229 expected: vec![self.n_features],
3230 actual: vec![x.ncols()],
3231 context: "number of features must match fitted model".into(),
3232 });
3233 }
3234
3235 // sklearn `SGDRegressor.partial_fit` validates X and the float target y
3236 // through `_validate_data(force_all_finite=True)`
3237 // (`_stochastic_gradient.py:1476`), raising `ValueError("Input X contains
3238 // NaN.")` / `"Input y contains NaN."` BEFORE the kernel. Mirrors the
3239 // #2263 `fit_with_sample_weight` guard.
3240 if x.iter().any(|v| !v.is_finite()) {
3241 return Err(FerroError::InvalidParameter {
3242 name: "X".into(),
3243 reason: "Input X contains NaN or infinity.".into(),
3244 });
3245 }
3246 if y.iter().any(|v| !v.is_finite()) {
3247 return Err(FerroError::InvalidParameter {
3248 name: "y".into(),
3249 reason: "Input y contains NaN or infinity.".into(),
3250 });
3251 }
3252
3253 let mut hyper = self.hyper.clone();
3254 hyper.max_iter = 1;
3255 // `partial_fit` carries no per-sample weight here; uniform `1.0`.
3256 let sample_w: Vec<F> = vec![F::one(); x.nrows()];
3257
3258 let (_, t) = dispatch_train_regressor(
3259 x,
3260 y,
3261 &mut self.weights,
3262 &mut self.intercept,
3263 &self.loss,
3264 &hyper,
3265 self.t,
3266 &sample_w,
3267 None,
3268 );
3269 self.t = t;
3270
3271 Ok(self)
3272 }
3273}
3274
3275impl<F: Float + Send + Sync + ScalarOperand + 'static> PartialFit<Array2<F>, Array1<F>>
3276 for SGDRegressor<F>
3277{
3278 type FitResult = FittedSGDRegressor<F>;
3279 type Error = FerroError;
3280
3281 /// Initial call to `partial_fit` on an unfitted regressor.
3282 ///
3283 /// Equivalent to `fit` but with a single epoch.
3284 ///
3285 /// # Errors
3286 ///
3287 /// Same as [`Fit::fit`].
3288 fn partial_fit(
3289 self,
3290 x: &Array2<F>,
3291 y: &Array1<F>,
3292 ) -> Result<FittedSGDRegressor<F>, FerroError> {
3293 validate_reg_params(
3294 x,
3295 y,
3296 &self.learning_rate,
3297 self.eta0,
3298 self.alpha,
3299 self.l1_ratio,
3300 &self.loss,
3301 self.validation_fraction,
3302 )?;
3303
3304 // sklearn `SGDRegressor.partial_fit` validates X and the float target y
3305 // through `_validate_data(force_all_finite=True)`
3306 // (`_stochastic_gradient.py:1476`), raising `ValueError("Input X contains
3307 // NaN.")` / `"Input y contains NaN."` BEFORE the kernel
3308 // (`validate_reg_params` checks shape/params, not finiteness).
3309 if x.iter().any(|v| !v.is_finite()) {
3310 return Err(FerroError::InvalidParameter {
3311 name: "X".into(),
3312 reason: "Input X contains NaN or infinity.".into(),
3313 });
3314 }
3315 if y.iter().any(|v| !v.is_finite()) {
3316 return Err(FerroError::InvalidParameter {
3317 name: "y".into(),
3318 reason: "Input y contains NaN or infinity.".into(),
3319 });
3320 }
3321
3322 let n_features = x.ncols();
3323 let mut hyper = reg_hyper(&self);
3324 hyper.max_iter = 1;
3325 let mut w = Array1::<F>::zeros(n_features);
3326 let mut b = F::zero();
3327 // Initial `partial_fit` carries no per-sample weight here; uniform `1.0`.
3328 let sample_w: Vec<F> = vec![F::one(); x.nrows()];
3329
3330 let (_, t) =
3331 dispatch_train_regressor(x, y, &mut w, &mut b, &self.loss, &hyper, 0, &sample_w, None);
3332
3333 Ok(FittedSGDRegressor {
3334 weights: w,
3335 intercept: b,
3336 n_features,
3337 loss: self.loss,
3338 hyper: reg_hyper(&self),
3339 t,
3340 })
3341 }
3342}
3343
3344impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
3345 for FittedSGDRegressor<F>
3346{
3347 fn coefficients(&self) -> &Array1<F> {
3348 &self.weights
3349 }
3350
3351 fn intercept(&self) -> F {
3352 self.intercept
3353 }
3354}
3355
3356// Pipeline integration.
3357impl<F> PipelineEstimator<F> for SGDRegressor<F>
3358where
3359 F: Float + ScalarOperand + Send + Sync + 'static,
3360{
3361 fn fit_pipeline(
3362 &self,
3363 x: &Array2<F>,
3364 y: &Array1<F>,
3365 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
3366 let fitted = self.fit(x, y)?;
3367 Ok(Box::new(fitted))
3368 }
3369}
3370
3371impl<F> FittedPipelineEstimator<F> for FittedSGDRegressor<F>
3372where
3373 F: Float + ScalarOperand + Send + Sync + 'static,
3374{
3375 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3376 self.predict(x)
3377 }
3378}
3379
3380// ---------------------------------------------------------------------------
3381// SGDOneClassSVM
3382// ---------------------------------------------------------------------------
3383
3384/// Linear One-Class SVM trained by Stochastic Gradient Descent.
3385///
3386/// Mirrors scikit-learn's
3387/// [`SGDOneClassSVM`](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html)
3388/// (`_stochastic_gradient.py:2084-2668`). It solves the linear One-Class SVM
3389/// primal via the same SGD kernel as [`SGDClassifier`], with the targets fixed
3390/// to `y = ones(n)`, the Hinge loss (`threshold = 1`), the L2 penalty, and
3391/// `alpha = nu / 2` (`_stochastic_gradient.py:2479,2588`). The SGD intercept
3392/// `b` relates to the One-Class offset `rho` by `offset_ = 1 - b`
3393/// (`_stochastic_gradient.py:2325,2377`), and the per-sample intercept update
3394/// gains an extra `- 2*eta*alpha` term (`_sgd_fast.pyx.tp:641-642`).
3395///
3396/// The decision function is `decision_function(X) = X · coef_ - offset_`
3397/// (`_stochastic_gradient.py:2622`); `predict` returns `+1` (inlier) where the
3398/// decision is `>= 0` and `-1` (outlier) otherwise (`:2655-2657`).
3399///
3400/// # Type Parameters
3401///
3402/// - `F`: The floating-point type (`f32` or `f64`).
3403///
3404/// # Examples
3405///
3406/// ```
3407/// use ferrolearn_linear::sgd::SGDOneClassSVM;
3408/// use ferrolearn_core::{Fit, Predict};
3409/// use ndarray::{array, Array2};
3410///
3411/// let x = Array2::from_shape_vec((4, 2), vec![
3412/// -1.0, -1.0, -2.0, -1.0, 1.0, 1.0, 2.0, 1.0,
3413/// ]).unwrap();
3414///
3415/// let model = SGDOneClassSVM::<f64>::new()
3416/// .with_learning_rate(ferrolearn_linear::sgd::LearningRateSchedule::Constant)
3417/// .with_eta0(0.05)
3418/// .with_max_iter(10)
3419/// .with_shuffle(false);
3420/// let fitted = model.fit(&x, &()).unwrap();
3421/// let preds = fitted.predict(&x).unwrap();
3422/// assert_eq!(preds.len(), 4);
3423/// ```
3424#[derive(Debug, Clone)]
3425pub struct SGDOneClassSVM<F> {
3426 /// The `nu` parameter — an upper bound on the fraction of training errors
3427 /// and a lower bound on the fraction of support vectors. Must be in
3428 /// `(0, 1]`. Defaults to `0.5` (`_stochastic_gradient.py:2098-2102,2247`).
3429 pub nu: F,
3430 /// Whether to fit (update) the intercept. Defaults to `true`
3431 /// (`_stochastic_gradient.py:2104-2105,2248`).
3432 pub fit_intercept: bool,
3433 /// Maximum number of passes over the training data. Defaults to `1000`
3434 /// (`_stochastic_gradient.py:2107,2249`).
3435 pub max_iter: usize,
3436 /// Convergence tolerance. Defaults to `1e-3`
3437 /// (`_stochastic_gradient.py:2113,2250`). Set to `F::neg_infinity()` to
3438 /// disable the early-stop rule (the analog of sklearn's `tol=None`,
3439 /// `_stochastic_gradient.py:2310`).
3440 pub tol: F,
3441 /// Whether to shuffle the training data after each epoch. Defaults to
3442 /// `true` (`_stochastic_gradient.py:2118,2251`).
3443 pub shuffle: bool,
3444 /// The learning rate schedule. Defaults to `Optimal`
3445 /// (`_stochastic_gradient.py:2132,2254`).
3446 pub learning_rate: LearningRateSchedule<F>,
3447 /// Initial learning rate for the `constant`/`invscaling`/`adaptive`
3448 /// schedules. Defaults to `0.0` (`_stochastic_gradient.py:2145,2255`).
3449 pub eta0: F,
3450 /// Power parameter for the inverse-scaling schedule. Defaults to `0.5`
3451 /// (`_stochastic_gradient.py:2151,2256`).
3452 pub power_t: F,
3453 /// Optional random seed for sample shuffling
3454 /// (`_stochastic_gradient.py:2125`).
3455 pub random_state: Option<u64>,
3456 /// Number of consecutive non-improving epochs before convergence (or, under
3457 /// the `adaptive` schedule, before `eta` is divided by 5). Defaults to `5`
3458 /// (`_stochastic_gradient.py:2278`).
3459 pub n_iter_no_change: usize,
3460}
3461
3462impl<F: Float> SGDOneClassSVM<F> {
3463 /// Create a new `SGDOneClassSVM` with default settings.
3464 ///
3465 /// Defaults match scikit-learn's `SGDOneClassSVM.__init__`
3466 /// (`_stochastic_gradient.py:2245-2281`): `nu = 0.5`,
3467 /// `fit_intercept = true`, `max_iter = 1000`, `tol = 1e-3`,
3468 /// `shuffle = true`, `learning_rate = Optimal`, `eta0 = 0.0`,
3469 /// `power_t = 0.5`, `n_iter_no_change = 5`.
3470 #[must_use]
3471 pub fn new() -> Self {
3472 Self {
3473 nu: cst(0.5),
3474 fit_intercept: true,
3475 max_iter: 1000,
3476 tol: cst(1e-3),
3477 shuffle: true,
3478 learning_rate: LearningRateSchedule::Optimal,
3479 eta0: cst(0.0),
3480 power_t: cst(0.5),
3481 random_state: None,
3482 n_iter_no_change: 5,
3483 }
3484 }
3485
3486 /// Set the `nu` parameter (upper bound on the fraction of training errors).
3487 #[must_use]
3488 pub fn with_nu(mut self, nu: F) -> Self {
3489 self.nu = nu;
3490 self
3491 }
3492
3493 /// Set whether the intercept (bias) term is fit.
3494 #[must_use]
3495 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
3496 self.fit_intercept = fit_intercept;
3497 self
3498 }
3499
3500 /// Set the maximum number of epochs.
3501 #[must_use]
3502 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
3503 self.max_iter = max_iter;
3504 self
3505 }
3506
3507 /// Set the convergence tolerance.
3508 #[must_use]
3509 pub fn with_tol(mut self, tol: F) -> Self {
3510 self.tol = tol;
3511 self
3512 }
3513
3514 /// Set whether the training data is shuffled after each epoch.
3515 #[must_use]
3516 pub fn with_shuffle(mut self, shuffle: bool) -> Self {
3517 self.shuffle = shuffle;
3518 self
3519 }
3520
3521 /// Set the learning rate schedule.
3522 #[must_use]
3523 pub fn with_learning_rate(mut self, lr: LearningRateSchedule<F>) -> Self {
3524 self.learning_rate = lr;
3525 self
3526 }
3527
3528 /// Set the initial learning rate.
3529 #[must_use]
3530 pub fn with_eta0(mut self, eta0: F) -> Self {
3531 self.eta0 = eta0;
3532 self
3533 }
3534
3535 /// Set the power parameter for inverse scaling.
3536 #[must_use]
3537 pub fn with_power_t(mut self, power_t: F) -> Self {
3538 self.power_t = power_t;
3539 self
3540 }
3541
3542 /// Set the random seed for reproducibility.
3543 #[must_use]
3544 pub fn with_random_state(mut self, seed: u64) -> Self {
3545 self.random_state = Some(seed);
3546 self
3547 }
3548
3549 /// Set the number of consecutive non-improving epochs before convergence.
3550 #[must_use]
3551 pub fn with_n_iter_no_change(mut self, n_iter_no_change: usize) -> Self {
3552 self.n_iter_no_change = n_iter_no_change;
3553 self
3554 }
3555
3556 /// Fit the linear One-Class SVM on `x` (the X-only fit shape).
3557 ///
3558 /// This is the inherent entry point mirroring sklearn's `fit(X)`
3559 /// (`_stochastic_gradient.py:2554-2600`). The [`Fit`] trait impl with a
3560 /// unit target `()` delegates here.
3561 ///
3562 /// # Errors
3563 ///
3564 /// - [`FerroError::InvalidParameter`] if `nu` is not in `(0, 1]`
3565 /// (`_stochastic_gradient.py:2236`,
3566 /// `Interval(Real, 0.0, 1.0, closed="right")`).
3567 /// - [`FerroError::InvalidParameter`] if `eta0` is not positive for the
3568 /// `constant`/`invscaling`/`adaptive` schedules.
3569 /// - [`FerroError::InsufficientSamples`] if `x` has no rows.
3570 pub fn fit_one_class(&self, x: &Array2<F>) -> Result<FittedSGDOneClassSVM<F>, FerroError>
3571 where
3572 F: Send + Sync + ScalarOperand + 'static,
3573 {
3574 // `nu` constraint: `Interval(Real, 0.0, 1.0, closed="right")`, i.e.
3575 // `0 < nu <= 1` (`_stochastic_gradient.py:2236`).
3576 if self.nu <= F::zero() || self.nu > F::one() {
3577 return Err(FerroError::InvalidParameter {
3578 name: "nu".into(),
3579 reason: "must be in the range (0, 1]".into(),
3580 });
3581 }
3582 let n_samples = x.nrows();
3583 if n_samples == 0 {
3584 return Err(FerroError::InsufficientSamples {
3585 required: 1,
3586 actual: 0,
3587 context: "SGDOneClassSVM requires at least one sample".into(),
3588 });
3589 }
3590
3591 // Non-finite input validation (#2263) — SEPARATE SGD arm. sklearn
3592 // `SGDOneClassSVM.fit` -> `self._validate_data(X, None, ...)`
3593 // (`_stochastic_gradient.py:2392`) keeps the default
3594 // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
3595 // X with a `ValueError("Input X contains NaN.")` / `"... contains
3596 // infinity ..."` BEFORE the SGD kernel. This is an X-only fit (no `y`, no
3597 // `sample_weight` argument), so X is the only runtime check.
3598 // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf (bounds-safe,
3599 // no panic, R-CODE-2); the finite path is byte-identical.
3600 if x.iter().any(|v| !v.is_finite()) {
3601 return Err(FerroError::InvalidParameter {
3602 name: "X".into(),
3603 reason: "Input X contains NaN or infinity.".into(),
3604 });
3605 }
3606
3607 // `eta0 > 0` is required for the constant/invscaling/adaptive schedules
3608 // (mirrors `_more_validate_params`, `_stochastic_gradient.py:149-153`);
3609 // the `optimal` schedule accepts `eta0 == 0`.
3610 if schedule_requires_eta0(&self.learning_rate) && self.eta0 <= F::zero() {
3611 return Err(FerroError::InvalidParameter {
3612 name: "eta0".into(),
3613 reason: "must be positive".into(),
3614 });
3615 }
3616
3617 let n_features = x.ncols();
3618 // sklearn: `alpha = self.nu / 2` (`_stochastic_gradient.py:2588`),
3619 // `penalty="l2"`, `l1_ratio=0`, `loss="hinge"` (`:2262-2265`).
3620 let two = cst::<F>(2.0);
3621 let alpha = self.nu / two;
3622 let hyper = SGDHyper {
3623 learning_rate: self.learning_rate,
3624 eta0: self.eta0,
3625 alpha,
3626 max_iter: self.max_iter,
3627 tol: self.tol,
3628 random_state: self.random_state,
3629 power_t: self.power_t,
3630 penalty: Penalty::L2,
3631 l1_ratio: F::zero(),
3632 shuffle: self.shuffle,
3633 n_iter_no_change: self.n_iter_no_change,
3634 fit_intercept: self.fit_intercept,
3635 one_class: true,
3636 // sklearn's `SGDOneClassSVM` has no `average` parameter — averaging is
3637 // always off on the one-class path (`_stochastic_gradient.py:2245-2281`).
3638 average: 0,
3639 // The one-class SVM exposes no `early_stopping`/`validation_fraction`
3640 // (`_stochastic_gradient.py:2245-2281`); the early-stop score branch
3641 // is always off, leaving the one-class trajectory byte-identical.
3642 early_stopping: false,
3643 validation_fraction: cst(0.1),
3644 };
3645
3646 // `y = np.ones(n_samples)` (`_stochastic_gradient.py:2289`).
3647 let y_ones: Array1<F> = Array1::from_elem(n_samples, F::one());
3648 let mut w = Array1::<F>::zeros(n_features);
3649 // The One-Class offset is initialized to 0, so the SGD intercept starts
3650 // at `b = 1 - offset_ = 1` (`_stochastic_gradient.py:2238,2325`). This
3651 // differs from the classifier/regressor paths, which start at `b = 0`.
3652 let mut b = F::one();
3653 // One-Class SVM fit has no per-sample weighting here; the per-sample
3654 // weight is uniform `1.0` (byte-identical to the pre-weighting kernel).
3655 let sample_w: Vec<F> = vec![F::one(); n_samples];
3656
3657 let (_, _t) = train_binary_sgd(
3658 x, &y_ones, &mut w, &mut b, &Hinge, &hyper, 0, &sample_w, None,
3659 );
3660
3661 // `offset_ = 1 - intercept` (`_stochastic_gradient.py:2377`).
3662 let offset = F::one() - b;
3663
3664 Ok(FittedSGDOneClassSVM {
3665 coef: w,
3666 offset,
3667 n_features,
3668 })
3669 }
3670}
3671
3672impl<F: Float> Default for SGDOneClassSVM<F> {
3673 fn default() -> Self {
3674 Self::new()
3675 }
3676}
3677
3678impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, ()> for SGDOneClassSVM<F> {
3679 type Fitted = FittedSGDOneClassSVM<F>;
3680 type Error = FerroError;
3681
3682 /// Fit the linear One-Class SVM. The target `y` is ignored (present for API
3683 /// consistency, mirroring sklearn's `fit(X, y=None)`,
3684 /// `_stochastic_gradient.py:2554`); the fit uses `y = ones(n)` internally.
3685 ///
3686 /// # Errors
3687 ///
3688 /// See [`SGDOneClassSVM::fit_one_class`].
3689 fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedSGDOneClassSVM<F>, FerroError> {
3690 self.fit_one_class(x)
3691 }
3692}
3693
3694/// Fitted linear One-Class SVM.
3695///
3696/// Holds the learned weight vector `coef_` and the One-Class offset `offset_`
3697/// (`_stochastic_gradient.py:2177-2182`). Implements [`Predict`] (returning
3698/// `+1`/`-1` inlier/outlier labels) and exposes [`decision_function`],
3699/// [`score_samples`], [`coef`], and [`offset`].
3700///
3701/// [`decision_function`]: FittedSGDOneClassSVM::decision_function
3702/// [`score_samples`]: FittedSGDOneClassSVM::score_samples
3703/// [`coef`]: FittedSGDOneClassSVM::coef
3704/// [`offset`]: FittedSGDOneClassSVM::offset
3705#[derive(Debug, Clone)]
3706pub struct FittedSGDOneClassSVM<F> {
3707 /// Weight vector (`coef_`, shape `(n_features,)`).
3708 coef: Array1<F>,
3709 /// The One-Class offset (`offset_`), a scalar: `offset_ = 1 - intercept`.
3710 offset: F,
3711 /// Number of features the model was trained on.
3712 n_features: usize,
3713}
3714
3715impl<F: Float + Send + Sync + ScalarOperand + 'static> FittedSGDOneClassSVM<F> {
3716 /// The learned weight vector (`coef_`).
3717 #[must_use]
3718 pub fn coef(&self) -> &Array1<F> {
3719 &self.coef
3720 }
3721
3722 /// The One-Class offset (`offset_`).
3723 ///
3724 /// Satisfies `decision_function = score_samples - offset_`
3725 /// (`_stochastic_gradient.py:2182`).
3726 #[must_use]
3727 pub fn offset(&self) -> F {
3728 self.offset
3729 }
3730
3731 /// Signed distance to the separating hyperplane:
3732 /// `decision_function(X) = X · coef_ - offset_`
3733 /// (`_stochastic_gradient.py:2622`).
3734 ///
3735 /// Positive for an inlier, negative for an outlier.
3736 ///
3737 /// # Errors
3738 ///
3739 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
3740 /// not match the fitted model.
3741 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3742 let n_features = x.ncols();
3743 if n_features != self.n_features {
3744 return Err(FerroError::ShapeMismatch {
3745 expected: vec![self.n_features],
3746 actual: vec![n_features],
3747 context: "number of features must match fitted model".into(),
3748 });
3749 }
3750 Ok(x.dot(&self.coef) - self.offset)
3751 }
3752
3753 /// Raw scoring function of the samples:
3754 /// `score_samples(X) = decision_function(X) + offset_ = X · coef_`
3755 /// (`_stochastic_gradient.py:2639`).
3756 ///
3757 /// # Errors
3758 ///
3759 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
3760 /// not match the fitted model.
3761 pub fn score_samples(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3762 Ok(self.decision_function(x)? + self.offset)
3763 }
3764}
3765
3766impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
3767 for FittedSGDOneClassSVM<F>
3768{
3769 type Output = Array1<isize>;
3770 type Error = FerroError;
3771
3772 /// Return labels (`+1` inlier, `-1` outlier) for the given feature matrix.
3773 ///
3774 /// Mirrors `_stochastic_gradient.py:2655-2657`:
3775 /// `y = (decision_function(X) >= 0); y[y == 0] = -1`, i.e. `+1` where the
3776 /// decision is `>= 0` and `-1` otherwise.
3777 ///
3778 /// # Errors
3779 ///
3780 /// Returns [`FerroError::ShapeMismatch`] if the number of features does
3781 /// not match the fitted model.
3782 fn predict(&self, x: &Array2<F>) -> Result<Array1<isize>, FerroError> {
3783 let decisions = self.decision_function(x)?;
3784 Ok(decisions.mapv(|d| if d >= F::zero() { 1 } else { -1 }))
3785 }
3786}
3787
3788// ---------------------------------------------------------------------------
3789// Tests
3790// ---------------------------------------------------------------------------
3791
3792#[cfg(test)]
3793mod tests {
3794 use super::*;
3795 use ndarray::array;
3796
3797 // -----------------------------------------------------------------------
3798 // Early-stopping validation-score helpers (REQ-13)
3799 //
3800 // These pin the DETERMINISTIC per-epoch validation score against the live
3801 // sklearn 1.5.2 oracle (`sklearn.metrics.r2_score` / `accuracy_score`, the
3802 // regressor/classifier `_ValidationScoreCallback`,
3803 // `_stochastic_gradient.py:79`). The validation-set SELECTION is numpy-RNG
3804 // coupled and NOT verified here (same barrier as `shuffle`); the SCORE math
3805 // on a GIVEN (weights, intercept, val set) is fully deterministic.
3806 // -----------------------------------------------------------------------
3807
3808 #[test]
3809 fn test_validation_r2_matches_sklearn() {
3810 // Oracle:
3811 // python3 -c "import numpy as np; from sklearn.metrics import r2_score; \
3812 // X=np.array([[1.,2.],[3.,1.],[0.,4.]]); w=np.array([1.,-2.]); b=0.5; \
3813 // y=np.array([-2.,4.,-7.]); print(r2_score(y, X@w+b))"
3814 // -> 0.8887362637362637
3815 let weights = array![1.0_f64, -2.0];
3816 let x_val = array![[1.0_f64, 2.0], [3.0, 1.0], [0.0, 4.0]];
3817 let y_val = array![-2.0_f64, 4.0, -7.0];
3818 let got = r2_score(&weights, 0.5, &x_val, &y_val);
3819 assert!(
3820 (got - 0.8887362637362637).abs() < 1e-12,
3821 "r2 {got} != sklearn 0.8887362637362637"
3822 );
3823 }
3824
3825 #[test]
3826 fn test_validation_r2_constant_y_edge_cases() {
3827 // sklearn `r2_score` with SS_tot == 0 (constant y_val): perfect const
3828 // prediction -> 1.0, imperfect -> 0.0.
3829 // python3 -c "from sklearn.metrics import r2_score; \
3830 // print(r2_score([5,5,5],[5,5,5]), r2_score([5,5,5],[4,4,4]))"
3831 // -> 1.0 0.0
3832 let weights = array![0.0_f64, 0.0];
3833 let x = array![[1.0_f64, 2.0], [3.0, 1.0], [0.0, 4.0]];
3834 let y_const = array![5.0_f64, 5.0, 5.0];
3835 // weights=0, intercept=5 -> all predictions 5 -> perfect -> 1.0.
3836 let got_perfect = r2_score(&weights, 5.0, &x, &y_const);
3837 assert!(
3838 (got_perfect - 1.0).abs() < 1e-12,
3839 "perfect-const r2 {got_perfect} != 1.0"
3840 );
3841 // weights=0, intercept=4 -> all predictions 4, y all 5 -> imperfect -> 0.0.
3842 let got_imperfect = r2_score(&weights, 4.0, &x, &y_const);
3843 assert!(
3844 got_imperfect.abs() < 1e-12,
3845 "imperfect-const r2 {got_imperfect} != 0.0"
3846 );
3847 }
3848
3849 #[test]
3850 fn test_validation_binary_accuracy_matches_sklearn() {
3851 // Oracle:
3852 // python3 -c "import numpy as np; from sklearn.metrics import accuracy_score; \
3853 // X=np.array([[1.,1.],[2.,2.],[0.,0.],[3.,1.]]); w=np.array([1.,1.]); b=-3.; \
3854 // dec=X@w+b; pred=np.where(dec>=0,1.,-1.); y=np.array([1.,1.,-1.,1.]); \
3855 // print(accuracy_score(y,pred))"
3856 // -> 0.75 (dec=[-1,1,-3,1] -> pred=[-1,1,-1,1], y=[1,1,-1,1] -> 3/4)
3857 let weights = array![1.0_f64, 1.0];
3858 let x_val = array![[1.0_f64, 1.0], [2.0, 2.0], [0.0, 0.0], [3.0, 1.0]];
3859 let y_val = array![1.0_f64, 1.0, -1.0, 1.0];
3860 let got = binary_accuracy(&weights, -3.0, &x_val, &y_val);
3861 assert!((got - 0.75).abs() < 1e-12, "accuracy {got} != sklearn 0.75");
3862 }
3863
3864 // -----------------------------------------------------------------------
3865 // Loss function tests
3866 // -----------------------------------------------------------------------
3867
3868 #[test]
3869 fn test_hinge_loss_correct_side() {
3870 let h = Hinge;
3871 // y=1, pred=2 => margin=2 >= 1 => loss=0
3872 assert!((Loss::<f64>::loss(&h, 1.0, 2.0) - 0.0).abs() < 1e-10);
3873 assert!((Loss::<f64>::gradient(&h, 1.0, 2.0) - 0.0).abs() < 1e-10);
3874 }
3875
3876 #[test]
3877 fn test_hinge_loss_wrong_side() {
3878 let h = Hinge;
3879 // y=1, pred=-0.5 => margin=-0.5 < 1 => loss=1.5
3880 assert!((Loss::<f64>::loss(&h, 1.0, -0.5) - 1.5).abs() < 1e-10);
3881 assert!((Loss::<f64>::gradient(&h, 1.0, -0.5) - (-1.0)).abs() < 1e-10);
3882 }
3883
3884 #[test]
3885 fn test_log_loss_zero_pred() {
3886 let l = LogLoss;
3887 // y=1, pred=0 => loss=log(1+exp(0))=log(2)
3888 let loss = Loss::<f64>::loss(&l, 1.0, 0.0);
3889 assert!((loss - 2.0_f64.ln()).abs() < 1e-10);
3890 }
3891
3892 #[test]
3893 fn test_log_loss_large_correct() {
3894 let l = LogLoss;
3895 // y=1, pred=20 => very small loss
3896 let loss = Loss::<f64>::loss(&l, 1.0, 20.0);
3897 assert!(loss < 1e-5);
3898 }
3899
3900 #[test]
3901 fn test_squared_error_loss() {
3902 let s = SquaredError;
3903 assert!((Loss::<f64>::loss(&s, 3.0, 1.0) - 2.0).abs() < 1e-10);
3904 assert!((Loss::<f64>::gradient(&s, 3.0, 1.0) - (-2.0)).abs() < 1e-10);
3905 }
3906
3907 #[test]
3908 fn test_modified_huber_loss() {
3909 let mh = ModifiedHuber;
3910 // y=1, pred=2 => z=2 >= 1 => loss=0
3911 assert!((Loss::<f64>::loss(&mh, 1.0, 2.0)).abs() < 1e-10);
3912 // y=1, pred=0.5 => z=0.5 => loss=(1-0.5)^2=0.25
3913 assert!((Loss::<f64>::loss(&mh, 1.0, 0.5) - 0.25).abs() < 1e-10);
3914 // y=1, pred=-2 => z=-2 < -1 => loss=-4*(-2)=8
3915 assert!((Loss::<f64>::loss(&mh, 1.0, -2.0) - 8.0).abs() < 1e-10);
3916 }
3917
3918 #[test]
3919 fn test_huber_loss_quadratic_region() {
3920 let h = Huber { epsilon: 1.0_f64 };
3921 // |y - p| = 0.5 <= 1.0 => quadratic
3922 assert!((Loss::<f64>::loss(&h, 1.0, 0.5) - 0.125).abs() < 1e-10);
3923 }
3924
3925 #[test]
3926 fn test_huber_loss_linear_region() {
3927 let h = Huber { epsilon: 1.0_f64 };
3928 // |y - p| = 3 > 1 => linear: 1*(3 - 0.5) = 2.5
3929 assert!((Loss::<f64>::loss(&h, 3.0, 0.0) - 2.5).abs() < 1e-10);
3930 }
3931
3932 #[test]
3933 fn test_epsilon_insensitive_inside() {
3934 let ei = EpsilonInsensitive { epsilon: 0.1_f64 };
3935 // |y - p| = 0.05 <= 0.1 => loss=0
3936 assert!((Loss::<f64>::loss(&ei, 1.0, 0.95)).abs() < 1e-10);
3937 }
3938
3939 #[test]
3940 fn test_epsilon_insensitive_outside() {
3941 let ei = EpsilonInsensitive { epsilon: 0.1_f64 };
3942 // |y - p| = 0.5 > 0.1 => loss=0.4
3943 assert!((Loss::<f64>::loss(&ei, 1.0, 0.5) - 0.4).abs() < 1e-10);
3944 }
3945
3946 // -----------------------------------------------------------------------
3947 // Learning rate tests
3948 // -----------------------------------------------------------------------
3949
3950 #[test]
3951 fn test_constant_lr() {
3952 let lr: LearningRateSchedule<f64> = LearningRateSchedule::Constant;
3953 // optimal_init is ignored by the Constant schedule.
3954 assert!((compute_lr(&lr, 0.1, 0.01, 0.25, 1.0, 100) - 0.1).abs() < 1e-10);
3955 }
3956
3957 #[test]
3958 fn test_optimal_lr() {
3959 // sklearn `_sgd_fast.pyx.tp:592`: `eta = 1/(alpha*(optimal_init+t-1))`.
3960 // At t=1 this equals `initial_eta0 = 1/(alpha*optimal_init)`. With
3961 // alpha=0.01 and Hinge `dloss(1,-typw)` (= -1, |·|=1), the live oracle
3962 // gives `typw = sqrt(1/sqrt(0.01)) = 3.1622776601683795` and
3963 // `optimal_init = 1/(typw*0.01) = 31.62277660168379` (computed by
3964 // `_sgd_fast`'s init block), so eta@t=1 == typw.
3965 let lr: LearningRateSchedule<f64> = LearningRateSchedule::Optimal;
3966 const OPTIMAL_INIT: f64 = 31.62277660168379;
3967 const ETA_T1: f64 = 3.1622776601683795; // = typw = initial_eta0
3968 assert!((compute_lr(&lr, 0.0, 0.01, 0.5, OPTIMAL_INIT, 1) - ETA_T1).abs() < 1e-9);
3969 // At t=10: eta = 1/(0.01*(31.62277660168379 + 9)).
3970 let expected_t10 = 1.0 / (0.01 * (OPTIMAL_INIT + 9.0));
3971 assert!((compute_lr(&lr, 0.0, 0.01, 0.5, OPTIMAL_INIT, 10) - expected_t10).abs() < 1e-10);
3972 }
3973
3974 #[test]
3975 fn test_optimal_init_matches_oracle() {
3976 // optimal_init derivation matches the live sklearn oracle (Hinge):
3977 // python3 -c "import numpy as np; from sklearn.linear_model._sgd_fast \
3978 // import Hinge; a=0.01; typw=np.sqrt(1/np.sqrt(a)); \
3979 // e0=typw/max(1.0,abs(Hinge(1.0).py_dloss(1.0,-typw))); print(1/(e0*a))"
3980 // -> 31.62277660168379
3981 const SK_OPTIMAL_INIT: f64 = 31.62277660168379;
3982 let got = optimal_init(&Hinge, 0.01_f64);
3983 assert!((got - SK_OPTIMAL_INIT).abs() < 1e-9, "got {got}");
3984 // alpha == 0 returns 1.0 (schedule guarded / unused upstream).
3985 assert!((optimal_init(&Hinge, 0.0_f64) - 1.0).abs() < 1e-12);
3986 }
3987
3988 #[test]
3989 fn test_invscaling_lr() {
3990 let lr: LearningRateSchedule<f64> = LearningRateSchedule::InvScaling;
3991 // eta = 0.1 / 10^0.5 = 0.1 / 3.162... ~= 0.0316...
3992 let result = compute_lr(&lr, 0.1, 0.01, 0.5, 1.0, 10);
3993 let expected = 0.1 / 10.0_f64.sqrt();
3994 assert!((result - expected).abs() < 1e-10);
3995 }
3996
3997 #[test]
3998 fn test_adaptive_lr_returns_eta0() {
3999 let lr: LearningRateSchedule<f64> = LearningRateSchedule::Adaptive;
4000 assert!((compute_lr(&lr, 0.05, 0.01, 0.25, 1.0, 100) - 0.05).abs() < 1e-10);
4001 }
4002
4003 // -----------------------------------------------------------------------
4004 // SGDClassifier tests
4005 // -----------------------------------------------------------------------
4006
4007 #[test]
4008 fn test_sgd_classifier_binary() {
4009 // Well-separated clusters centered near origin for SGD stability.
4010 let x = Array2::from_shape_vec(
4011 (8, 2),
4012 vec![
4013 -2.0, -2.0, -1.5, -2.0, -2.0, -1.5, -1.5, -1.5, 2.0, 2.0, 1.5, 2.0, 2.0, 1.5, 1.5,
4014 1.5,
4015 ],
4016 )
4017 .unwrap();
4018 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4019
4020 let clf = SGDClassifier::<f64>::new()
4021 .with_loss(ClassifierLoss::Log)
4022 .with_random_state(42)
4023 .with_max_iter(1000)
4024 .with_eta0(0.01);
4025 let fitted = clf.fit(&x, &y).unwrap();
4026 let preds = fitted.predict(&x).unwrap();
4027
4028 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
4029 assert!(correct >= 6, "expected >= 6 correct, got {correct}");
4030 }
4031
4032 #[test]
4033 fn test_sgd_classifier_log_loss() {
4034 let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
4035 let y = array![0, 0, 0, 1, 1, 1];
4036
4037 let clf = SGDClassifier::<f64>::new()
4038 .with_loss(ClassifierLoss::Log)
4039 .with_random_state(42)
4040 .with_max_iter(500);
4041 let fitted = clf.fit(&x, &y).unwrap();
4042 let preds = fitted.predict(&x).unwrap();
4043
4044 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
4045 assert!(correct >= 4, "expected >= 4 correct, got {correct}");
4046 }
4047
4048 #[test]
4049 fn test_sgd_classifier_multiclass() {
4050 let x = Array2::from_shape_vec(
4051 (9, 2),
4052 vec![
4053 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
4054 0.0, 5.5,
4055 ],
4056 )
4057 .unwrap();
4058 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
4059
4060 let clf = SGDClassifier::<f64>::new()
4061 .with_random_state(42)
4062 .with_max_iter(1000)
4063 .with_eta0(0.01);
4064 let fitted = clf.fit(&x, &y).unwrap();
4065 let preds = fitted.predict(&x).unwrap();
4066
4067 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
4068 assert!(
4069 correct >= 6,
4070 "expected >= 6 correct for multiclass, got {correct}"
4071 );
4072 }
4073
4074 #[test]
4075 fn test_sgd_classifier_shape_mismatch_fit() {
4076 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4077 let y = array![0, 1]; // Wrong length
4078 let clf = SGDClassifier::<f64>::new();
4079 assert!(clf.fit(&x, &y).is_err());
4080 }
4081
4082 #[test]
4083 fn test_sgd_classifier_shape_mismatch_predict() {
4084 let x =
4085 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 8.0, 8.0, 9.0, 9.0]).unwrap();
4086 let y = array![0, 0, 1, 1];
4087 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4088 let fitted = clf.fit(&x, &y).unwrap();
4089
4090 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap();
4091 assert!(fitted.predict(&x_bad).is_err());
4092 }
4093
4094 #[test]
4095 fn test_sgd_classifier_single_class_error() {
4096 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4097 let y = array![0, 0, 0];
4098 let clf = SGDClassifier::<f64>::new();
4099 assert!(clf.fit(&x, &y).is_err());
4100 }
4101
4102 #[test]
4103 fn test_sgd_classifier_invalid_eta0() {
4104 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4105 let y = array![0, 0, 1, 1];
4106 // sklearn enforces `eta0 > 0` only for constant/invscaling/adaptive
4107 // (`_stochastic_gradient.py:149-153`); under the default `optimal`
4108 // schedule `eta0 = 0.0` is valid, so use `constant` to hit the reject.
4109 let clf = SGDClassifier::<f64>::new()
4110 .with_learning_rate(LearningRateSchedule::Constant)
4111 .with_eta0(0.0);
4112 assert!(clf.fit(&x, &y).is_err());
4113 }
4114
4115 #[test]
4116 fn test_sgd_classifier_optimal_eta0_zero_ok() {
4117 // Default `optimal` schedule with `eta0 = 0.0` (sklearn default) must
4118 // NOT be rejected by validation (`_stochastic_gradient.py:149-153`).
4119 let x = Array2::from_shape_vec((4, 1), vec![-2.0, -1.0, 1.0, 2.0]).unwrap_or_default();
4120 let y = array![0, 0, 1, 1];
4121 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4122 assert!((clf.eta0 - 0.0).abs() < 1e-12);
4123 assert!(clf.fit(&x, &y).is_ok());
4124 }
4125
4126 #[test]
4127 fn test_sgd_classifier_invalid_alpha() {
4128 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4129 let y = array![0, 0, 1, 1];
4130 let clf = SGDClassifier::<f64>::new().with_alpha(-1.0);
4131 assert!(clf.fit(&x, &y).is_err());
4132 }
4133
4134 #[test]
4135 fn test_sgd_classifier_has_coefficients() {
4136 let x =
4137 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 8.0, 8.0, 9.0, 9.0]).unwrap();
4138 let y = array![0, 0, 1, 1];
4139 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4140 let fitted = clf.fit(&x, &y).unwrap();
4141 assert_eq!(fitted.coefficients().len(), 2);
4142 }
4143
4144 #[test]
4145 fn test_sgd_classifier_partial_fit() {
4146 let x =
4147 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 8.0, 8.0, 9.0, 9.0]).unwrap();
4148 let y = array![0, 0, 1, 1];
4149
4150 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4151 let fitted = clf.partial_fit(&x, &y).unwrap();
4152 let fitted = fitted.partial_fit(&x, &y).unwrap();
4153 let preds = fitted.predict(&x).unwrap();
4154 assert_eq!(preds.len(), 4);
4155 }
4156
4157 #[test]
4158 fn test_sgd_classifier_partial_fit_chain() {
4159 // Test the chaining pattern:
4160 // model.partial_fit(&b1, &y1)?.partial_fit(&b2, &y2)?.predict(&x)?
4161 let x1 =
4162 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 8.0, 8.0, 9.0, 9.0]).unwrap();
4163 let y1 = array![0, 0, 1, 1];
4164 let x2 =
4165 Array2::from_shape_vec((4, 2), vec![0.5, 0.5, 1.5, 1.5, 7.5, 7.5, 8.5, 8.5]).unwrap();
4166 let y2 = array![0, 0, 1, 1];
4167
4168 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4169 let preds = clf
4170 .partial_fit(&x1, &y1)
4171 .unwrap()
4172 .partial_fit(&x2, &y2)
4173 .unwrap()
4174 .predict(&x1)
4175 .unwrap();
4176 assert_eq!(preds.len(), 4);
4177 }
4178
4179 #[test]
4180 fn test_sgd_classifier_partial_fit_shape_mismatch() {
4181 let x =
4182 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 8.0, 8.0, 9.0, 9.0]).unwrap();
4183 let y = array![0, 0, 1, 1];
4184 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4185 let fitted = clf.partial_fit(&x, &y).unwrap();
4186
4187 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap();
4188 let y_bad = array![0, 1];
4189 assert!(fitted.partial_fit(&x_bad, &y_bad).is_err());
4190 }
4191
4192 #[test]
4193 fn test_sgd_classifier_modified_huber() {
4194 let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
4195 let y = array![0, 0, 0, 1, 1, 1];
4196
4197 let clf = SGDClassifier::<f64>::new()
4198 .with_loss(ClassifierLoss::ModifiedHuber)
4199 .with_random_state(42)
4200 .with_max_iter(500);
4201 let fitted = clf.fit(&x, &y).unwrap();
4202 let preds = fitted.predict(&x).unwrap();
4203 assert_eq!(preds.len(), 6);
4204 }
4205
4206 #[test]
4207 fn test_sgd_classifier_squared_error_loss() {
4208 let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
4209 let y = array![0, 0, 0, 1, 1, 1];
4210
4211 let clf = SGDClassifier::<f64>::new()
4212 .with_loss(ClassifierLoss::SquaredError)
4213 .with_random_state(42)
4214 .with_max_iter(500);
4215 let fitted = clf.fit(&x, &y).unwrap();
4216 let preds = fitted.predict(&x).unwrap();
4217 assert_eq!(preds.len(), 6);
4218 }
4219
4220 #[test]
4221 fn test_sgd_classifier_pipeline() {
4222 let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
4223 let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
4224
4225 let clf = SGDClassifier::<f64>::new().with_random_state(42);
4226 let fitted = clf.fit_pipeline(&x, &y).unwrap();
4227 let preds = fitted.predict_pipeline(&x).unwrap();
4228 assert_eq!(preds.len(), 6);
4229 }
4230
4231 #[test]
4232 fn test_sgd_classifier_constant_lr() -> Result<(), FerroError> {
4233 let x = Array2::from_shape_vec((4, 1), vec![-2.0, -1.0, 1.0, 2.0]).unwrap_or_default();
4234 let y = array![0, 0, 1, 1];
4235
4236 // The `constant` schedule requires `eta0 > 0`; the default is now 0.0
4237 // (sklearn `optimal` default), so set it explicitly.
4238 let clf = SGDClassifier::<f64>::new()
4239 .with_learning_rate(LearningRateSchedule::Constant)
4240 .with_eta0(0.01)
4241 .with_random_state(42)
4242 .with_max_iter(200);
4243 let fitted = clf.fit(&x, &y)?;
4244 assert_eq!(fitted.predict(&x)?.len(), 4);
4245 Ok(())
4246 }
4247
4248 #[test]
4249 fn test_sgd_classifier_f32() {
4250 let x = Array2::from_shape_vec((4, 1), vec![-2.0f32, -1.0, 1.0, 2.0]).unwrap();
4251 let y = array![0_usize, 0, 1, 1];
4252
4253 let clf = SGDClassifier::<f32>::new()
4254 .with_random_state(42)
4255 .with_max_iter(200);
4256 let fitted = clf.fit(&x, &y).unwrap();
4257 assert_eq!(fitted.predict(&x).unwrap().len(), 4);
4258 }
4259
4260 // -----------------------------------------------------------------------
4261 // SGDRegressor tests
4262 // -----------------------------------------------------------------------
4263
4264 #[test]
4265 fn test_sgd_regressor_basic() {
4266 // y = 2*x + 1
4267 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
4268 let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
4269
4270 let model = SGDRegressor::<f64>::new()
4271 .with_random_state(42)
4272 .with_max_iter(2000)
4273 .with_eta0(0.01)
4274 .with_alpha(0.0);
4275 let fitted = model.fit(&x, &y).unwrap();
4276 let preds = fitted.predict(&x).unwrap();
4277
4278 // Check rough accuracy.
4279 for (p, &actual) in preds.iter().zip(y.iter()) {
4280 assert!(
4281 (*p - actual).abs() < 2.0,
4282 "prediction {p} too far from {actual}"
4283 );
4284 }
4285 }
4286
4287 #[test]
4288 fn test_sgd_regressor_shape_mismatch() {
4289 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4290 let y = array![1.0, 2.0]; // Wrong length
4291 let model = SGDRegressor::<f64>::new();
4292 assert!(model.fit(&x, &y).is_err());
4293 }
4294
4295 #[test]
4296 fn test_sgd_regressor_predict_shape_mismatch() {
4297 let x =
4298 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0]).unwrap();
4299 let y = array![1.0, 2.0, 3.0, 4.0];
4300 let model = SGDRegressor::<f64>::new().with_random_state(42);
4301 let fitted = model.fit(&x, &y).unwrap();
4302
4303 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap();
4304 assert!(fitted.predict(&x_bad).is_err());
4305 }
4306
4307 #[test]
4308 fn test_sgd_regressor_invalid_eta0() {
4309 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4310 let y = array![1.0, 2.0, 3.0];
4311 let model = SGDRegressor::<f64>::new().with_eta0(-0.1);
4312 assert!(model.fit(&x, &y).is_err());
4313 }
4314
4315 #[test]
4316 fn test_sgd_regressor_has_coefficients() {
4317 let x =
4318 Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0]).unwrap();
4319 let y = array![1.0, 2.0, 3.0, 4.0];
4320 let model = SGDRegressor::<f64>::new().with_random_state(42);
4321 let fitted = model.fit(&x, &y).unwrap();
4322 assert_eq!(fitted.coefficients().len(), 2);
4323 }
4324
4325 #[test]
4326 fn test_sgd_regressor_partial_fit() {
4327 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4328 let y = array![2.0, 4.0, 6.0, 8.0];
4329
4330 let model = SGDRegressor::<f64>::new().with_random_state(42);
4331 let fitted = model.partial_fit(&x, &y).unwrap();
4332 let fitted = fitted.partial_fit(&x, &y).unwrap();
4333 let preds = fitted.predict(&x).unwrap();
4334 assert_eq!(preds.len(), 4);
4335 }
4336
4337 #[test]
4338 fn test_sgd_regressor_partial_fit_chain() {
4339 let x1 = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4340 let y1 = array![2.0, 4.0, 6.0];
4341 let x2 = Array2::from_shape_vec((3, 1), vec![4.0, 5.0, 6.0]).unwrap();
4342 let y2 = array![8.0, 10.0, 12.0];
4343
4344 let model = SGDRegressor::<f64>::new().with_random_state(42);
4345 let preds = model
4346 .partial_fit(&x1, &y1)
4347 .unwrap()
4348 .partial_fit(&x2, &y2)
4349 .unwrap()
4350 .predict(&x1)
4351 .unwrap();
4352 assert_eq!(preds.len(), 3);
4353 }
4354
4355 #[test]
4356 fn test_sgd_regressor_partial_fit_shape_mismatch() {
4357 let x = Array2::from_shape_vec((3, 2), vec![1.0, 1.0, 2.0, 2.0, 3.0, 3.0]).unwrap();
4358 let y = array![1.0, 2.0, 3.0];
4359 let model = SGDRegressor::<f64>::new().with_random_state(42);
4360 let fitted = model.partial_fit(&x, &y).unwrap();
4361
4362 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap();
4363 let y_bad = array![1.0, 2.0];
4364 assert!(fitted.partial_fit(&x_bad, &y_bad).is_err());
4365 }
4366
4367 #[test]
4368 fn test_sgd_regressor_huber_loss() {
4369 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4370 let y = array![2.0, 4.0, 6.0, 8.0];
4371
4372 let model = SGDRegressor::<f64>::new()
4373 .with_loss(RegressorLoss::Huber(1.35))
4374 .with_random_state(42)
4375 .with_max_iter(500);
4376 let fitted = model.fit(&x, &y).unwrap();
4377 assert_eq!(fitted.predict(&x).unwrap().len(), 4);
4378 }
4379
4380 #[test]
4381 fn test_sgd_regressor_epsilon_insensitive() {
4382 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4383 let y = array![2.0, 4.0, 6.0, 8.0];
4384
4385 let model = SGDRegressor::<f64>::new()
4386 .with_loss(RegressorLoss::EpsilonInsensitive(0.1))
4387 .with_random_state(42)
4388 .with_max_iter(500);
4389 let fitted = model.fit(&x, &y).unwrap();
4390 assert_eq!(fitted.predict(&x).unwrap().len(), 4);
4391 }
4392
4393 #[test]
4394 fn test_sgd_regressor_pipeline() {
4395 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4396 let y = Array1::from_vec(vec![2.0, 4.0, 6.0, 8.0]);
4397
4398 let model = SGDRegressor::<f64>::new().with_random_state(42);
4399 let fitted = model.fit_pipeline(&x, &y).unwrap();
4400 let preds = fitted.predict_pipeline(&x).unwrap();
4401 assert_eq!(preds.len(), 4);
4402 }
4403
4404 #[test]
4405 fn test_sgd_regressor_f32() {
4406 let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
4407 let y = Array1::from_vec(vec![2.0f32, 4.0, 6.0, 8.0]);
4408
4409 let model = SGDRegressor::<f32>::new().with_random_state(42);
4410 let fitted = model.fit(&x, &y).unwrap();
4411 assert_eq!(fitted.predict(&x).unwrap().len(), 4);
4412 }
4413
4414 #[test]
4415 fn test_sgd_regressor_empty_data() {
4416 let x = Array2::<f64>::zeros((0, 2));
4417 let y = Array1::<f64>::zeros(0);
4418 let model = SGDRegressor::<f64>::new();
4419 assert!(model.fit(&x, &y).is_err());
4420 }
4421
4422 #[test]
4423 fn test_sgd_classifier_empty_data() {
4424 let x = Array2::<f64>::zeros((0, 2));
4425 let y = Array1::<usize>::zeros(0);
4426 let clf = SGDClassifier::<f64>::new();
4427 assert!(clf.fit(&x, &y).is_err());
4428 }
4429
4430 #[test]
4431 fn test_sgd_classifier_default() {
4432 // sklearn `SGDClassifier()` defaults (live oracle / `:1242-1244`):
4433 // learning_rate='optimal', eta0=0.0, alpha=0.0001, power_t=0.5.
4434 let clf = SGDClassifier::<f64>::default();
4435 assert!(matches!(clf.learning_rate, LearningRateSchedule::Optimal));
4436 assert!((clf.eta0 - 0.0).abs() < 1e-12);
4437 assert!((clf.alpha - 0.0001).abs() < 1e-12);
4438 assert!((clf.power_t - 0.5).abs() < 1e-12);
4439 }
4440
4441 #[test]
4442 fn test_sgd_regressor_default() {
4443 let model = SGDRegressor::<f64>::default();
4444 assert!(model.eta0 > 0.0);
4445 assert!(model.alpha >= 0.0);
4446 }
4447}