1use 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 LogisticRegression {
82 pub(super) fn params(&self) -> Option<Array1<f64>> {
84 let coef = self.coef.as_ref()?;
85 let mut beta = Array1::zeros(coef.len() + 1);
86 beta[0] = self.intercept;
87 beta.slice_mut(ndarray::s![1..]).assign(coef);
88 Some(beta)
89 }
90
91 pub(super) fn fit_from(
94 &mut self,
95 x: ArrayView2<'_, f64>,
96 y: ArrayView1<'_, f64>,
97 init: Option<&Array1<f64>>,
98 ) -> Result<()> {
99 self.fit_impl(x, y, None, init)
100 }
101
102 fn fit_impl(
105 &mut self,
106 x: ArrayView2<'_, f64>,
107 y: ArrayView1<'_, f64>,
108 weights: Option<ArrayView1<'_, f64>>,
109 init: Option<&Array1<f64>>,
110 ) -> Result<()> {
111 let (n, p) = x.dim();
112 let sw = match weights {
113 Some(w) if w.len() != n => {
114 return Err(DmlError::DimensionMismatch(
115 "LogisticRegression::fit_weighted weights length".into(),
116 ))
117 }
118 Some(w) if !w.iter().all(|&v| v.is_finite() && v >= 0.0) || w.sum() <= 0.0 => {
119 return Err(DmlError::InvalidArgument(
120 "sample weights must be finite, non-negative and not all zero".into(),
121 ))
122 }
123 Some(w) => w.to_owned(),
124 None => Array1::ones(n),
125 };
126 if y.len() != n {
127 return Err(DmlError::DimensionMismatch(
128 "LogisticRegression::fit x/y rows".into(),
129 ));
130 }
131 if !y.iter().all(|&v| v == 0.0 || v == 1.0) {
132 return Err(DmlError::InvalidArgument(
133 "LogisticRegression labels must be 0 or 1".into(),
134 ));
135 }
136 let n1 = y.sum();
137 if n1 == 0.0 || n1 == n as f64 {
138 return Err(DmlError::InvalidArgument(
139 "LogisticRegression needs both classes in the training data".into(),
140 ));
141 }
142 let lambda = if self.c.is_finite() {
143 1.0 / self.c
144 } else {
145 0.0
146 };
147 let xa = concatenate![Axis(1), Array2::ones((n, 1)), x];
149 let objective = |beta: &Array1<f64>| -> f64 {
150 let eta = xa.dot(beta);
151 let loss: f64 = eta
152 .iter()
153 .zip(y.iter())
154 .zip(sw.iter())
155 .map(|((&e, &t), &w)| w * (softplus(e) - t * e))
156 .sum();
157 loss + 0.5
158 * lambda
159 * beta
160 .slice(ndarray::s![1..])
161 .dot(&beta.slice(ndarray::s![1..]))
162 };
163
164 let mut beta = match init {
165 Some(b) if b.len() == p + 1 => b.clone(),
166 _ => {
167 let mean = sw.dot(&y) / sw.sum();
168 let mut b = Array1::<f64>::zeros(p + 1);
169 b[0] = (mean / (1.0 - mean)).ln();
170 b
171 }
172 };
173 let mut obj = objective(&beta);
174 let mut converged = false;
175 for _ in 0..self.max_iter {
176 let prob = xa.dot(&beta).mapv(sigmoid);
177 let mut grad = xa.t().dot(&(&sw * &(&prob - &y)));
178 let w = &sw * &prob.mapv(|q| q * (1.0 - q));
179 let mut hess = xa.t().dot(&(&xa * &w.insert_axis(Axis(1))));
180 for j in 1..=p {
181 grad[j] += lambda * beta[j];
182 hess[[j, j]] += lambda;
183 }
184 let step = cholesky_solve(&hess, &grad)?;
185 let mut t = 1.0;
187 let mut candidate = &beta - &step;
188 let mut cand_obj = objective(&candidate);
189 while cand_obj > obj && t > 1e-10 {
190 t *= 0.5;
191 candidate = &beta - &(t * &step);
192 cand_obj = objective(&candidate);
193 }
194 let change = (t * &step).iter().fold(0.0_f64, |m, v| m.max(v.abs()));
195 beta = candidate;
196 obj = cand_obj;
197 if change <= self.tol * beta.iter().fold(1.0_f64, |m, v| m.max(v.abs())) {
198 converged = true;
199 break;
200 }
201 }
202 if !converged || !beta.iter().all(|v| v.is_finite()) {
203 return Err(DmlError::Convergence(format!(
204 "LogisticRegression did not converge in {} Newton steps (separable classes?)",
205 self.max_iter
206 )));
207 }
208 self.intercept = beta[0];
209 self.coef = Some(beta.slice(ndarray::s![1..]).to_owned());
210 Ok(())
211 }
212}
213
214impl Classifier for LogisticRegression {
215 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
216 self.fit_from(x, y, None)
217 }
218
219 fn fit_weighted(
220 &mut self,
221 x: ArrayView2<'_, f64>,
222 y: ArrayView1<'_, f64>,
223 w: ArrayView1<'_, f64>,
224 ) -> Result<()> {
225 self.fit_impl(x, y, Some(w), None)
226 }
227
228 fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
229 let coef = self.coef.as_ref().ok_or(DmlError::NotFitted)?;
230 if x.ncols() != coef.len() {
231 return Err(DmlError::DimensionMismatch(
232 "LogisticRegression::predict_proba feature count".into(),
233 ));
234 }
235 Ok((x.dot(coef) + self.intercept).mapv(sigmoid))
236 }
237
238 fn fresh(&self) -> Box<dyn Classifier> {
239 Box::new(Self::new(self.c).tol(self.tol).max_iter(self.max_iter))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use ndarray::array;
247
248 #[test]
249 fn integer_weights_equal_repeated_rows() {
250 let x = array![
251 [0.0, 1.0],
252 [1.0, 0.5],
253 [2.0, -1.0],
254 [3.0, 0.0],
255 [1.5, 2.0],
256 [0.5, -0.5]
257 ];
258 let y = array![0.0, 0.0, 1.0, 1.0, 0.0, 1.0];
259 let w = array![1.0, 2.0, 1.0, 3.0, 1.0, 2.0];
260 let mut weighted = LogisticRegression::new(2.0).tol(1e-12);
261 weighted.fit_weighted(x.view(), y.view(), w.view()).unwrap();
262 let rows: Vec<usize> = (0..6)
263 .flat_map(|i| std::iter::repeat(i).take(w[i] as usize))
264 .collect();
265 let mut repeated = LogisticRegression::new(2.0).tol(1e-12);
266 repeated
267 .fit(
268 x.select(Axis(0), &rows).view(),
269 y.select(Axis(0), &rows).view(),
270 )
271 .unwrap();
272 let (a, b) = (
273 weighted.predict_proba(x.view()).unwrap(),
274 repeated.predict_proba(x.view()).unwrap(),
275 );
276 assert!(a.iter().zip(&b).all(|(p, q)| (p - q).abs() < 1e-10));
277 assert!(weighted
278 .fit_weighted(x.view(), y.view(), (-&w).view())
279 .is_err());
280 }
281
282 #[test]
283 fn gradient_is_zero_at_optimum() {
284 let x = array![
285 [0.0, 1.0],
286 [1.0, 0.5],
287 [2.0, -1.0],
288 [3.0, 0.0],
289 [1.5, 2.0],
290 [0.5, -0.5]
291 ];
292 let y = array![0.0, 0.0, 1.0, 1.0, 0.0, 1.0];
293 let mut m = LogisticRegression::new(2.0);
294 m.fit(x.view(), y.view()).unwrap();
295 let p = m.predict_proba(x.view()).unwrap();
296 let r = &p - &y;
297 assert!(r.sum().abs() < 1e-9);
299 let g = 2.0 * x.t().dot(&r) + m.coef().unwrap();
300 assert!(g.iter().all(|v| v.abs() < 1e-9));
301 }
302
303 #[test]
304 fn rejects_single_class_and_bad_labels() {
305 let x = array![[0.0], [1.0]];
306 let mut m = LogisticRegression::default();
307 assert!(m.fit(x.view(), array![1.0, 1.0].view()).is_err());
308 assert!(m.fit(x.view(), array![0.0, 2.0].view()).is_err());
309 }
310
311 #[test]
312 fn separable_unpenalized_reports_non_convergence() {
313 let x = array![[0.0], [1.0], [2.0], [3.0]];
314 let y = array![0.0, 0.0, 1.0, 1.0];
315 let err = LogisticRegression::unpenalized()
316 .fit(x.view(), y.view())
317 .unwrap_err();
318 assert!(matches!(err, DmlError::Convergence(_) | DmlError::Singular));
319 }
320}