Skip to main content

ferrolearn_linear/
logistic_regression.rs

1//! Logistic regression classifier.
2//!
3//! This module provides [`LogisticRegression`], a linear classifier that uses
4//! the logistic (sigmoid) function for binary classification and softmax for
5//! multiclass classification. Parameters are estimated using a custom L-BFGS
6//! optimizer with Wolfe line search.
7//!
8//! The regularization parameter `C` is the inverse of regularization strength
9//! (matching scikit-learn's convention): smaller values specify stronger
10//! regularization.
11//!
12//! ## REQ status (per `.design/linear/logistic_regression.md`, mirrors `sklearn/linear_model/_logistic.py` @ 1.5.2)
13//!
14//! Mirrors `sklearn.linear_model.LogisticRegression` default path (`solver='lbfgs'`,
15//! `penalty='l2'`, `C=1.0`, multinomial for >2 classes). Objective `C·Σlogloss + ½||w||²`
16//! (intercept unpenalized). coef_/intercept_/predict_proba match the live oracle to ~1e-8 at
17//! convergence (the ~1e-3 gap at default tol is the LBFGS stopping bound, analog of #412).
18//!
19//! | REQ | Status | Evidence |
20//! |---|---|---|
21//! | REQ-1 (binary LBFGS L2 fit) | SHIPPED | `Fit for LogisticRegression` (LBFGS, C·Σlogloss+½‖w‖², intercept unpenalized); coef/intercept match oracle to 1e-8. Consumers: `RsLogisticRegression` (ferrolearn-python), `LogisticRegressionCV`. |
22//! | REQ-2 (multiclass multinomial softmax fit) | SHIPPED | softmax cross-entropy for ≥3 classes = sklearn lbfgs multinomial; predict_proba matches oracle to 5e-8. |
23//! | REQ-3 (predict argmax → original labels) | SHIPPED | returns `classes[idx]` (original label values; no #368 collapse). |
24//! | REQ-4 (predict_proba sigmoid/softmax, normalized) | SHIPPED | rows sum to 1; matches oracle. |
25//! | REQ-5 (decision_function values) | SHIPPED | values match oracle; the binary numpy `(n,)`-shape ABI is a ferrolearn-python binding concern (#454, binding ravels `(n,1)→(n,)`). |
26//! | REQ-6 (fit_intercept incl. false) | SHIPPED | intercept fixed at 0, unpenalized. |
27//! | REQ-7 (C regularization convention) | SHIPPED | C multiplies the loss (not the penalty); matches sklearn. |
28//! | REQ-8 (HasCoefficients/HasClasses) | SHIPPED | coef_ `(n_classes,n_features)`/`(1,n_features)` (no transpose). |
29//! | REQ-12 (class_weight) | SHIPPED | `ClassWeight` enum (`Balanced`/`Dict`) + `with_class_weight`; `effective_sample_weights` folds the per-class multiplier into the per-sample weight (`utils/class_weight.py:73` balanced formula, `:77-83` dict). Consumer: `RsLogisticRegression` (binding). Verified vs live oracle (balanced/dict, 2- and 3-class). |
30//! | REQ-17 (n_iter_) | SHIPPED | `FittedLogisticRegression::n_iter` (via `LbfgsOptimizer::minimize_reporting`) + getter `n_iter()`; positive int `<= max_iter`, deterministic (R-DEV-7: contract not literal sklearn count). Consumer: `RsLogisticRegression::n_iter_`. |
31//! | REQ-18 (sample_weight) | SHIPPED | `fit_with_sample_weight(x,y,Option<&Array1<F>>)` threads per-sample weights into logloss+grad in both branches; `Fit::fit` delegates `None` (byte-identical). Consumer: `RsLogisticRegression::fit`. Verified vs oracle (weighted coef/intercept, integer-weight≡row-dup). |
32//! | REQ-19 (random_state/n_jobs) | SHIPPED | `random_state`/`n_jobs` ctor fields + `with_random_state`/`with_n_jobs`; documented no-ops on the deterministic lbfgs path (R-DEV-7). Consumer: `RsLogisticRegression` get_params/clone parity. |
33//! | REQ-21 (non-finite input rejected) | SHIPPED | The shared fit entry `fit_with_sample_weight` (`Fit::fit` delegates with `None`) rejects any NaN/+/-inf in X or `sample_weight` BEFORE the LBFGS solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_logistic.py:1223`) + `_check_sample_weight` (`_logistic.py:303`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. `y` is `Array1<usize>` (integer labels), finite by type, so only X + sample_weight are checked. `.iter().any(|v| !v.is_finite())` catches NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `LogisticRegression().fit` raises `ValueError` for NaN/+inf/-inf in X and NaN/inf in sample_weight (`tests/divergence_linear_nonfinite_batch4.rs::logreg_*`). Non-test consumer: the existing `fit_with_sample_weight` / `RsLogisticRegression` consumers. (#2263) |
34//! | REQ-9..11,13..16,20 NOT-STARTED | penalty l1/elasticnet/none (#442), solver variants (#443), multi_class=ovr (#444), dual (#446), intercept_scaling (#447), l1_ratio (#448), warm_start (#449), ferray substrate (#453). |
35//!
36//! acto-critic: binary + multinomial coef/intercept/predict_proba match the live oracle to ~1e-8 at
37//! convergence; classes_ returns original labels; intercept unpenalized; C convention correct. The
38//! only divergence (#454, binary decision_function shape) is a binding-layer ABI item (goal.md:
39//! shape fixed at the boundary). Two states only per goal.md R-DEFER-2.
40//!
41//! # Examples
42//!
43//! ```
44//! use ferrolearn_linear::LogisticRegression;
45//! use ferrolearn_core::{Fit, Predict};
46//! use ndarray::{array, Array1, Array2};
47//!
48//! let model = LogisticRegression::<f64>::new();
49//! let x = Array2::from_shape_vec(
50//!     (6, 2),
51//!     vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0],
52//! ).unwrap();
53//! let y = array![0, 0, 0, 1, 1, 1];
54//!
55//! let fitted = model.fit(&x, &y).unwrap();
56//! let preds = fitted.predict(&x).unwrap();
57//! ```
58
59use ferrolearn_core::error::FerroError;
60use ferrolearn_core::introspection::{HasClasses, HasCoefficients};
61use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
62use ferrolearn_core::traits::{Fit, Predict};
63use ndarray::{Array1, Array2, Axis, ScalarOperand};
64use num_traits::{Float, FromPrimitive, ToPrimitive};
65
66use crate::optim::lbfgs::LbfgsOptimizer;
67
68/// Per-class weighting strategy, mirroring scikit-learn's `class_weight`
69/// parameter (`sklearn/linear_model/_logistic.py:1111`,
70/// `"class_weight": [dict, StrOptions({"balanced"}), None]`).
71///
72/// `None` (the absence of this enum) means uniform weights. The two non-uniform
73/// modes both produce a per-class multiplier that is folded into the effective
74/// per-sample weight `sample_weight[i] * class_weight[y[i]]`
75/// (`sklearn/linear_model/_logistic.py:312-313`).
76#[derive(Debug, Clone, PartialEq)]
77pub enum ClassWeight<F> {
78    /// `'balanced'`: per-class weight `n_samples / (n_classes * bincount[class])`
79    /// (`sklearn/utils/class_weight.py:73`,
80    /// `recip_freq = len(y) / (len(le.classes_) * np.bincount(y_ind))`).
81    Balanced,
82    /// A user-supplied `{class_label: weight}` map. Classes absent from the map
83    /// default to weight `1.0` (`sklearn/utils/class_weight.py:77-83`). Stored as
84    /// `(class_label, weight)` pairs to preserve the `F` generic.
85    Dict(Vec<(usize, F)>),
86}
87
88/// Logistic regression classifier.
89///
90/// Uses L-BFGS optimization to minimize the regularized logistic loss.
91/// Supports both binary and multiclass (multinomial) classification.
92///
93/// # Type Parameters
94///
95/// - `F`: The floating-point type (`f32` or `f64`).
96#[derive(Debug, Clone)]
97pub struct LogisticRegression<F> {
98    /// Inverse regularization strength. Smaller values specify stronger
99    /// regularization (matching scikit-learn's convention).
100    pub c: F,
101    /// Maximum number of L-BFGS iterations.
102    pub max_iter: usize,
103    /// Convergence tolerance for the optimizer.
104    pub tol: F,
105    /// Whether to fit an intercept (bias) term.
106    pub fit_intercept: bool,
107    /// Per-class weighting strategy. `None` => uniform weights (sklearn default,
108    /// `_logistic.py:1138` `class_weight=None`).
109    pub class_weight: Option<ClassWeight<F>>,
110    /// Random seed. On the lbfgs solver (the only path implemented) this is a
111    /// no-op: lbfgs is deterministic and consumes no RNG — `random_state` only
112    /// affects sag/saga/liblinear shuffling (sklearn `_logistic.py:1112`
113    /// `"random_state": ["random_state"]`). Stored for `get_params`/`clone`
114    /// parity.
115    pub random_state: Option<u64>,
116    /// Number of parallel jobs. A threading knob only; it never changes the
117    /// fitted result (sklearn `_logistic.py:1121` `"n_jobs": [None, Integral]`).
118    /// Stored for `get_params`/`clone` parity.
119    pub n_jobs: Option<i64>,
120}
121
122impl<F: Float> LogisticRegression<F> {
123    /// Create a new `LogisticRegression` with default settings.
124    ///
125    /// Defaults: `C = 1.0`, `max_iter = 1000`, `tol = 1e-4`,
126    /// `fit_intercept = true`.
127    #[must_use]
128    pub fn new() -> Self {
129        Self {
130            c: F::one(),
131            max_iter: 1000,
132            tol: F::from(1e-4).unwrap(),
133            fit_intercept: true,
134            class_weight: None,
135            random_state: None,
136            n_jobs: None,
137        }
138    }
139
140    /// Set the inverse regularization strength.
141    #[must_use]
142    pub fn with_c(mut self, c: F) -> Self {
143        self.c = c;
144        self
145    }
146
147    /// Set the maximum number of iterations.
148    #[must_use]
149    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
150        self.max_iter = max_iter;
151        self
152    }
153
154    /// Set the convergence tolerance.
155    #[must_use]
156    pub fn with_tol(mut self, tol: F) -> Self {
157        self.tol = tol;
158        self
159    }
160
161    /// Set whether to fit an intercept term.
162    #[must_use]
163    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
164        self.fit_intercept = fit_intercept;
165        self
166    }
167
168    /// Set the per-class weighting strategy (`'balanced'` or a `{class: weight}`
169    /// dict). Mirrors sklearn's `class_weight` constructor argument
170    /// (`_logistic.py:1138`).
171    #[must_use]
172    pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
173        self.class_weight = Some(class_weight);
174        self
175    }
176
177    /// Set the random seed. On the lbfgs solver this is a no-op (lbfgs is
178    /// deterministic); stored for API/`clone` parity with sklearn
179    /// (`_logistic.py:1139`).
180    #[must_use]
181    pub fn with_random_state(mut self, random_state: u64) -> Self {
182        self.random_state = Some(random_state);
183        self
184    }
185
186    /// Set the number of parallel jobs. A threading knob that never changes the
187    /// fitted result; stored for API/`clone` parity with sklearn
188    /// (`_logistic.py:1145`).
189    #[must_use]
190    pub fn with_n_jobs(mut self, n_jobs: i64) -> Self {
191        self.n_jobs = Some(n_jobs);
192        self
193    }
194}
195
196impl<F: Float> Default for LogisticRegression<F> {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202/// Fitted logistic regression classifier.
203///
204/// Stores the learned coefficients, intercept, and class labels.
205/// For binary classification, stores a single coefficient vector.
206/// For multiclass, stores one coefficient vector per class.
207#[derive(Debug, Clone)]
208pub struct FittedLogisticRegression<F> {
209    /// Learned coefficient vectors.
210    /// For binary: shape `(n_features,)` (single vector).
211    /// For multiclass: shape `(n_classes, n_features)`.
212    coefficients: Array1<F>,
213    /// Learned intercept for the primary class (binary).
214    intercept: F,
215    /// All coefficient vectors for multiclass, shape `(n_classes, n_features)`.
216    /// For binary, this has shape `(1, n_features)`.
217    weight_matrix: Array2<F>,
218    /// Intercept vector, one per class.
219    intercept_vec: Array1<F>,
220    /// Sorted unique class labels.
221    classes: Vec<usize>,
222    /// Whether this is a binary problem.
223    is_binary: bool,
224    /// Number of L-BFGS outer iterations actually performed during `fit`
225    /// (the analog of scipy's `OptimizeResult.nit` that sklearn stores in
226    /// `n_iter_`, `_logistic.py:1375-1376`). A positive integer `<= max_iter`.
227    n_iter: usize,
228}
229
230/// Sigmoid function: 1 / (1 + exp(-z)).
231fn sigmoid<F: Float>(z: F) -> F {
232    if z >= F::zero() {
233        F::one() / (F::one() + (-z).exp())
234    } else {
235        let ez = z.exp();
236        ez / (F::one() + ez)
237    }
238}
239
240impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<usize>>
241    for LogisticRegression<F>
242{
243    type Fitted = FittedLogisticRegression<F>;
244    type Error = FerroError;
245
246    /// Fit the logistic regression model using L-BFGS optimization.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in
251    /// `x` and `y` differ.
252    /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
253    /// Returns [`FerroError::InsufficientSamples`] if there are fewer
254    /// than 2 distinct classes.
255    fn fit(
256        &self,
257        x: &Array2<F>,
258        y: &Array1<usize>,
259    ) -> Result<FittedLogisticRegression<F>, FerroError> {
260        // `Fit::fit` is the unweighted entry point: delegate with no
261        // `sample_weight`, which is byte-identical to the pre-weighting
262        // implementation (the per-sample weight loop collapses to `w_i = 1`).
263        self.fit_with_sample_weight(x, y, None)
264    }
265}
266
267impl<F: Float + Send + Sync + ScalarOperand + 'static> LogisticRegression<F> {
268    /// Fit the model with optional per-sample weights.
269    ///
270    /// `sample_weight`, when supplied, weights each sample's contribution to the
271    /// log-loss and its gradient (mirroring sklearn's
272    /// `LogisticRegression.fit(X, y, sample_weight=...)`,
273    /// `_logistic.py:1165`). When `self.class_weight` is set, the per-class
274    /// multiplier is folded in so the effective per-sample weight is
275    /// `sample_weight[i] * class_weight[y[i]]`
276    /// (`_logistic.py:302-313`). `None` for `sample_weight` with `class_weight =
277    /// None` reproduces the unweighted fit exactly.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in `x`,
282    /// `y`, or `sample_weight` differ.
283    /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
284    /// (Negative `sample_weight` entries are accepted, matching sklearn 1.5.2.)
285    /// Returns [`FerroError::InsufficientSamples`] if there are fewer than 2
286    /// distinct classes.
287    pub fn fit_with_sample_weight(
288        &self,
289        x: &Array2<F>,
290        y: &Array1<usize>,
291        sample_weight: Option<&Array1<F>>,
292    ) -> Result<FittedLogisticRegression<F>, FerroError> {
293        let (n_samples, n_features) = x.dim();
294
295        if n_samples != y.len() {
296            return Err(FerroError::ShapeMismatch {
297                expected: vec![n_samples],
298                actual: vec![y.len()],
299                context: "y length must match number of samples in X".into(),
300            });
301        }
302
303        // sklearn 1.5.2 `LogisticRegression.fit` validates sample_weight via
304        // `_check_sample_weight` WITHOUT `only_non_negative=True`
305        // (`_logistic.py:303`), so negative weights are accepted and flow into
306        // the weighted logloss/gradient with a negative contribution. We match
307        // that: only the length is enforced, no non-negativity check (#2171).
308        if let Some(sw) = sample_weight
309            && sw.len() != n_samples
310        {
311            return Err(FerroError::ShapeMismatch {
312                expected: vec![n_samples],
313                actual: vec![sw.len()],
314                context: "sample_weight length must match number of samples in X".into(),
315            });
316        }
317
318        if self.c <= F::zero() {
319            return Err(FerroError::InvalidParameter {
320                name: "C".into(),
321                reason: "must be positive".into(),
322            });
323        }
324
325        if n_samples == 0 {
326            return Err(FerroError::InsufficientSamples {
327                required: 1,
328                actual: 0,
329                context: "LogisticRegression requires at least one sample".into(),
330            });
331        }
332
333        // Non-finite input validation (#2263). sklearn `LogisticRegression.fit`
334        // -> `self._validate_data(X, y, ...)` (`_logistic.py:1223`) keeps the
335        // default `force_all_finite=True`, so `check_array` rejects any NaN or
336        // +/-inf in X with a `ValueError("Input X contains NaN.")` /
337        // `"... contains infinity ..."` BEFORE the LBFGS solve. sklearn also
338        // validates `sample_weight` via `_check_sample_weight` (default
339        // `force_all_finite=True`, `_logistic.py:303`), raising on a non-finite
340        // weight. `y` is `Array1<usize>` here (integer class labels), so it is
341        // finite by construction — only X + sample_weight need the runtime
342        // check. `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf
343        // (bounds-safe, no panic, R-CODE-2). The finite path is byte-identical
344        // (the guard never fires on finite input). This is the shared fit entry
345        // (`Fit::fit` delegates here with `None`).
346        if x.iter().any(|v| !v.is_finite()) {
347            return Err(FerroError::InvalidParameter {
348                name: "X".into(),
349                reason: "Input X contains NaN or infinity.".into(),
350            });
351        }
352        if let Some(sw) = sample_weight
353            && sw.iter().any(|v| !v.is_finite())
354        {
355            return Err(FerroError::InvalidParameter {
356                name: "sample_weight".into(),
357                reason: "Input sample_weight contains NaN or infinity.".into(),
358            });
359        }
360
361        // Determine unique classes.
362        let mut classes: Vec<usize> = y.to_vec();
363        classes.sort_unstable();
364        classes.dedup();
365
366        if classes.len() < 2 {
367            return Err(FerroError::InsufficientSamples {
368                required: 2,
369                actual: classes.len(),
370                context: "LogisticRegression requires at least 2 distinct classes".into(),
371            });
372        }
373
374        // Build the effective per-sample weights `sw_i * class_weight[y_i]`
375        // (sklearn folds class_weight INTO sample_weight before the loss,
376        // `_logistic.py:312-313`). `None` is returned when both are absent, so
377        // the unweighted path stays byte-identical.
378        let effective = self.effective_sample_weights(y, &classes, sample_weight)?;
379
380        let n_classes = classes.len();
381
382        if n_classes == 2 {
383            self.fit_binary(x, y, n_samples, n_features, &classes, effective.as_ref())
384        } else {
385            self.fit_multinomial(x, y, n_samples, n_features, &classes, effective.as_ref())
386        }
387    }
388
389    /// Compose `sample_weight` with `class_weight` into the effective per-sample
390    /// weight vector `w_i = sample_weight[i] * class_weight[y[i]]`.
391    ///
392    /// Returns `None` when neither `sample_weight` nor `class_weight` is set
393    /// (the unweighted fast path). Mirrors sklearn's
394    /// `sample_weight *= class_weight_[le.fit_transform(y)]`
395    /// (`_logistic.py:313`) with `class_weight_` from
396    /// `compute_class_weight` (`utils/class_weight.py`).
397    fn effective_sample_weights(
398        &self,
399        y: &Array1<usize>,
400        classes: &[usize],
401        sample_weight: Option<&Array1<F>>,
402    ) -> Result<Option<Array1<F>>, FerroError> {
403        // Per-class multiplier indexed by position in `classes`.
404        let class_mult: Option<Vec<F>> = match &self.class_weight {
405            None => None,
406            Some(ClassWeight::Balanced) => {
407                // n_samples / (n_classes * bincount[class])
408                // (utils/class_weight.py:73).
409                let n_samples = y.len();
410                let n_classes = classes.len();
411                let mut counts = vec![0usize; n_classes];
412                for &label in y {
413                    if let Some(pos) = classes.iter().position(|&c| c == label) {
414                        counts[pos] += 1;
415                    }
416                }
417                let n_f = F::from(n_samples).ok_or_else(|| FerroError::NumericalInstability {
418                    message: "n_samples not representable in F".into(),
419                })?;
420                let nc_f = F::from(n_classes).ok_or_else(|| FerroError::NumericalInstability {
421                    message: "n_classes not representable in F".into(),
422                })?;
423                let mut mult = vec![F::one(); n_classes];
424                for (k, &cnt) in counts.iter().enumerate() {
425                    let cnt_f = F::from(cnt).ok_or_else(|| FerroError::NumericalInstability {
426                        message: "class count not representable in F".into(),
427                    })?;
428                    if cnt_f > F::zero() {
429                        mult[k] = n_f / (nc_f * cnt_f);
430                    }
431                }
432                Some(mult)
433            }
434            Some(ClassWeight::Dict(map)) => {
435                // Classes absent from the map default to 1.0
436                // (utils/class_weight.py:77-83).
437                let mut mult = vec![F::one(); classes.len()];
438                for (k, &cls) in classes.iter().enumerate() {
439                    if let Some(&(_, w)) = map.iter().find(|&&(c, _)| c == cls) {
440                        mult[k] = w;
441                    }
442                }
443                Some(mult)
444            }
445        };
446
447        match (sample_weight, class_mult) {
448            (None, None) => Ok(None),
449            (Some(sw), None) => Ok(Some(sw.clone())),
450            (None, Some(mult)) => {
451                let eff = y.mapv(|label| {
452                    classes
453                        .iter()
454                        .position(|&c| c == label)
455                        .map_or(F::one(), |pos| mult[pos])
456                });
457                Ok(Some(eff))
458            }
459            (Some(sw), Some(mult)) => {
460                let eff = Array1::from_shape_fn(sw.len(), |i| {
461                    let m = classes
462                        .iter()
463                        .position(|&c| c == y[i])
464                        .map_or(F::one(), |pos| mult[pos]);
465                    sw[i] * m
466                });
467                Ok(Some(eff))
468            }
469        }
470    }
471
472    /// Fit binary logistic regression.
473    fn fit_binary(
474        &self,
475        x: &Array2<F>,
476        y: &Array1<usize>,
477        n_samples: usize,
478        n_features: usize,
479        classes: &[usize],
480        sample_weight: Option<&Array1<F>>,
481    ) -> Result<FittedLogisticRegression<F>, FerroError> {
482        let n_f = F::from(n_samples).unwrap();
483        let reg = F::one() / self.c;
484
485        // Convert labels to 0/1 float.
486        let y_binary: Array1<F> = y.mapv(|label| {
487            if label == classes[1] {
488                F::one()
489            } else {
490                F::zero()
491            }
492        });
493
494        // Materialize the per-sample weights (effective sample_weight*class_weight
495        // already composed upstream); `None` => unit weights, the unweighted path.
496        let sw: Array1<F> =
497            sample_weight.map_or_else(|| Array1::from_elem(n_samples, F::one()), Clone::clone);
498
499        // Parameter vector: [w_0, w_1, ..., w_{n_features-1}, (intercept)]
500        let n_params = if self.fit_intercept {
501            n_features + 1
502        } else {
503            n_features
504        };
505
506        let objective = |params: &Array1<F>| -> (F, Array1<F>) {
507            let w = params.slice(ndarray::s![..n_features]);
508            let b = if self.fit_intercept {
509                params[n_features]
510            } else {
511                F::zero()
512            };
513
514            // Compute logits: X @ w + b
515            let logits = x.dot(&w.to_owned()) + b;
516
517            // Compute loss and gradient.
518            let mut loss = F::zero();
519            let mut grad_w = Array1::<F>::zeros(n_features);
520            let mut grad_b = F::zero();
521
522            for i in 0..n_samples {
523                let p = sigmoid(logits[i]);
524                let yi = y_binary[i];
525                let wi = sw[i];
526
527                // Binary cross-entropy loss (negative log-likelihood).
528                let eps = F::from(1e-15).unwrap();
529                let p_clipped = p.max(eps).min(F::one() - eps);
530                // Per-sample weighting: each pointwise loss term scaled by w_i
531                // (sklearn folds sample_weight*class_weight into the loss,
532                // `_logistic.py:302-313`, `:451`).
533                loss = loss
534                    - wi * (yi * p_clipped.ln() + (F::one() - yi) * (F::one() - p_clipped).ln());
535
536                // Gradient (each sample's contribution scaled by w_i).
537                let diff = wi * (p - yi);
538                let xi = x.row(i);
539                for j in 0..n_features {
540                    grad_w[j] = grad_w[j] + diff * xi[j];
541                }
542                if self.fit_intercept {
543                    grad_b = grad_b + diff;
544                }
545            }
546
547            // sklearn convention: J(w) = C * sum_i log_loss_i + 0.5 * ||w||^2.
548            // We minimise the equivalent objective scaled by 1/C:
549            //   J(w) / C = sum_i log_loss_i + (1/(2C)) * ||w||^2
550            // which is what we accumulate here (loss = sum, NOT mean).
551            // Previously ferrolearn divided by n which made the effective
552            // regularisation `n×` stronger than sklearn's at the same C (#334).
553            let _ = n_f; // intentionally unused — kept for compile-symmetry
554
555            // L2 regularization (on weights only, not intercept).
556            let reg_loss: F = w.iter().fold(F::zero(), |acc, &wi| acc + wi * wi);
557            loss = loss + reg / (F::from(2.0).unwrap()) * reg_loss;
558
559            for j in 0..n_features {
560                grad_w[j] = grad_w[j] + reg * w[j];
561            }
562
563            let mut grad = Array1::<F>::zeros(n_params);
564            for j in 0..n_features {
565                grad[j] = grad_w[j];
566            }
567            if self.fit_intercept {
568                grad[n_features] = grad_b;
569            }
570
571            (loss, grad)
572        };
573
574        let optimizer = LbfgsOptimizer::new(self.max_iter, self.tol);
575        let x0 = Array1::<F>::zeros(n_params);
576        // `minimize_reporting` returns the L-BFGS outer-iteration count (scipy
577        // `OptimizeResult.nit` analog) that sklearn stores in `n_iter_`
578        // (`_logistic.py:1375-1376`), without changing `minimize`'s behavior.
579        let (params, n_iter) = optimizer.minimize_reporting(objective, x0)?;
580
581        let coefficients = params.slice(ndarray::s![..n_features]).to_owned();
582        let intercept = if self.fit_intercept {
583            params[n_features]
584        } else {
585            F::zero()
586        };
587
588        let weight_matrix = coefficients
589            .clone()
590            .into_shape_with_order((1, n_features))
591            .map_err(|_| FerroError::NumericalInstability {
592                message: "failed to reshape coefficients".into(),
593            })?;
594
595        Ok(FittedLogisticRegression {
596            coefficients,
597            intercept,
598            weight_matrix,
599            intercept_vec: Array1::from_vec(vec![intercept]),
600            classes: classes.to_vec(),
601            is_binary: true,
602            n_iter,
603        })
604    }
605
606    /// Fit multinomial logistic regression.
607    fn fit_multinomial(
608        &self,
609        x: &Array2<F>,
610        y: &Array1<usize>,
611        n_samples: usize,
612        n_features: usize,
613        classes: &[usize],
614        sample_weight: Option<&Array1<F>>,
615    ) -> Result<FittedLogisticRegression<F>, FerroError> {
616        let n_classes = classes.len();
617        let n_f = F::from(n_samples).unwrap();
618        let reg = F::one() / self.c;
619
620        // Create class index map.
621        let class_indices: Vec<usize> = y
622            .iter()
623            .map(|&label| classes.iter().position(|&c| c == label).unwrap())
624            .collect();
625
626        // One-hot encode targets.
627        let mut y_onehot = Array2::<F>::zeros((n_samples, n_classes));
628        for (i, &ci) in class_indices.iter().enumerate() {
629            y_onehot[[i, ci]] = F::one();
630        }
631
632        // Effective per-sample weights (sample_weight*class_weight already
633        // composed upstream); `None` => unit weights.
634        let sw: Array1<F> =
635            sample_weight.map_or_else(|| Array1::from_elem(n_samples, F::one()), Clone::clone);
636
637        // Parameter vector: flattened [W (n_classes x n_features), b (n_classes)]
638        let n_weight_params = n_classes * n_features;
639        let n_params = if self.fit_intercept {
640            n_weight_params + n_classes
641        } else {
642            n_weight_params
643        };
644
645        let fit_intercept = self.fit_intercept;
646
647        let objective = move |params: &Array1<F>| -> (F, Array1<F>) {
648            // Extract weight matrix W (n_classes x n_features).
649            let mut w_mat = Array2::<F>::zeros((n_classes, n_features));
650            for c in 0..n_classes {
651                for j in 0..n_features {
652                    w_mat[[c, j]] = params[c * n_features + j];
653                }
654            }
655
656            let b_vec: Array1<F> = if fit_intercept {
657                Array1::from_shape_fn(n_classes, |c| params[n_weight_params + c])
658            } else {
659                Array1::zeros(n_classes)
660            };
661
662            // Compute logits: X @ W^T + b^T, shape (n_samples, n_classes).
663            let logits = x.dot(&w_mat.t()) + &b_vec;
664
665            // Softmax probabilities.
666            let probs = softmax_2d(&logits);
667
668            // Multinomial cross-entropy loss (sklearn convention: sum, not
669            // mean — see #334 for the binary-branch counterpart and the
670            // associated J(w) = C * sum_i loss_i + 0.5 * ||w||^2 contract).
671            let mut loss = F::zero();
672            let eps = F::from(1e-15).unwrap();
673            for i in 0..n_samples {
674                let wi = sw[i];
675                for c in 0..n_classes {
676                    let p = probs[[i, c]].max(eps);
677                    // Per-sample weighting of each cross-entropy term
678                    // (`_logistic.py:302-313`, `:451`).
679                    loss = loss - wi * y_onehot[[i, c]] * p.ln();
680                }
681            }
682            let _ = n_f; // n_f intentionally unused since we don't divide
683
684            // L2 regularization.
685            let reg_loss: F = w_mat.iter().fold(F::zero(), |acc, &wi| acc + wi * wi);
686            loss = loss + reg / F::from(2.0).unwrap() * reg_loss;
687
688            // Gradient (sum form to match sklearn's loss scaling). Each sample
689            // row of `diff` is scaled by its weight w_i, so the weighted loss's
690            // gradient is `sum_i w_i (p_i - y_i) x_i` (`_logistic.py:302-313`).
691            let mut diff = &probs - &y_onehot;
692            for i in 0..n_samples {
693                let wi = sw[i];
694                for c in 0..n_classes {
695                    diff[[i, c]] = diff[[i, c]] * wi;
696                }
697            }
698            let grad_w = diff.t().dot(x);
699
700            let mut grad = Array1::<F>::zeros(n_params);
701            for c in 0..n_classes {
702                for j in 0..n_features {
703                    grad[c * n_features + j] = grad_w[[c, j]] + reg * w_mat[[c, j]];
704                }
705            }
706
707            if fit_intercept {
708                // grad_b = sum(diff, axis=0)
709                let grad_b = diff.sum_axis(Axis(0));
710                for c in 0..n_classes {
711                    grad[n_weight_params + c] = grad_b[c];
712                }
713            }
714
715            (loss, grad)
716        };
717
718        let optimizer = LbfgsOptimizer::new(self.max_iter, self.tol);
719        let x0 = Array1::<F>::zeros(n_params);
720        // Capture the L-BFGS iteration count (scipy `nit` analog) for `n_iter_`
721        // (`_logistic.py:1375-1376`).
722        let (params, n_iter) = optimizer.minimize_reporting(objective, x0)?;
723
724        // Extract results.
725        let mut weight_matrix = Array2::<F>::zeros((n_classes, n_features));
726        for c in 0..n_classes {
727            for j in 0..n_features {
728                weight_matrix[[c, j]] = params[c * n_features + j];
729            }
730        }
731
732        let intercept_vec = if self.fit_intercept {
733            Array1::from_shape_fn(n_classes, |c| params[n_weight_params + c])
734        } else {
735            Array1::zeros(n_classes)
736        };
737
738        // For HasCoefficients, store the first class coefficients.
739        let coefficients = weight_matrix.row(0).to_owned();
740        let intercept = intercept_vec[0];
741
742        Ok(FittedLogisticRegression {
743            coefficients,
744            intercept,
745            weight_matrix,
746            intercept_vec,
747            classes: classes.to_vec(),
748            is_binary: false,
749            n_iter,
750        })
751    }
752}
753
754/// Compute softmax probabilities row-wise for a 2D array.
755fn softmax_2d<F: Float>(logits: &Array2<F>) -> Array2<F> {
756    let n_rows = logits.nrows();
757    let n_cols = logits.ncols();
758    let mut probs = Array2::<F>::zeros((n_rows, n_cols));
759
760    for i in 0..n_rows {
761        // Numerical stability: subtract max.
762        let max_logit = logits
763            .row(i)
764            .iter()
765            .fold(F::neg_infinity(), |a, &b| a.max(b));
766
767        let mut sum = F::zero();
768        for j in 0..n_cols {
769            let exp_val = (logits[[i, j]] - max_logit).exp();
770            probs[[i, j]] = exp_val;
771            sum = sum + exp_val;
772        }
773
774        if sum > F::zero() {
775            for j in 0..n_cols {
776                probs[[i, j]] = probs[[i, j]] / sum;
777            }
778        }
779    }
780
781    probs
782}
783
784impl<F: Float + Send + Sync + ScalarOperand + 'static> FittedLogisticRegression<F> {
785    /// Returns a reference to the full weight matrix.
786    ///
787    /// For binary classification, shape is `(1, n_features)`.
788    /// For multiclass, shape is `(n_classes, n_features)`.
789    #[must_use]
790    pub fn weight_matrix(&self) -> &Array2<F> {
791        &self.weight_matrix
792    }
793
794    /// Returns a reference to the intercept vector (one per class).
795    #[must_use]
796    pub fn intercept_vec(&self) -> &Array1<F> {
797        &self.intercept_vec
798    }
799
800    /// Returns whether this is a binary classification model.
801    #[must_use]
802    pub fn is_binary(&self) -> bool {
803        self.is_binary
804    }
805
806    /// Number of L-BFGS iterations performed during `fit` (sklearn `n_iter_`,
807    /// `_logistic.py:1376`). For the binary and multinomial lbfgs paths sklearn
808    /// reports a single count (its `n_iter_` is shape `(1,)`); this scalar IS
809    /// that single count. A positive integer `<= max_iter`.
810    ///
811    /// NOTE (R-DEV-7): ferrolearn's L-BFGS is not scipy's, so the exact count
812    /// differs from sklearn's — the CONTRACT (positive, deterministic,
813    /// `<= max_iter`) is matched, not the literal value.
814    #[must_use]
815    pub fn n_iter(&self) -> usize {
816        self.n_iter
817    }
818
819    /// Predict class probabilities for the given feature matrix.
820    ///
821    /// For binary classification, returns an array of shape `(n_samples, 2)`.
822    /// For multiclass, returns shape `(n_samples, n_classes)`.
823    ///
824    /// # Errors
825    ///
826    /// Returns [`FerroError::ShapeMismatch`] if the number of features
827    /// does not match the fitted model.
828    pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
829        let n_features = x.ncols();
830        let expected_features = self.weight_matrix.ncols();
831
832        if n_features != expected_features {
833            return Err(FerroError::ShapeMismatch {
834                expected: vec![expected_features],
835                actual: vec![n_features],
836                context: "number of features must match fitted model".into(),
837            });
838        }
839
840        if self.is_binary {
841            let logits = x.dot(&self.coefficients) + self.intercept;
842            let n_samples = x.nrows();
843            let mut probs = Array2::<F>::zeros((n_samples, 2));
844            for i in 0..n_samples {
845                let p1 = sigmoid(logits[i]);
846                probs[[i, 0]] = F::one() - p1;
847                probs[[i, 1]] = p1;
848            }
849            Ok(probs)
850        } else {
851            let logits = x.dot(&self.weight_matrix.t()) + &self.intercept_vec;
852            Ok(softmax_2d(&logits))
853        }
854    }
855
856    /// Element-wise log of [`predict_proba`](Self::predict_proba). Mirrors
857    /// sklearn `LogisticRegression.predict_log_proba`.
858    ///
859    /// # Errors
860    ///
861    /// Forwards any error from [`predict_proba`](Self::predict_proba).
862    pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
863        let proba = self.predict_proba(x)?;
864        Ok(crate::log_proba(&proba))
865    }
866
867    /// Raw signed distance from the decision boundary (binary) or per-class
868    /// scores (multiclass). Mirrors sklearn
869    /// `LogisticRegression.decision_function`.
870    ///
871    /// Binary: shape `(n_samples, 1)` containing `X @ coef + intercept`.
872    /// Multiclass: shape `(n_samples, n_classes)` containing the raw
873    /// pre-softmax scores. (sklearn returns `(n_samples,)` for binary;
874    /// ferrolearn keeps a 2-D shape for type uniformity, matching the
875    /// tree-crate convention.)
876    ///
877    /// # Errors
878    ///
879    /// Returns [`FerroError::ShapeMismatch`] if the number of features
880    /// does not match the fitted model.
881    pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
882        let n_features = x.ncols();
883        let expected = self.weight_matrix.ncols();
884        if n_features != expected {
885            return Err(FerroError::ShapeMismatch {
886                expected: vec![expected],
887                actual: vec![n_features],
888                context: "number of features must match fitted model".into(),
889            });
890        }
891        if self.is_binary {
892            let logits = x.dot(&self.coefficients) + self.intercept;
893            let n = logits.len();
894            let mut out = Array2::<F>::zeros((n, 1));
895            for i in 0..n {
896                out[[i, 0]] = logits[i];
897            }
898            Ok(out)
899        } else {
900            Ok(x.dot(&self.weight_matrix.t()) + &self.intercept_vec)
901        }
902    }
903}
904
905impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
906    for FittedLogisticRegression<F>
907{
908    type Output = Array1<usize>;
909    type Error = FerroError;
910
911    /// Predict class labels for the given feature matrix.
912    ///
913    /// Returns the class with the highest predicted probability.
914    ///
915    /// # Errors
916    ///
917    /// Returns [`FerroError::ShapeMismatch`] if the number of features
918    /// does not match the fitted model.
919    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
920        let proba = self.predict_proba(x)?;
921        let n_samples = proba.nrows();
922        let n_classes = proba.ncols();
923
924        let mut predictions = Array1::<usize>::zeros(n_samples);
925        for i in 0..n_samples {
926            let mut best_class = 0;
927            let mut best_prob = proba[[i, 0]];
928            for c in 1..n_classes {
929                if proba[[i, c]] > best_prob {
930                    best_prob = proba[[i, c]];
931                    best_class = c;
932                }
933            }
934            predictions[i] = self.classes[best_class];
935        }
936
937        Ok(predictions)
938    }
939}
940
941impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
942    for FittedLogisticRegression<F>
943{
944    fn coefficients(&self) -> &Array1<F> {
945        &self.coefficients
946    }
947
948    fn intercept(&self) -> F {
949        self.intercept
950    }
951}
952
953impl<F: Float + Send + Sync + ScalarOperand + 'static> HasClasses for FittedLogisticRegression<F> {
954    fn classes(&self) -> &[usize] {
955        &self.classes
956    }
957
958    fn n_classes(&self) -> usize {
959        self.classes.len()
960    }
961}
962
963// Pipeline integration.
964impl<F> PipelineEstimator<F> for LogisticRegression<F>
965where
966    F: Float + ToPrimitive + FromPrimitive + ScalarOperand + Send + Sync + 'static,
967{
968    fn fit_pipeline(
969        &self,
970        x: &Array2<F>,
971        y: &Array1<F>,
972    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
973        // Convert f64 labels to usize.
974        let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
975        let fitted = self.fit(x, &y_usize)?;
976        Ok(Box::new(FittedLogisticRegressionPipeline(fitted)))
977    }
978}
979
980/// Wrapper for pipeline integration that converts predictions to float.
981struct FittedLogisticRegressionPipeline<F>(FittedLogisticRegression<F>)
982where
983    F: Float + Send + Sync + 'static;
984
985// Safety: the inner type is Send + Sync.
986unsafe impl<F: Float + Send + Sync + 'static> Send for FittedLogisticRegressionPipeline<F> {}
987unsafe impl<F: Float + Send + Sync + 'static> Sync for FittedLogisticRegressionPipeline<F> {}
988
989impl<F> FittedPipelineEstimator<F> for FittedLogisticRegressionPipeline<F>
990where
991    F: Float + ToPrimitive + FromPrimitive + ScalarOperand + Send + Sync + 'static,
992{
993    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
994        let preds = self.0.predict(x)?;
995        Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use approx::assert_relative_eq;
1003    use ndarray::array;
1004
1005    #[test]
1006    fn test_sigmoid() {
1007        assert_relative_eq!(sigmoid(0.0_f64), 0.5, epsilon = 1e-10);
1008        assert!(sigmoid(10.0_f64) > 0.99);
1009        assert!(sigmoid(-10.0_f64) < 0.01);
1010        // Check symmetry.
1011        assert_relative_eq!(sigmoid(1.0_f64) + sigmoid(-1.0_f64), 1.0, epsilon = 1e-10);
1012    }
1013
1014    #[test]
1015    fn test_binary_classification() {
1016        // Linearly separable binary data.
1017        let x = Array2::from_shape_vec(
1018            (8, 2),
1019            vec![
1020                1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, // class 0
1021                5.0, 5.0, 5.0, 6.0, 6.0, 5.0, 6.0, 6.0, // class 1
1022            ],
1023        )
1024        .unwrap();
1025        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1026
1027        let model = LogisticRegression::<f64>::new()
1028            .with_c(1.0)
1029            .with_max_iter(1000);
1030        let fitted = model.fit(&x, &y).unwrap();
1031
1032        let preds = fitted.predict(&x).unwrap();
1033
1034        // At minimum, most samples should be correctly classified.
1035        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1036        assert!(correct >= 6, "expected at least 6 correct, got {correct}");
1037    }
1038
1039    #[test]
1040    fn test_binary_predict_proba() {
1041        let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
1042        let y = array![0, 0, 0, 1, 1, 1];
1043
1044        let model = LogisticRegression::<f64>::new().with_c(1.0);
1045        let fitted = model.fit(&x, &y).unwrap();
1046
1047        let proba = fitted.predict_proba(&x).unwrap();
1048
1049        // Probabilities should sum to 1.
1050        for i in 0..proba.nrows() {
1051            assert_relative_eq!(proba.row(i).sum(), 1.0, epsilon = 1e-10);
1052        }
1053
1054        // Class 0 should have higher probability for negative x.
1055        assert!(proba[[0, 0]] > proba[[0, 1]]);
1056        // Class 1 should have higher probability for positive x.
1057        assert!(proba[[5, 1]] > proba[[5, 0]]);
1058    }
1059
1060    #[test]
1061    fn test_multiclass_classification() {
1062        // Three linearly separable clusters.
1063        let x = Array2::from_shape_vec(
1064            (9, 2),
1065            vec![
1066                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, // class 0
1067                5.0, 0.0, 5.5, 0.0, 5.0, 0.5, // class 1
1068                0.0, 5.0, 0.5, 5.0, 0.0, 5.5, // class 2
1069            ],
1070        )
1071        .unwrap();
1072        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1073
1074        let model = LogisticRegression::<f64>::new()
1075            .with_c(10.0)
1076            .with_max_iter(2000);
1077        let fitted = model.fit(&x, &y).unwrap();
1078
1079        assert_eq!(fitted.n_classes(), 3);
1080        assert_eq!(fitted.classes(), &[0, 1, 2]);
1081
1082        let preds = fitted.predict(&x).unwrap();
1083        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1084        assert!(correct >= 7, "expected at least 7 correct, got {correct}");
1085    }
1086
1087    #[test]
1088    fn test_multiclass_predict_proba() {
1089        let x = Array2::from_shape_vec(
1090            (9, 2),
1091            vec![
1092                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,
1093                0.0, 5.5,
1094            ],
1095        )
1096        .unwrap();
1097        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1098
1099        let model = LogisticRegression::<f64>::new()
1100            .with_c(10.0)
1101            .with_max_iter(2000);
1102        let fitted = model.fit(&x, &y).unwrap();
1103        let proba = fitted.predict_proba(&x).unwrap();
1104
1105        // Probabilities should sum to 1 for each sample.
1106        for i in 0..proba.nrows() {
1107            assert_relative_eq!(proba.row(i).sum(), 1.0, epsilon = 1e-10);
1108        }
1109    }
1110
1111    #[test]
1112    fn test_shape_mismatch_fit() {
1113        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1114        let y = array![0, 1]; // Wrong length
1115
1116        let model = LogisticRegression::<f64>::new();
1117        assert!(model.fit(&x, &y).is_err());
1118    }
1119
1120    #[test]
1121    fn test_invalid_c() {
1122        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1123        let y = array![0, 0, 1, 1];
1124
1125        let model = LogisticRegression::<f64>::new().with_c(0.0);
1126        assert!(model.fit(&x, &y).is_err());
1127
1128        let model_neg = LogisticRegression::<f64>::new().with_c(-1.0);
1129        assert!(model_neg.fit(&x, &y).is_err());
1130    }
1131
1132    #[test]
1133    fn test_single_class_error() {
1134        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1135        let y = array![0, 0, 0]; // Only one class
1136
1137        let model = LogisticRegression::<f64>::new();
1138        assert!(model.fit(&x, &y).is_err());
1139    }
1140
1141    #[test]
1142    fn test_has_coefficients() {
1143        let x = Array2::from_shape_vec(
1144            (6, 2),
1145            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 5.0, 5.0, 5.0, 6.0, 6.0, 5.0],
1146        )
1147        .unwrap();
1148        let y = array![0, 0, 0, 1, 1, 1];
1149
1150        let model = LogisticRegression::<f64>::new();
1151        let fitted = model.fit(&x, &y).unwrap();
1152
1153        assert_eq!(fitted.coefficients().len(), 2);
1154    }
1155
1156    #[test]
1157    fn test_has_classes() {
1158        let x = Array2::from_shape_vec((6, 1), vec![-2.0, -1.0, -0.5, 0.5, 1.0, 2.0]).unwrap();
1159        let y = array![0, 0, 0, 1, 1, 1];
1160
1161        let model = LogisticRegression::<f64>::new();
1162        let fitted = model.fit(&x, &y).unwrap();
1163
1164        assert_eq!(fitted.classes(), &[0, 1]);
1165        assert_eq!(fitted.n_classes(), 2);
1166    }
1167
1168    #[test]
1169    fn test_pipeline_integration() {
1170        let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
1171        let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
1172
1173        let model = LogisticRegression::<f64>::new();
1174        let fitted = model.fit_pipeline(&x, &y).unwrap();
1175        let preds = fitted.predict_pipeline(&x).unwrap();
1176        assert_eq!(preds.len(), 6);
1177    }
1178
1179    #[test]
1180    fn test_no_intercept() {
1181        let x = Array2::from_shape_vec((6, 1), vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]).unwrap();
1182        let y = array![0, 0, 0, 1, 1, 1];
1183
1184        let model = LogisticRegression::<f64>::new().with_fit_intercept(false);
1185        let fitted = model.fit(&x, &y).unwrap();
1186        assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
1187    }
1188
1189    #[test]
1190    fn test_softmax_2d() {
1191        let logits = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 1.0, 1.0, 1.0]).unwrap();
1192        let probs = softmax_2d(&logits);
1193
1194        // Each row should sum to 1.
1195        assert_relative_eq!(probs.row(0).sum(), 1.0, epsilon = 1e-10);
1196        assert_relative_eq!(probs.row(1).sum(), 1.0, epsilon = 1e-10);
1197
1198        // Uniform logits should give uniform probs.
1199        assert_relative_eq!(probs[[1, 0]], 1.0 / 3.0, epsilon = 1e-10);
1200        assert_relative_eq!(probs[[1, 1]], 1.0 / 3.0, epsilon = 1e-10);
1201        assert_relative_eq!(probs[[1, 2]], 1.0 / 3.0, epsilon = 1e-10);
1202    }
1203}