1use ndarray::{Array1, ArrayView1, ArrayView2, Axis};
10
11use super::cv::{check_cv, complement, kfold_test_sets, map_folds, stratified_test_sets};
12use super::{Classifier, Regressor};
13use crate::data::gather;
14use crate::error::{DmlError, Result};
15
16fn argmin(scores: &[f64]) -> usize {
18 scores
19 .iter()
20 .enumerate()
21 .fold(
22 (0, f64::INFINITY),
23 |(bi, bv), (i, &v)| if v < bv { (i, v) } else { (bi, bv) },
24 )
25 .0
26}
27
28fn check_candidates(n: usize) -> Result<()> {
29 if n == 0 {
30 return Err(DmlError::InvalidArgument(
31 "a grid search needs at least one candidate".into(),
32 ));
33 }
34 Ok(())
35}
36
37pub struct GridSearchRegressor {
40 candidates: Vec<Box<dyn Regressor>>,
41 cv: usize,
42 best: Option<usize>,
43 scores: Vec<f64>,
44 fitted: Option<Box<dyn Regressor>>,
45}
46
47impl GridSearchRegressor {
48 pub fn new(candidates: Vec<Box<dyn Regressor>>) -> Self {
50 Self {
51 candidates,
52 cv: 5,
53 best: None,
54 scores: Vec::new(),
55 fitted: None,
56 }
57 }
58
59 pub fn cv(mut self, cv: usize) -> Self {
61 self.cv = cv;
62 self
63 }
64
65 pub fn best_index(&self) -> Option<usize> {
67 self.best
68 }
69
70 pub fn cv_scores(&self) -> &[f64] {
72 &self.scores
73 }
74
75 fn fit_impl(
76 &mut self,
77 x: ArrayView2<'_, f64>,
78 y: ArrayView1<'_, f64>,
79 w: Option<ArrayView1<'_, f64>>,
80 ) -> Result<()> {
81 check_candidates(self.candidates.len())?;
82 let n = x.nrows();
83 check_cv(n, self.cv)?;
84 let tests = kfold_test_sets(n, self.cv);
85 let fit = |c: &dyn Regressor, rows: &[usize]| -> Result<Box<dyn Regressor>> {
86 let mut m = c.fresh();
87 let (xt, yt) = (x.select(Axis(0), rows), gather(y, rows));
88 match w {
89 Some(w) => m.fit_weighted(xt.view(), yt.view(), gather(w, rows).view())?,
90 None => m.fit(xt.view(), yt.view())?,
91 }
92 Ok(m)
93 };
94 let mut scores = Vec::with_capacity(self.candidates.len());
95 for c in &self.candidates {
96 let fold_mse = map_folds(&tests, |test| {
97 let m = fit(c.as_ref(), &complement(n, test))?;
98 let pred = m.predict(x.select(Axis(0), test).view())?;
99 Ok(test
100 .iter()
101 .zip(&pred)
102 .map(|(&i, p)| (y[i] - p).powi(2))
103 .sum::<f64>()
104 / test.len() as f64)
105 })?;
106 scores.push(fold_mse.iter().sum::<f64>() / fold_mse.len() as f64);
107 }
108 let best = argmin(&scores);
109 let all: Vec<usize> = (0..n).collect();
110 self.fitted = Some(fit(self.candidates[best].as_ref(), &all)?);
111 self.best = Some(best);
112 self.scores = scores;
113 Ok(())
114 }
115}
116
117impl Regressor for GridSearchRegressor {
118 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
119 self.fit_impl(x, y, None)
120 }
121
122 fn fit_weighted(
124 &mut self,
125 x: ArrayView2<'_, f64>,
126 y: ArrayView1<'_, f64>,
127 w: ArrayView1<'_, f64>,
128 ) -> Result<()> {
129 self.fit_impl(x, y, Some(w))
130 }
131
132 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
133 self.fitted.as_ref().ok_or(DmlError::NotFitted)?.predict(x)
134 }
135
136 fn fresh(&self) -> Box<dyn Regressor> {
137 Box::new(Self::new(self.candidates.iter().map(|c| c.fresh()).collect()).cv(self.cv))
138 }
139}
140
141impl Clone for GridSearchRegressor {
142 fn clone(&self) -> Self {
144 Self::new(self.candidates.iter().map(|c| c.fresh()).collect()).cv(self.cv)
145 }
146}
147
148pub struct GridSearchClassifier {
151 candidates: Vec<Box<dyn Classifier>>,
152 cv: usize,
153 best: Option<usize>,
154 scores: Vec<f64>,
155 fitted: Option<Box<dyn Classifier>>,
156}
157
158impl GridSearchClassifier {
159 pub fn new(candidates: Vec<Box<dyn Classifier>>) -> Self {
160 Self {
161 candidates,
162 cv: 5,
163 best: None,
164 scores: Vec::new(),
165 fitted: None,
166 }
167 }
168
169 pub fn cv(mut self, cv: usize) -> Self {
171 self.cv = cv;
172 self
173 }
174
175 pub fn best_index(&self) -> Option<usize> {
176 self.best
177 }
178
179 pub fn cv_scores(&self) -> &[f64] {
181 &self.scores
182 }
183
184 fn fit_impl(
185 &mut self,
186 x: ArrayView2<'_, f64>,
187 y: ArrayView1<'_, f64>,
188 w: Option<ArrayView1<'_, f64>>,
189 ) -> Result<()> {
190 check_candidates(self.candidates.len())?;
191 let n = x.nrows();
192 check_cv(n, self.cv)?;
193 let tests = stratified_test_sets(y, self.cv)?;
194 let fit = |c: &dyn Classifier, rows: &[usize]| -> Result<Box<dyn Classifier>> {
195 let mut m = c.fresh();
196 let (xt, yt) = (x.select(Axis(0), rows), gather(y, rows));
197 match w {
198 Some(w) => m.fit_weighted(xt.view(), yt.view(), gather(w, rows).view())?,
199 None => m.fit(xt.view(), yt.view())?,
200 }
201 Ok(m)
202 };
203 let mut scores = Vec::with_capacity(self.candidates.len());
204 for c in &self.candidates {
205 let fold_loss = map_folds(&tests, |test| {
206 let m = fit(c.as_ref(), &complement(n, test))?;
207 let p = m.predict_proba(x.select(Axis(0), test).view())?;
208 let loss: f64 = test
209 .iter()
210 .zip(&p)
211 .map(|(&i, &q)| {
212 let q = q.clamp(1e-15, 1.0 - 1e-15);
213 -(y[i] * q.ln() + (1.0 - y[i]) * (1.0 - q).ln())
214 })
215 .sum();
216 Ok(loss / test.len() as f64)
217 })?;
218 scores.push(fold_loss.iter().sum::<f64>() / fold_loss.len() as f64);
219 }
220 let best = argmin(&scores);
221 let all: Vec<usize> = (0..n).collect();
222 self.fitted = Some(fit(self.candidates[best].as_ref(), &all)?);
223 self.best = Some(best);
224 self.scores = scores;
225 Ok(())
226 }
227}
228
229impl Classifier for GridSearchClassifier {
230 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
231 self.fit_impl(x, y, None)
232 }
233
234 fn fit_weighted(
235 &mut self,
236 x: ArrayView2<'_, f64>,
237 y: ArrayView1<'_, f64>,
238 w: ArrayView1<'_, f64>,
239 ) -> Result<()> {
240 self.fit_impl(x, y, Some(w))
241 }
242
243 fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
244 self.fitted
245 .as_ref()
246 .ok_or(DmlError::NotFitted)?
247 .predict_proba(x)
248 }
249
250 fn fresh(&self) -> Box<dyn Classifier> {
251 Box::new(Self::new(self.candidates.iter().map(|c| c.fresh()).collect()).cv(self.cv))
252 }
253}
254
255impl Clone for GridSearchClassifier {
256 fn clone(&self) -> Self {
258 Self::new(self.candidates.iter().map(|c| c.fresh()).collect()).cv(self.cv)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::learner::{Lasso, LogisticRegression, Ols};
266 use ndarray::Array2;
267
268 #[test]
269 fn picks_the_candidate_with_the_smallest_cv_error() {
270 let n = 200;
272 let x = Array2::from_shape_fn((n, 3), |(i, j)| ((i * 7 + j * 13) % 17) as f64 / 17.0 - 0.5);
273 let y: Array1<f64> = (0..n)
274 .map(|i| 3.0 * x[[i, 0]] + 0.01 * ((i % 5) as f64))
275 .collect();
276 let mut gs = GridSearchRegressor::new(vec![
277 Box::new(Lasso::new(1.0)),
278 Box::new(Ols::new()),
279 Box::new(Lasso::new(0.5)),
280 ]);
281 gs.fit(x.view(), y.view()).unwrap();
282 assert_eq!(gs.best_index(), Some(1));
283 assert_eq!(gs.cv_scores().len(), 3);
284 let p = gs.predict(x.view()).unwrap();
285 assert!(p.iter().zip(&y).all(|(a, b)| (a - b).abs() < 0.1));
286 }
287
288 #[test]
289 fn classifier_search_and_errors() {
290 let n = 200;
291 let x = Array2::from_shape_fn((n, 2), |(i, j)| ((i * 3 + j * 11) % 23) as f64 / 23.0 - 0.5);
292 let y: Array1<f64> = (0..n)
293 .map(|i| f64::from(x[[i, 0]] + 0.1 * x[[i, 1]] > 0.0))
294 .collect();
295 let mut gs = GridSearchClassifier::new(vec![
296 Box::new(LogisticRegression::new(0.001)),
297 Box::new(LogisticRegression::new(10.0)),
298 ]);
299 gs.fit(x.view(), y.view()).unwrap();
300 assert_eq!(gs.best_index(), Some(1));
301 assert!(GridSearchClassifier::new(vec![])
302 .fit(x.view(), y.view())
303 .is_err());
304 assert!(gs.fresh().predict_proba(x.view()).is_err());
305 }
306}