dml_core/learner/
logistic.rs1use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
2
3use super::Classifier;
4use crate::error::{DmlError, Result};
5use crate::linalg::cholesky_solve;
6
7#[derive(Debug, Clone)]
13pub struct LogisticRegression {
14 c: f64,
15 tol: f64,
16 max_iter: usize,
17 coef: Option<Array1<f64>>,
18 intercept: f64,
19}
20
21impl Default for LogisticRegression {
22 fn default() -> Self {
23 Self::new(1.0)
24 }
25}
26
27impl LogisticRegression {
28 pub fn new(c: f64) -> Self {
30 Self {
31 c,
32 tol: 1e-10,
33 max_iter: 100,
34 coef: None,
35 intercept: 0.0,
36 }
37 }
38
39 pub fn unpenalized() -> Self {
41 Self::new(f64::INFINITY)
42 }
43
44 pub fn tol(mut self, tol: f64) -> Self {
45 self.tol = tol;
46 self
47 }
48
49 pub fn max_iter(mut self, max_iter: usize) -> Self {
50 self.max_iter = max_iter;
51 self
52 }
53
54 pub fn coef(&self) -> Option<&Array1<f64>> {
55 self.coef.as_ref()
56 }
57
58 pub fn intercept(&self) -> f64 {
59 self.intercept
60 }
61}
62
63fn softplus(eta: f64) -> f64 {
65 if eta > 0.0 {
66 eta + (-eta).exp().ln_1p()
67 } else {
68 eta.exp().ln_1p()
69 }
70}
71
72fn sigmoid(eta: f64) -> f64 {
73 if eta >= 0.0 {
74 1.0 / (1.0 + (-eta).exp())
75 } else {
76 let e = eta.exp();
77 e / (1.0 + e)
78 }
79}
80
81impl Classifier for LogisticRegression {
82 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
83 let (n, p) = x.dim();
84 if y.len() != n {
85 return Err(DmlError::DimensionMismatch(
86 "LogisticRegression::fit x/y rows".into(),
87 ));
88 }
89 if !y.iter().all(|&v| v == 0.0 || v == 1.0) {
90 return Err(DmlError::InvalidArgument(
91 "LogisticRegression labels must be 0 or 1".into(),
92 ));
93 }
94 let n1 = y.sum();
95 if n1 == 0.0 || n1 == n as f64 {
96 return Err(DmlError::InvalidArgument(
97 "LogisticRegression needs both classes in the training data".into(),
98 ));
99 }
100 let lambda = if self.c.is_finite() {
101 1.0 / self.c
102 } else {
103 0.0
104 };
105 let xa = concatenate![Axis(1), Array2::ones((n, 1)), x];
107 let objective = |beta: &Array1<f64>| -> f64 {
108 let eta = xa.dot(beta);
109 let loss: f64 = eta
110 .iter()
111 .zip(y.iter())
112 .map(|(&e, &t)| softplus(e) - t * e)
113 .sum();
114 loss + 0.5
115 * lambda
116 * beta
117 .slice(ndarray::s![1..])
118 .dot(&beta.slice(ndarray::s![1..]))
119 };
120
121 let mean = n1 / n as f64;
122 let mut beta = Array1::<f64>::zeros(p + 1);
123 beta[0] = (mean / (1.0 - mean)).ln();
124 let mut obj = objective(&beta);
125 let mut converged = false;
126 for _ in 0..self.max_iter {
127 let prob = xa.dot(&beta).mapv(sigmoid);
128 let mut grad = xa.t().dot(&(&prob - &y));
129 let w = prob.mapv(|q| q * (1.0 - q));
130 let mut hess = xa.t().dot(&(&xa * &w.insert_axis(Axis(1))));
131 for j in 1..=p {
132 grad[j] += lambda * beta[j];
133 hess[[j, j]] += lambda;
134 }
135 let step = cholesky_solve(&hess, &grad)?;
136 let mut t = 1.0;
138 let mut candidate = &beta - &step;
139 let mut cand_obj = objective(&candidate);
140 while cand_obj > obj && t > 1e-10 {
141 t *= 0.5;
142 candidate = &beta - &(t * &step);
143 cand_obj = objective(&candidate);
144 }
145 let change = (t * &step).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
146 beta = candidate;
147 obj = cand_obj;
148 if change <= self.tol * beta.iter().fold(1.0_f64, |m, v| m.max(v.abs())) {
149 converged = true;
150 break;
151 }
152 }
153 if !converged || !beta.iter().all(|v| v.is_finite()) {
154 return Err(DmlError::Convergence(format!(
155 "LogisticRegression did not converge in {} Newton steps (separable classes?)",
156 self.max_iter
157 )));
158 }
159 self.intercept = beta[0];
160 self.coef = Some(beta.slice(ndarray::s![1..]).to_owned());
161 Ok(())
162 }
163
164 fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
165 let coef = self.coef.as_ref().ok_or(DmlError::NotFitted)?;
166 if x.ncols() != coef.len() {
167 return Err(DmlError::DimensionMismatch(
168 "LogisticRegression::predict_proba feature count".into(),
169 ));
170 }
171 Ok((x.dot(coef) + self.intercept).mapv(sigmoid))
172 }
173
174 fn fresh(&self) -> Box<dyn Classifier> {
175 Box::new(Self::new(self.c).tol(self.tol).max_iter(self.max_iter))
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use ndarray::array;
183
184 #[test]
185 fn gradient_is_zero_at_optimum() {
186 let x = array![
187 [0.0, 1.0],
188 [1.0, 0.5],
189 [2.0, -1.0],
190 [3.0, 0.0],
191 [1.5, 2.0],
192 [0.5, -0.5]
193 ];
194 let y = array![0.0, 0.0, 1.0, 1.0, 0.0, 1.0];
195 let mut m = LogisticRegression::new(2.0);
196 m.fit(x.view(), y.view()).unwrap();
197 let p = m.predict_proba(x.view()).unwrap();
198 let r = &p - &y;
199 assert!(r.sum().abs() < 1e-9);
201 let g = 2.0 * x.t().dot(&r) + m.coef().unwrap();
202 assert!(g.iter().all(|v| v.abs() < 1e-9));
203 }
204
205 #[test]
206 fn rejects_single_class_and_bad_labels() {
207 let x = array![[0.0], [1.0]];
208 let mut m = LogisticRegression::default();
209 assert!(m.fit(x.view(), array![1.0, 1.0].view()).is_err());
210 assert!(m.fit(x.view(), array![0.0, 2.0].view()).is_err());
211 }
212
213 #[test]
214 fn separable_unpenalized_reports_non_convergence() {
215 let x = array![[0.0], [1.0], [2.0], [3.0]];
216 let y = array![0.0, 0.0, 1.0, 1.0];
217 let err = LogisticRegression::unpenalized()
218 .fit(x.view(), y.view())
219 .unwrap_err();
220 assert!(matches!(err, DmlError::Convergence(_) | DmlError::Singular));
221 }
222}