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}