Skip to main content

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}